Compare commits

..

1 Commits

Author SHA1 Message Date
fallenbagel
166796804e refactor: attempt to fix ip forwarding issue with more logging 2024-06-13 00:50:01 +05:00
218 changed files with 16017 additions and 31315 deletions

View File

@@ -376,33 +376,6 @@
"contributions": [
"code"
]
},
{
"login": "j0srisk",
"name": "Joseph Risk",
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
"profile": "http://josephrisk.com",
"contributions": [
"code"
]
},
{
"login": "Loetwiek",
"name": "Loetwiek",
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
"profile": "https://github.com/Loetwiek",
"contributions": [
"code"
]
},
{
"login": "Fuochi",
"name": "Fuochi",
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
"profile": "https://github.com/Fuochi",
"contributions": [
"doc"
]
}
]
}

View File

@@ -5,7 +5,9 @@ module.exports = {
'eslint:recommended',
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
'plugin:jsx-a11y/recommended',
'plugin:@next/next/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:react/jsx-runtime',
'prettier',
],
parserOptions: {

View File

@@ -13,35 +13,20 @@ jobs:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04
container: node:20-alpine
container: node:18.18-alpine
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: sh
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
env:
HUSKY: 0
run: pnpm install
run: yarn
- name: Lint
run: pnpm lint
run: yarn lint
- name: Formatting
run: pnpm format:check
run: yarn format:check
- name: Build
run: pnpm build
run: yarn build
build_and_push:
name: Build & Publish Docker Images
@@ -75,7 +60,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}

View File

@@ -14,19 +14,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 9
- name: Cypress run
uses: cypress-io/github-action@v6
with:
build: pnpm cypress:build
start: pnpm start
build: yarn cypress:build
start: yarn start
wait-on: 'http://localhost:5055'
record: true
env:

View File

@@ -29,7 +29,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
@@ -26,23 +26,8 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: sh
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
run: yarn
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -60,6 +45,7 @@ jobs:
# architecture:
# - amd64
# - arm64
# - armhf
# steps:
# - name: Checkout Code
# uses: actions/checkout@v4

View File

@@ -30,6 +30,7 @@ jobs:
architecture:
- amd64
- arm64
- armhf
steps:
- name: Checkout Code
uses: actions/checkout@v4

1
.npmrc
View File

@@ -1 +0,0 @@
engine-strict=true

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS BUILD_IMAGE
FROM node:18.18-alpine AS BUILD_IMAGE
WORKDIR /app
@@ -10,24 +10,22 @@ RUN \
'linux/arm64' | 'linux/arm/v7') \
apk update && \
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
npm install --global node-gyp \
yarn global add node-gyp \
;; \
esac
Run npm install --global pnpm
COPY package.json pnpm-lock.yaml ./
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
COPY package.json yarn.lock ./
RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
COPY . ./
ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG}
RUN pnpm build
RUN yarn build
# remove development dependencies
RUN pnpm prune --prod --ignore-scripts
RUN yarn install --production --ignore-scripts --prefer-offline
RUN rm -rf src server .next/cache
@@ -36,7 +34,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:20-alpine
FROM node:18.18-alpine
# Metadata for Github Package Registry
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
@@ -49,6 +47,6 @@ RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
COPY --from=BUILD_IMAGE /app ./
ENTRYPOINT [ "/sbin/tini", "--" ]
CMD [ "pnpm", "start" ]
CMD [ "yarn", "start" ]
EXPOSE 5055

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:18.18-alpine
COPY . /app
WORKDIR /app

View File

@@ -53,7 +53,7 @@ https://hub.docker.com/r/fallenbagel/jellyseerr
Pre-requisites:
- Nodejs [v20](https://nodejs.org/en/download)
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
- Download/git clone the source code from the github (Either develop branch or main for stable)
@@ -73,7 +73,7 @@ _To set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` i
**Pre-requisites:**
- Nodejs [v20](https://nodejs.org/en/download)
- Nodejs [v18](https://nodejs.org/en/download/package-manager)
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on Debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`)
- Git
@@ -378,9 +378,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RemiRigal"><img src="https://avatars.githubusercontent.com/u/19256051?v=4?s=100" width="100px;" alt="RemiRigal"/><br /><sub><b>RemiRigal</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=RemiRigal" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
</tr>
</tbody>
</table>

25
babel.config.js Normal file
View File

@@ -0,0 +1,25 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
[
'next/babel',
{
'preset-env': {
useBuiltIns: 'entry',
corejs: '3',
},
},
],
],
plugins: [
[
'react-intl-auto',
{
removePrefix: 'src/',
},
],
],
};
};

View File

@@ -19,7 +19,6 @@
"region": "",
"originalLanguage": "",
"trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true,
"locale": "en"
},
@@ -38,17 +37,6 @@
],
"machineId": "test"
},
"jellyfin": {
"name": "",
"ip": "",
"port": 8096,
"useSsl": false,
"urlBase": "",
"externalHostname": "",
"jellyfinForgotPasswordUrl": "",
"libraries": [],
"serverId": ""
},
"tautulli": {},
"radarr": [],
"sonarr": [],
@@ -151,26 +139,11 @@
"sonarr-scan": {
"schedule": "0 30 4 * * *"
},
"plex-watchlist-sync": {
"schedule": "0 */10 * * * *"
},
"availability-sync": {
"schedule": "0 0 5 * * *"
},
"download-sync": {
"schedule": "0 * * * * *"
},
"download-sync-reset": {
"schedule": "0 0 1 * * *"
},
"jellyfin-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"jellyfin-full-scan": {
"schedule": "0 0 3 * * *"
},
"image-cache-cleanup": {
"schedule": "0 0 5 * * *"
}
}
}

View File

@@ -2,6 +2,6 @@ import './commands';
before(() => {
if (Cypress.env('SEED_DATABASE')) {
cy.exec('pnpm cypress:prepare');
cy.exec('yarn cypress:prepare');
}
});

View File

@@ -10,11 +10,7 @@ module.exports = {
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
},
images: {
remotePatterns: [
{ hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' },
{ hostname: '*', protocol: 'https' },
],
domains: ['image.tmdb.org'],
},
webpack(config) {
config.module.rules.push({

View File

@@ -3,27 +3,26 @@
"version": "0.1.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
"build:next": "next build",
"build": "pnpm build:next && pnpm build:server",
"build": "yarn build:next && yarn build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
"start": "NODE_ENV=production node dist/index.js",
"i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts",
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
"format": "prettier --loglevel warn --write --cache .",
"format:check": "prettier --check --cache .",
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
"typecheck": "yarn typecheck:server && yarn typecheck:client",
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
"typecheck:client": "tsc --noEmit",
"prepare": "husky install",
"cypress:open": "cypress open",
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
"cypress:build": "pnpm build && pnpm cypress:prepare"
"cypress:build": "yarn build && yarn cypress:prepare"
},
"repository": {
"type": "git",
@@ -35,7 +34,6 @@
"@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.1.10",
"@formatjs/intl-utils": "3.8.4",
"@formatjs/swc-plugin-experimental": "^0.4.0",
"@headlessui/react": "1.7.12",
"@heroicons/react": "2.0.16",
"@supercharge/request-ip": "1.2.0",
@@ -61,10 +59,11 @@
"express-openapi-validator": "4.13.8",
"express-rate-limit": "6.7.0",
"express-session": "1.17.3",
"formik": "^2.4.6",
"formik": "2.2.9",
"gravatar-url": "3.1.0",
"intl": "1.2.5",
"lodash": "4.17.21",
"next": "^14.2.4",
"next": "12.3.4",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
"node-schedule": "2.1.1",
@@ -72,13 +71,13 @@
"openpgp": "5.7.0",
"plex-api": "5.3.2",
"pug": "3.0.2",
"react": "^18.3.1",
"react": "18.2.0",
"react-ace": "10.1.0",
"react-animate-height": "2.1.2",
"react-aria": "3.23.0",
"react-dom": "^18.3.1",
"react-dom": "18.2.0",
"react-intersection-observer": "9.4.3",
"react-intl": "^6.6.8",
"react-intl": "6.2.10",
"react-markdown": "8.0.5",
"react-popper-tooltip": "4.4.2",
"react-select": "5.7.0",
@@ -90,10 +89,9 @@
"reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3",
"semver": "7.3.8",
"sharp": "^0.33.4",
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2",
"swr": "2.2.5",
"swr": "2.0.4",
"typeorm": "0.3.12",
"web-push": "3.5.0",
"winston": "3.8.2",
@@ -104,6 +102,7 @@
"zod": "3.20.6"
},
"devDependencies": {
"@babel/cli": "7.21.0",
"@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4",
"@semantic-release/changelog": "6.0.2",
@@ -124,8 +123,8 @@
"@types/node": "17.0.36",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.13",
@@ -137,13 +136,15 @@
"@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.13",
"babel-plugin-react-intl": "8.2.25",
"babel-plugin-react-intl-auto": "3.3.0",
"commitizen": "4.3.0",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "12.7.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.35.0",
"eslint-config-next": "^14.2.4",
"eslint-config-next": "12.3.4",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.7.1",
@@ -151,6 +152,7 @@
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1",
"husky": "8.0.3",
"lint-staged": "13.1.2",
"nodemon": "2.0.20",
@@ -166,12 +168,10 @@
"tsconfig-paths": "4.1.2",
"typescript": "4.9.5"
},
"engines": {
"node": "^20.0.0",
"pnpm": "^9.0.0"
},
"overrides": {
"resolutions": {
"sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/express-session": "1.17.6"
},
"config": {
@@ -237,7 +237,8 @@
],
"platforms": [
"linux/amd64",
"linux/arm64"
"linux/arm64",
"linux/arm/v7"
]
},
"@semantic-release/github"

26267
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -126,32 +126,37 @@ class JellyfinAPI extends ExternalAPI {
Password?: string,
ClientIP?: string
): Promise<JellyfinLoginResponse> {
const authenticate = async (useHeaders: boolean) => {
const headers =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
try {
const headers = ClientIP
? {
'X-Forwarded-For': ClientIP,
}
: {};
return this.post<JellyfinLoginResponse>(
logger.debug(`Logging in to Jellyfin server: ${this.jellyfinHost}`, {
label: 'Jellyfin API',
clientIp: ClientIP,
});
const authResponse = await this.post<JellyfinLoginResponse>(
'/Users/AuthenticateByName',
{
Username,
Username: Username,
Pw: Password,
},
{ headers }
{
headers: headers,
}
);
};
try {
return await authenticate(true);
return authResponse;
} catch (e) {
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
logger.error('Failed to login to Jellyfin server', {
label: 'Jellyfin API',
ip: ClientIP,
clientIp: ClientIP,
error: e,
});
}
try {
return await authenticate(false);
} catch (e) {
const status = e.response?.status;
const networkErrorCodes = new Set([
@@ -184,16 +189,6 @@ class JellyfinAPI extends ExternalAPI {
return;
}
public async getSystemInfo(): Promise<any> {
try {
const systemInfoResponse = await this.get<any>('/System/Info');
return systemInfoResponse;
} catch (e) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getServerName(): Promise<string> {
try {
const serverResponse = await this.get<JellyfinUserResponse>(

View File

@@ -3,7 +3,5 @@ export enum ApiErrorCode {
InvalidCredentials = 'INVALID_CREDENTIALS',
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
NotAdmin = 'NOT_ADMIN',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unknown = 'UNKNOWN',
}

View File

@@ -9,7 +9,6 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
import {
AfterLoad,
Column,
@@ -212,12 +211,15 @@ class Media {
} else {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: getHostname();
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;

View File

@@ -27,7 +27,6 @@ import type CacheableLookupType from 'cacheable-lookup';
import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
import { lookup } from 'dns';
import type { NextFunction, Request, Response } from 'express';
import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
@@ -55,19 +54,6 @@ app
const CacheableLookup = (await _importDynamic('cacheable-lookup'))
.default as typeof CacheableLookupType;
const cacheable = new CacheableLookup();
const originalLookup = cacheable.lookup;
// if hostname is localhost use dns.lookup instead of cacheable-lookup
cacheable.lookup = (...args: any) => {
const [hostname] = args;
if (hostname === 'localhost') {
lookup(...(args as Parameters<typeof lookup>));
} else {
originalLookup(...(args as Parameters<typeof originalLookup>));
}
};
cacheable.install(http.globalAgent);
cacheable.install(https.globalAgent);
@@ -146,7 +132,7 @@ app
try {
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
if (descriptor?.writable === true) {
(req as any).ip = getClientIp(req) ?? '';
req.ip = getClientIp(req) ?? '';
}
} catch (e) {
logger.error('Failed to attach the ip to the request', {

View File

@@ -16,7 +16,6 @@ import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
class AvailabilitySync {
public running = false;
@@ -85,7 +84,7 @@ class AvailabilitySync {
) {
if (admin) {
this.jellyfinClient = new JellyfinAPI(
getHostname(),
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);

View File

@@ -14,12 +14,7 @@ import {
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
interface PushoverImagePayload {
attachment_base64: string;
attachment_type: string;
}
interface PushoverPayload extends PushoverImagePayload {
interface PushoverPayload {
token: string;
user: string;
title: string;
@@ -48,36 +43,10 @@ class PushoverAgent
return true;
}
private async getImagePayload(
imageUrl: string
): Promise<Partial<PushoverImagePayload>> {
try {
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
});
const base64 = Buffer.from(response.data, 'binary').toString('base64');
const contentType = (
response.headers['Content-Type'] || response.headers['content-type']
)?.toString();
return {
attachment_base64: base64,
attachment_type: contentType,
};
} catch (e) {
logger.error('Error getting image payload', {
label: 'Notifications',
errorMessage: e.message,
response: e.response?.data,
});
return {};
}
}
private async getNotificationPayload(
private getNotificationPayload(
type: Notification,
payload: NotificationPayload
): Promise<Partial<PushoverPayload>> {
): Partial<PushoverPayload> {
const { applicationUrl, applicationTitle } = getSettings().main;
const title = payload.event ?? payload.subject;
@@ -153,16 +122,6 @@ class PushoverAgent
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
: undefined;
let attachment_base64;
let attachment_type;
if (payload.image) {
const imagePayload = await this.getImagePayload(payload.image);
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
attachment_base64 = imagePayload.attachment_base64;
attachment_type = imagePayload.attachment_type;
}
}
return {
title,
message,
@@ -170,8 +129,6 @@ class PushoverAgent
url_title,
priority,
html: 1,
attachment_base64,
attachment_type,
};
}
@@ -181,10 +138,7 @@ class PushoverAgent
): Promise<boolean> {
const settings = this.getSettings();
const endpoint = 'https://api.pushover.net/1/messages.json';
const notificationPayload = await this.getNotificationPayload(
type,
payload
);
const notificationPayload = this.getNotificationPayload(type, payload);
// Send system notification
if (

View File

@@ -12,7 +12,6 @@ import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname';
import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash';
@@ -595,10 +594,8 @@ class JellyfinScanner {
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
}
const hostname = getHostname();
this.jfClient = new JellyfinAPI(
hostname,
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);

View File

@@ -1,11 +1,10 @@
import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto';
import fs from 'fs';
import { merge } from 'lodash';
import path from 'path';
import webpush from 'web-push';
import { Permission } from './permissions';
export interface Library {
id: string;
@@ -39,10 +38,7 @@ export interface PlexSettings {
export interface JellyfinSettings {
name: string;
ip: string;
port: number;
useSsl?: boolean;
urlBase?: string;
hostname: string;
externalHostname?: string;
jellyfinForgotPasswordUrl?: string;
libraries: Library[];
@@ -134,6 +130,7 @@ interface FullPublicSettings extends PublicSettings {
region: string;
originalLanguage: string;
mediaServerType: number;
jellyfinHost?: string;
jellyfinExternalHost?: string;
jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string;
@@ -277,7 +274,7 @@ export type JobId =
| 'image-cache-cleanup'
| 'availability-sync';
export interface AllSettings {
interface AllSettings {
clientId: string;
vapidPublic: string;
vapidPrivate: string;
@@ -294,7 +291,7 @@ export interface AllSettings {
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/settings.json`
: path.join(__dirname, '../../../config/settings.json');
: path.join(__dirname, '../../config/settings.json');
class Settings {
private data: AllSettings;
@@ -334,10 +331,7 @@ class Settings {
},
jellyfin: {
name: '',
ip: '',
port: 8096,
useSsl: false,
urlBase: '',
hostname: '',
externalHostname: '',
jellyfinForgotPasswordUrl: '',
libraries: [],
@@ -553,6 +547,8 @@ class Settings {
region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
jellyfinExternalHost: this.jellyfin.externalHostname,
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,
@@ -641,11 +637,7 @@ class Settings {
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) {
const parsedJson = JSON.parse(data);
this.data = runMigrations(parsedJson);
this.data = merge(this.data, parsedJson);
this.data = merge(this.data, JSON.parse(data));
this.save();
}
return this;

View File

@@ -1,30 +0,0 @@
import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
const oldJellyfinSettings = settings.jellyfin;
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
const { hostname } = oldJellyfinSettings;
const protocolMatch = hostname.match(/^(https?):\/\//i);
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
delete oldJellyfinSettings.hostname;
if (urlMatch) {
const [, ip, , port, urlBase] = urlMatch;
settings.jellyfin = {
...settings.jellyfin,
ip,
port: port || (useSsl ? 443 : 80),
useSsl,
urlBase: urlBase ? urlBase.replace(/\/$/, '') : '',
};
}
}
if (settings.jellyfin && settings.jellyfin.hostname) {
delete settings.jellyfin.hostname;
}
return settings;
};
export default migrateHostname;

View File

@@ -1,21 +0,0 @@
import type { AllSettings } from '@server/lib/settings';
import fs from 'fs';
import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = (settings: AllSettings): AllSettings => {
const migrations = fs
.readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
// eslint-disable-next-line @typescript-eslint/no-var-requires
.map((file) => require(path.join(migrationsDir, file)).default);
let migrated = settings;
for (const migration of migrations) {
migrated = migration(migrated);
}
return migrated;
};

View File

@@ -11,7 +11,6 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
@@ -223,39 +222,30 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
username?: string;
password?: string;
hostname?: string;
port?: number;
urlBase?: string;
useSsl?: boolean;
email?: string;
};
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
settings.jellyfin.hostname !== ''
) {
return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username) {
return res.status(500).json({ error: 'You must provide an username' });
} else if (settings.jellyfin.ip !== '' && body.hostname) {
} else if (settings.jellyfin.hostname !== '' && body.hostname) {
return res
.status(500)
.json({ error: 'Jellyfin hostname already configured' });
} else if (settings.jellyfin.ip === '' && !body.hostname) {
} else if (settings.jellyfin.hostname === '' && !body.hostname) {
return res.status(500).json({ error: 'No hostname provided.' });
}
try {
const hostname =
settings.jellyfin.ip !== ''
? getHostname()
: getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
});
settings.jellyfin.hostname !== ''
? settings.jellyfin.hostname
: body.hostname ?? '';
const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -271,14 +261,17 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
'base64'
);
}
// First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
const jellyfinHost =
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const ip = req.ip;
let clientIp;
@@ -335,11 +328,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
const serverName = await jellyfinserver.getServerName();
settings.jellyfin.name = serverName;
settings.jellyfin.hostname = body.hostname ?? '';
settings.jellyfin.serverId = account.User.ServerId;
settings.jellyfin.ip = body.hostname ?? '';
settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.save();
startJobs();
@@ -454,12 +444,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
label: 'Auth',
error: e.errorCode,
status: e.statusCode,
hostname: getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
}),
hostname: body.hostname,
}
);
return next({

View File

@@ -12,7 +12,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
try {
const collection = await tmdb.getCollection({
collectionId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(

View File

@@ -166,7 +166,7 @@ discoverRoutes.get<{ language: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
originalLanguage: req.params.language,
});
@@ -211,7 +211,7 @@ discoverRoutes.get<{ genreId: string }>(
try {
const genres = await tmdb.getMovieGenres({
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const genre = genres.find(
@@ -224,7 +224,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
genre: req.params.genreId as string,
});
@@ -272,7 +272,7 @@ discoverRoutes.get<{ studioId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
studio: req.params.studioId as string,
});
@@ -322,7 +322,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
try {
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
primaryReleaseDateGte: date,
});
@@ -447,7 +447,7 @@ discoverRoutes.get<{ language: string }>(
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
originalLanguage: req.params.language,
});
@@ -492,7 +492,7 @@ discoverRoutes.get<{ genreId: string }>(
try {
const genres = await tmdb.getTvGenres({
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const genre = genres.find(
@@ -505,7 +505,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
genre: req.params.genreId,
});
@@ -553,7 +553,7 @@ discoverRoutes.get<{ networkId: string }>(
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
network: Number(req.params.networkId),
});
@@ -603,7 +603,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
try {
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
firstAirDateGte: date,
});
@@ -643,7 +643,7 @@ discoverRoutes.get('/trending', async (req, res, next) => {
try {
const data = await tmdb.getAllTrending({
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -698,7 +698,7 @@ discoverRoutes.get<{ keywordId: string }>(
const data = await tmdb.getMoviesByKeyword({
keywordId: Number(req.params.keywordId),
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -743,7 +743,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getMovieGenres({
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
await Promise.all(
@@ -787,7 +787,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getTvGenres({
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
await Promise.all(

View File

@@ -237,7 +237,7 @@ router.get('/genres/movie', isAuthenticated(), async (req, res, next) => {
try {
const genres = await tmdb.getMovieGenres({
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(genres);
@@ -258,7 +258,7 @@ router.get('/genres/tv', isAuthenticated(), async (req, res, next) => {
try {
const genres = await tmdb.getTvGenres({
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(genres);

View File

@@ -17,7 +17,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
try {
const tmdbMovie = await tmdb.getMovie({
movieId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
@@ -43,7 +43,7 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => {
const results = await tmdb.getMovieRecommendations({
movieId: Number(req.params.id),
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -85,7 +85,7 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
const results = await tmdb.getMovieSimilar({
movieId: Number(req.params.id),
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(

View File

@@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => {
try {
const person = await tmdb.getPerson({
personId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(mapPersonDetails(person));
} catch (e) {
@@ -38,7 +38,7 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
try {
const combinedCredits = await tmdb.getPersonCombinedCredits({
personId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const castMedia = await Media.getRelatedMedia(

View File

@@ -20,7 +20,7 @@ searchRoutes.get('/', async (req, res, next) => {
.match(searchProvider.pattern) as RegExpMatchArray;
results = await searchProvider.search({
id,
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
query: queryString,
});
} else {
@@ -29,7 +29,7 @@ searchRoutes.get('/', async (req, res, next) => {
results = await tmdb.searchMulti({
query: queryString,
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
}

View File

@@ -2,7 +2,6 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
@@ -25,10 +24,8 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover';
import { ApiError } from '@server/types/error';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
@@ -255,59 +252,11 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
res.status(200).json(settings.jellyfin);
});
settingsRoutes.post('/jellyfin', async (req, res, next) => {
const userRepository = getRepository(User);
settingsRoutes.post('/jellyfin', (req, res) => {
const settings = getSettings();
try {
const admin = await userRepository.findOneOrFail({
where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const tempJellyfinSettings = { ...settings.jellyfin, ...req.body };
const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings),
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
const result = await jellyfinClient.getSystemInfo();
if (!result?.Id) {
throw new ApiError(result?.status, ApiErrorCode.InvalidUrl);
}
Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName;
settings.save();
} catch (e) {
if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', {
label: 'API',
status: e.statusCode,
errorMessage: ApiErrorCode.InvalidUrl,
});
return next({
status: e.statusCode,
message: ApiErrorCode.InvalidUrl,
});
} else {
logger.error('Something went wrong', {
label: 'API',
errorMessage: e.message,
});
return next({
status: e.statusCode ?? 500,
message: ApiErrorCode.Unknown,
});
}
}
settings.jellyfin = merge(settings.jellyfin, req.body);
settings.save();
return res.status(200).json(settings.jellyfin);
});
@@ -323,7 +272,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
getHostname(),
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
@@ -339,13 +288,10 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
// Automatic Library grouping is not supported when user views are used to get library
if (account.Configuration.GroupedFolders.length > 0) {
return next({
status: 501,
message: ApiErrorCode.SyncErrorGroupedFolders,
});
return next({ status: 501, message: 'SYNC_ERROR_GROUPED_FOLDERS' });
}
return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
return next({ status: 404, message: 'SYNC_ERROR_NO_LIBRARIES' });
}
const newLibraries: Library[] = libraries.map((library) => {
@@ -376,12 +322,16 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
});
settingsRoutes.get('/jellyfin/users', async (req, res) => {
const { externalHostname } = getSettings().jellyfin;
const jellyfinHost =
const settings = getSettings();
const { hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: getHostname();
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -389,6 +339,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);

View File

@@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
try {
const tv = await tmdb.getTvShow({
tvId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getMedia(tv.id, MediaType.TV);
@@ -40,7 +40,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
const season = await tmdb.getTvSeason({
tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
return res.status(200).json(mapSeasonWithEpisodes(season));
@@ -65,7 +65,7 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => {
const results = await tmdb.getTvRecommendations({
tvId: Number(req.params.id),
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(
@@ -106,7 +106,7 @@ tvRoutes.get('/:id/similar', async (req, res, next) => {
const results = await tmdb.getTvSimilar({
tvId: Number(req.params.id),
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
language: req.locale ?? (req.query.language as string),
});
const media = await Media.getRelatedMedia(

View File

@@ -20,7 +20,6 @@ import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
@@ -497,6 +496,7 @@ router.post(
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
@@ -504,14 +504,15 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin;
const hostname = getHostname();
const jellyfinHost =
const { hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers();

View File

@@ -5,7 +5,6 @@
"module": "commonjs",
"outDir": "../dist",
"noEmit": false,
"incremental": true,
"baseUrl": ".",
"paths": {
"@server/*": ["*"]

View File

@@ -1,18 +0,0 @@
import { getSettings } from '@server/lib/settings';
interface HostnameParams {
useSsl?: boolean;
ip?: string;
port?: number;
urlBase?: string;
}
export const getHostname = (params?: HostnameParams): string => {
const settings = params ? params : getSettings().jellyfin;
const { useSsl, ip, port, urlBase } = settings;
const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`;
return hostname;
};

View File

@@ -12,7 +12,7 @@ confinement: strict
architectures:
- build-on: amd64
- build-on: arm64
# - build-on: armhf
- build-on: armhf
parts:
jellyseerr:
@@ -27,12 +27,12 @@ parts:
- automake
- python-gi
- python-gi-dev
# - on armhf:
# - libatomic1
# - build-essential
# - automake
# - python-gi
# - python-gi-dev
- on armhf:
- libatomic1
- build-essential
- automake
- python-gi
- python-gi-dev
source: .
override-pull: |
snapcraftctl pull
@@ -75,7 +75,7 @@ parts:
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
# Set Node.js version
NODE_MAJOR=20
NODE_MAJOR=18
# Add Node.js repository to sources list
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
@@ -97,9 +97,9 @@ parts:
cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/
# Remove .github and gitbook as it will fail snap lint
rm -rf $SNAPCRAFT_PART_INSTALL/.github
# stage-packages:
# - on armhf:
# - libatomic1
stage-packages:
- on armhf:
- libatomic1
stage: [.next, ./*]
prime: [.next, ./*]

View File

@@ -1,8 +1,7 @@
import Badge from '@app/components/Common/Badge';
import defineMessages from '@app/utils/defineMessages';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
const messages = defineMessages('components.AirDateBadge', {
const messages = defineMessages({
airedrelative: 'Aired {relativeTime}',
airsrelative: 'Airing {relativeTime}',
});

View File

@@ -1,9 +1,8 @@
import Alert from '@app/components/Common/Alert';
import defineMessages from '@app/utils/defineMessages';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.AppDataWarning', {
const messages = defineMessages({
dockerVolumeMissingDescription:
'The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
});

View File

@@ -10,7 +10,6 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
@@ -19,10 +18,10 @@ import { uniq } from 'lodash';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.CollectionDetails', {
const messages = defineMessages({
overview: 'Overview',
numberofmovies: '{count} Movies',
requestcollection: 'Request Collection',
@@ -167,9 +166,10 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<Link
href={`/discover/movies/genre/${genreId}`}
key={`genre-${genreId}`}
className="hover:underline"
>
{genres.find((g) => g.id === genreId)?.name}
<a className="hover:underline">
{genres.find((g) => g.id === genreId)?.name}
</a>
</Link>
))
.reduce((prev, curr) => (
@@ -195,8 +195,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
layout="fill"
objectFit="cover"
priority
/>
<div
@@ -229,8 +229,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
style={{ width: '100%', height: 'auto' }}
layout="responsive"
width={600}
height={900}
priority

View File

@@ -1,3 +1,4 @@
import type * as React from 'react';
import { useState } from 'react';
import AnimateHeight from 'react-animate-height';

View File

@@ -93,12 +93,13 @@ const Badge = (
);
} else if (href) {
return (
<Link
href={href}
className={badgeStyle.join(' ')}
ref={ref as React.Ref<HTMLAnchorElement>}
>
{children}
<Link href={href}>
<a
className={badgeStyle.join(' ')}
ref={ref as React.Ref<HTMLAnchorElement>}
>
{children}
</a>
</Link>
);
} else {

View File

@@ -64,8 +64,8 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
className="absolute inset-0 h-full w-full"
alt=""
src={imageUrl}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
layout="fill"
objectFit="cover"
{...overrides}
/>
<div

View File

@@ -125,8 +125,8 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<CachedImage
alt=""
src={backdrop}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
layout="fill"
objectFit="cover"
priority
/>
<div

View File

@@ -55,14 +55,15 @@ const SettingsLink = ({
}
return (
<Link
href={route}
className={`${linkClasses} ${
currentPath.match(regex) ? activeLinkColor : inactiveLinkColor
}`}
aria-current="page"
>
{children}
<Link href={route}>
<a
className={`${linkClasses} ${
currentPath.match(regex) ? activeLinkColor : inactiveLinkColor
}`}
aria-current="page"
>
{children}
</a>
</Link>
);
};

View File

@@ -12,39 +12,40 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
const [isHovered, setHovered] = useState(false);
return (
<Link
href={url}
className={`relative flex h-32 w-56 transform-gpu cursor-pointer items-center justify-center p-8 shadow ring-1 transition duration-300 ease-in-out sm:h-36 sm:w-72 ${
isHovered
? 'scale-105 bg-gray-700 ring-gray-500'
: 'scale-100 bg-gray-800 ring-gray-700'
} rounded-xl`}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
<Link href={url}>
<a
className={`relative flex h-32 w-56 transform-gpu cursor-pointer items-center justify-center p-8 shadow ring-1 transition duration-300 ease-in-out sm:h-36 sm:w-72 ${
isHovered
? 'scale-105 bg-gray-700 ring-gray-500'
: 'scale-100 bg-gray-800 ring-gray-700'
} rounded-xl`}
onMouseEnter={() => {
setHovered(true);
}
}}
role="link"
tabIndex={0}
>
<div className="relative h-full w-full">
<CachedImage
src={image}
alt={name}
className="relative z-40 h-full w-full"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
fill
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setHovered(true);
}
}}
role="link"
tabIndex={0}
>
<div className="relative h-full w-full">
<CachedImage
src={image}
alt={name}
className="relative z-40 h-full w-full"
layout="fill"
objectFit="contain"
/>
</div>
<div
className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900'
}`}
/>
</div>
<div
className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900'
}`}
/>
</a>
</Link>
);
};

View File

@@ -4,7 +4,6 @@ import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider';
import { WatchProviderSelector } from '@app/components/Selector';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import defineMessages from '@app/utils/defineMessages';
import type {
TmdbCompanySearchResponse,
TmdbGenre,
@@ -17,12 +16,12 @@ import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import AsyncSelect from 'react-select/async';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
const messages = defineMessages('components.Discover.CreateSlider', {
const messages = defineMessages({
addSlider: 'Add Slider',
editSlider: 'Edit Slider',
slidernameplaceholder: 'Slider Name',

View File

@@ -4,12 +4,11 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverMovieGenre', {
const messages = defineMessages({
genreMovies: '{genre} Movies',
});

View File

@@ -4,13 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverMovieKeyword', {
const messages = defineMessages({
keywordMovies: '{keywordTitle} Movies',
});

View File

@@ -4,12 +4,11 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverMovieLanguage', {
const messages = defineMessages({
languageMovies: '{language} Movies',
});

View File

@@ -11,15 +11,14 @@ import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverMovies', {
const messages = defineMessages({
discovermovies: 'Movies',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',

View File

@@ -4,14 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TvNetwork } from '@server/models/common';
import type { TvResult } from '@server/models/Search';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverNetwork', {
const messages = defineMessages({
networkSeries: '{network} Series',
});
@@ -49,11 +47,10 @@ const DiscoverTvNetwork = () => {
<Header>
{firstResultData?.network.logoPath ? (
<div className="mb-6 flex justify-center">
<Image
<img
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
alt={firstResultData.network.name}
className="max-h-24 sm:max-h-32"
fill
/>
</div>
) : (

View File

@@ -8,7 +8,6 @@ import CreateSlider from '@app/components/Discover/CreateSlider';
import GenreTag from '@app/components/GenreTag';
import KeywordTag from '@app/components/KeywordTag';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import {
ArrowUturnLeftIcon,
@@ -23,10 +22,10 @@ import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages('components.Discover.DiscoverSliderEdit', {
const messages = defineMessages({
deletesuccess: 'Sucessfully deleted slider.',
deletefail: 'Failed to delete slider.',
remove: 'Remove',

View File

@@ -4,14 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { ProductionCompany } from '@server/models/common';
import type { MovieResult } from '@server/models/Search';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverStudio', {
const messages = defineMessages({
studioMovies: '{studio} Movies',
});
@@ -49,11 +47,10 @@ const DiscoverMovieStudio = () => {
<Header>
{firstResultData?.studio.logoPath ? (
<div className="mb-6 flex justify-center">
<Image
<img
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
alt={firstResultData.studio.name}
className="max-h-24 sm:max-h-32"
fill
/>
</div>
) : (

View File

@@ -11,15 +11,14 @@ import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverTv', {
const messages = defineMessages({
discovertv: 'Series',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',

View File

@@ -4,12 +4,11 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverTvGenre', {
const messages = defineMessages({
genreSeries: '{genre} Series',
});

View File

@@ -4,13 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverTvKeyword', {
const messages = defineMessages({
keywordSeries: '{keywordTitle} Series',
});

View File

@@ -4,12 +4,11 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverTvLanguage', {
const messages = defineMessages({
languageSeries: '{language} Series',
});

View File

@@ -3,11 +3,12 @@ import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.DiscoverTvUpcoming', {});
const messages = defineMessages({
upcomingtv: 'Upcoming Series',
});
const DiscoverTvUpcoming = () => {
const intl = useIntl();

View File

@@ -4,13 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import { useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.DiscoverWatchlist', {
const messages = defineMessages({
discoverwatchlist: 'Your Watchlist',
watchlist: 'Plex Watchlist',
});
@@ -59,8 +58,8 @@ const DiscoverWatchlist = () => {
<Header
subtext={
router.query.userId ? (
<Link href={`/users/${user?.id}`} className="hover:underline">
{user?.displayName}
<Link href={`/users/${user?.id}`}>
<a className="hover:underline">{user?.displayName}</a>
</Link>
) : (
''

View File

@@ -15,12 +15,11 @@ import {
useBatchUpdateQueryParams,
useUpdateQueryParams,
} from '@app/hooks/useUpdateQueryParams';
import defineMessages from '@app/utils/defineMessages';
import { XCircleIcon } from '@heroicons/react/24/outline';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import Datepicker from 'react-tailwindcss-datepicker-sct';
const messages = defineMessages('components.Discover.FilterSlideover', {
const messages = defineMessages({
filters: 'Filters',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',

View File

@@ -4,12 +4,11 @@ import PageTitle from '@app/components/Common/PageTitle';
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Discover.MovieGenreList', {
const messages = defineMessages({
moviegenres: 'Movie Genres',
});

View File

@@ -1,15 +1,14 @@
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider';
import defineMessages from '@app/utils/defineMessages';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import React from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Discover.MovieGenreSlider', {
const messages = defineMessages({
moviegenres: 'Movie Genres',
});
@@ -26,9 +25,11 @@ const MovieGenreSlider = () => {
return (
<>
<div className="slider-header">
<Link href="/discover/movies/genres" className="slider-title">
<span>{intl.formatMessage(messages.moviegenres)}</span>
<ArrowRightCircleIcon />
<Link href="/discover/movies/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.moviegenres)}</span>
<ArrowRightCircleIcon />
</a>
</Link>
</div>
<Slider

View File

@@ -1,9 +1,8 @@
import CompanyCard from '@app/components/CompanyCard';
import Slider from '@app/components/Slider';
import defineMessages from '@app/utils/defineMessages';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.NetworkSlider', {
const messages = defineMessages({
networks: 'Networks',
});

View File

@@ -1,14 +1,13 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Discover.PlexWatchlistSlider', {
const messages = defineMessages({
plexwatchlist: 'Your Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
@@ -40,9 +39,11 @@ const PlexWatchlistSlider = () => {
return (
<>
<div className="slider-header">
<Link href="/discover/watchlist" className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowRightCircleIcon />
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowRightCircleIcon />
</a>
</Link>
</div>
<Slider

View File

@@ -24,9 +24,11 @@ const RecentRequestsSlider = () => {
return (
<>
<div className="slider-header">
<Link href="/requests?filter=all" className="slider-title">
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
<ArrowRightCircleIcon />
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
<ArrowRightCircleIcon />
</a>
</Link>
</div>
<Slider

View File

@@ -1,12 +1,11 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Discover.RecentlyAddedSlider', {
const messages = defineMessages({
recentlyAdded: 'Recently Added',
});

View File

@@ -1,9 +1,8 @@
import CompanyCard from '@app/components/CompanyCard';
import Slider from '@app/components/Slider';
import defineMessages from '@app/utils/defineMessages';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover.StudioSlider', {
const messages = defineMessages({
studios: 'Studios',
});

View File

@@ -3,15 +3,14 @@ import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type {
MovieResult,
PersonResult,
TvResult,
} from '@server/models/Search';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover', {
const messages = defineMessages({
trending: 'Trending',
});

View File

@@ -4,12 +4,11 @@ import PageTitle from '@app/components/Common/PageTitle';
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Discover.TvGenreList', {
const messages = defineMessages({
seriesgenres: 'Series Genres',
});

View File

@@ -1,15 +1,14 @@
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider';
import defineMessages from '@app/utils/defineMessages';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import React from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Discover.TvGenreSlider', {
const messages = defineMessages({
tvgenres: 'Series Genres',
});
@@ -26,9 +25,11 @@ const TvGenreSlider = () => {
return (
<>
<div className="slider-header">
<Link href="/discover/tv/genres" className="slider-title">
<span>{intl.formatMessage(messages.tvgenres)}</span>
<ArrowRightCircleIcon />
<Link href="/discover/tv/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.tvgenres)}</span>
<ArrowRightCircleIcon />
</a>
</Link>
</div>
<Slider

View File

@@ -3,11 +3,10 @@ import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { MovieResult } from '@server/models/Search';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Discover', {
const messages = defineMessages({
upcomingmovies: 'Upcoming Movies',
});

View File

@@ -1,5 +1,5 @@
import defineMessages from '@app/utils/defineMessages';
import type { ParsedUrlQuery } from 'querystring';
import { defineMessages } from 'react-intl';
import { z } from 'zod';
type AvailableColors =
@@ -66,7 +66,7 @@ export const genreColorMap: Record<number, [string, string]> = {
10768: colorTones.darkred, // War & Politics
};
export const sliderTitles = defineMessages('components.Discover', {
export const sliderTitles = defineMessages({
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',

View File

@@ -17,7 +17,6 @@ import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import {
ArrowDownOnSquareIcon,
@@ -30,11 +29,11 @@ import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Discover', {
const messages = defineMessages({
discover: 'Discover',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',

View File

@@ -1,10 +1,9 @@
import Badge from '@app/components/Common/Badge';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
const messages = defineMessages('components.DownloadBlock', {
const messages = defineMessages({
estimatedtime: 'Estimated {time}',
formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}',
});

View File

@@ -14,41 +14,37 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
const [isHovered, setHovered] = useState(false);
return (
<Link
href={url}
className={`relative flex h-32 items-center justify-center sm:h-36 ${
canExpand ? 'w-full' : 'w-56 sm:w-72'
} transform-gpu cursor-pointer p-8 shadow ring-1 transition duration-300 ease-in-out ${
isHovered
? 'scale-105 bg-gray-700 bg-opacity-100 ring-gray-500'
: 'scale-100 bg-gray-800 bg-opacity-80 ring-gray-700'
} overflow-hidden rounded-xl bg-cover bg-center`}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
<Link href={url}>
<a
className={`relative flex h-32 items-center justify-center sm:h-36 ${
canExpand ? 'w-full' : 'w-56 sm:w-72'
} transform-gpu cursor-pointer p-8 shadow ring-1 transition duration-300 ease-in-out ${
isHovered
? 'scale-105 bg-gray-700 bg-opacity-100 ring-gray-500'
: 'scale-100 bg-gray-800 bg-opacity-80 ring-gray-700'
} overflow-hidden rounded-xl bg-cover bg-center`}
onMouseEnter={() => {
setHovered(true);
}
}}
role="link"
tabIndex={0}
>
<CachedImage
src={image}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
/>
<div
className={`absolute inset-0 z-10 h-full w-full bg-gray-800 transition duration-300 ${
isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
}`}
/>
<div className="relative z-20 w-full truncate whitespace-normal text-center text-2xl font-bold text-white sm:text-3xl">
{name}
</div>
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setHovered(true);
}
}}
role="link"
tabIndex={0}
>
<CachedImage src={image} alt="" layout="fill" objectFit="cover" />
<div
className={`absolute inset-0 z-10 h-full w-full bg-gray-800 transition duration-300 ${
isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
}`}
/>
<div className="relative z-20 w-full truncate whitespace-normal text-center text-2xl font-bold text-white sm:text-3xl">
{name}
</div>
</a>
</Link>
);
};

View File

@@ -45,9 +45,10 @@ const IssueBlock = ({ issue }: IssueBlockProps) => {
? '/profile'
: `/users/${issue.createdBy.id}`
}
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
>
{issue.createdBy.displayName}
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{issue.createdBy.displayName}
</a>
</Link>
</span>
</div>
@@ -63,7 +64,7 @@ const IssueBlock = ({ issue }: IssueBlockProps) => {
</div>
</div>
<div className="ml-2 flex flex-shrink-0 flex-wrap">
<Link href={`/issues/${issue.id}`} passHref legacyBehavior>
<Link href={`/issues/${issue.id}`} passHref>
<Button buttonType="primary" as="a">
<EyeIcon />
</Button>

View File

@@ -1,20 +1,18 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
import { Fragment, useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown';
import * as Yup from 'yup';
const messages = defineMessages('components.IssueDetails.IssueComment', {
const messages = defineMessages({
postedby: 'Posted {relativeTime} by {username}',
postedbyedited: 'Posted {relativeTime} by {username} (Edited)',
delete: 'Delete Comment',
@@ -86,13 +84,13 @@ const IssueComment = ({
</Modal>
</Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<Image
src={comment.user.avatar}
alt=""
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={40}
height={40}
/>
<a>
<img
src={comment.user.avatar}
alt=""
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</a>
</Link>
<div className="relative flex-1">
<div className="w-full rounded-md shadow ring-1 ring-gray-500">
@@ -244,9 +242,10 @@ const IssueComment = ({
href={
isActiveUser ? '/profile' : `/users/${comment.user.id}`
}
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
>
{comment.user.displayName}
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{comment.user.displayName}
</a>
</Link>
),
relativeTime: (

View File

@@ -1,15 +1,14 @@
import Button from '@app/components/Common/Button';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown';
const messages = defineMessages('components.IssueDetails.IssueDescription', {
const messages = defineMessages({
description: 'Description',
edit: 'Edit Description',
deleteissue: 'Delete Issue',

View File

@@ -12,7 +12,6 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import {
ChatBubbleOvalLeftEllipsisIcon,
@@ -30,16 +29,15 @@ import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
const messages = defineMessages('components.IssueDetails', {
const messages = defineMessages({
openedby: '#{issueId} opened {relativeTime} by {username}',
closeissue: 'Close Issue',
closeissueandcomment: 'Close with Comment',
@@ -212,8 +210,8 @@ const IssueDetails = () => {
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
layout="fill"
objectFit="cover"
priority
/>
<div
@@ -234,8 +232,7 @@ const IssueDetails = () => {
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
style={{ width: '100%', height: 'auto' }}
layout="responsive"
width={600}
height={900}
priority
@@ -259,9 +256,8 @@ const IssueDetails = () => {
href={`/${
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
}/${data.id}`}
className="hover:underline"
>
{title}
<a className="hover:underline">{title}</a>
</Link>{' '}
{releaseYear && (
<span className="media-year">({releaseYear.slice(0, 4)})</span>
@@ -277,18 +273,17 @@ const IssueDetails = () => {
? '/profile'
: `/users/${issueData.createdBy.id}`
}
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
>
<Image
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
src={issueData.createdBy.avatar}
alt=""
width={20}
height={20}
/>
<span className="font-semibold text-gray-100 transition duration-300 group-hover:text-white group-hover:underline">
{issueData.createdBy.displayName}
</span>
<a className="group ml-1 inline-flex h-full items-center xl:ml-1.5">
<img
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
src={issueData.createdBy.avatar}
alt=""
/>
<span className="font-semibold text-gray-100 transition duration-300 group-hover:text-white group-hover:underline">
{issueData.createdBy.displayName}
</span>
</a>
</Link>
),
relativeTime: (

View File

@@ -4,20 +4,18 @@ import CachedImage from '@app/components/Common/CachedImage';
import { issueOptions } from '@app/components/IssueModal/constants';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { EyeIcon } from '@heroicons/react/24/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link';
import { useInView } from 'react-intersection-observer';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.IssueList.IssueItem', {
const messages = defineMessages({
openeduserdate: '{date} by {user}',
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
episodes: '{episodeCount, plural, one {Episode} other {Episodes}}',
@@ -115,8 +113,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
<CachedImage
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
@@ -135,20 +133,21 @@ const IssueItem = ({ issue }: IssueItemProps) => {
? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}`
}
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
>
<CachedImage
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
width={600}
height={900}
/>
<a className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105">
<CachedImage
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
objectFit="cover"
/>
</a>
</Link>
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div className="pt-0.5 text-xs text-white sm:pt-1">
@@ -163,9 +162,10 @@ const IssueItem = ({ issue }: IssueItemProps) => {
? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}`
}
className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"
>
{isMovie(title) ? title.title : title.name}
<a className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
{problemSeasonEpisodeLine.length > 0 && (
<div className="card-field">
@@ -222,20 +222,17 @@ const IssueItem = ({ issue }: IssueItemProps) => {
/>
),
user: (
<Link
href={`/users/${issue.createdBy.id}`}
className="group flex items-center truncate"
>
<Image
src={issue.createdBy.avatar}
alt=""
className="avatar-sm ml-1.5 object-cover"
width={20}
height={20}
/>
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{issue.createdBy.displayName}
</span>
<Link href={`/users/${issue.createdBy.id}`}>
<a className="group flex items-center truncate">
<img
src={issue.createdBy.avatar}
alt=""
className="avatar-sm ml-1.5 object-cover"
/>
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{issue.createdBy.displayName}
</span>
</a>
</Link>
),
})}
@@ -262,7 +259,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
<span className="w-full">
<Link href={`/issues/${issue.id}`} passHref legacyBehavior>
<Link href={`/issues/${issue.id}`} passHref>
<Button as="a" className="w-full" buttonType="primary">
<EyeIcon />
<span>{intl.formatMessage(messages.viewissue)}</span>

View File

@@ -5,7 +5,6 @@ import PageTitle from '@app/components/Common/PageTitle';
import IssueItem from '@app/components/IssueList/IssueItem';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import {
BarsArrowDownIcon,
ChevronLeftIcon,
@@ -15,10 +14,10 @@ import {
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.IssueList', {
const messages = defineMessages({
issues: 'Issues',
sortAdded: 'Most Recent',
sortModified: 'Last Modified',

View File

@@ -4,7 +4,6 @@ import { issueOptions } from '@app/components/IssueModal/constants';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { RadioGroup } from '@headlessui/react';
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media';
@@ -14,12 +13,12 @@ import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Formik } from 'formik';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
const messages = defineMessages('components.IssueModal.CreateIssueModal', {
const messages = defineMessages({
validationMessageRequired: 'You must provide a description',
whatswrong: "What's wrong?",
providedetail:
@@ -119,7 +118,7 @@ const CreateIssueModal = ({
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</div>
<Link href={`/issues/${newIssue.data.id}`} legacyBehavior>
<Link href={`/issues/${newIssue.data.id}`}>
<Button as="a" className="mt-4">
<span>{intl.formatMessage(messages.toastviewissue)}</span>
<ArrowRightCircleIcon />

View File

@@ -1,8 +1,8 @@
import defineMessages from '@app/utils/defineMessages';
import { IssueType } from '@server/constants/issue';
import type { MessageDescriptor } from 'react-intl';
import { defineMessages } from 'react-intl';
const messages = defineMessages('components.IssueModal', {
const messages = defineMessages({
issueAudio: 'Audio',
issueVideo: 'Video',
issueSubtitles: 'Subtitle',

View File

@@ -1,14 +1,13 @@
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import type { Language } from '@server/lib/settings';
import { sortBy } from 'lodash';
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import type { CSSObjectWithLabel } from 'react-select';
import Select from 'react-select';
import useSWR from 'swr';
const messages = defineMessages('components.LanguageSelector', {
const messages = defineMessages({
originalLanguageDefault: 'All Languages',
languageServerDefault: 'Default ({language})',
});

View File

@@ -2,13 +2,12 @@ import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext';
import useClickOutside from '@app/hooks/useClickOutside';
import useLocale from '@app/hooks/useLocale';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { LanguageIcon } from '@heroicons/react/24/solid';
import { useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Layout.LanguagePicker', {
const messages = defineMessages({
displaylanguage: 'Display Language',
});

View File

@@ -142,25 +142,25 @@ const MobileMenu = () => {
{filteredLinks.map((link) => {
const isActive = router.pathname.match(link.activeRegExp);
return (
<Link
key={`mobile-menu-link-${link.href}`}
href={link.href}
className={`flex items-center space-x-2 ${
isActive ? 'text-indigo-500' : ''
}`}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setIsOpen(false);
}
}}
onClick={() => setIsOpen(false)}
role="button"
tabIndex={0}
>
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
className: 'h-5 w-5',
})}
<span>{link.content}</span>
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`flex items-center space-x-2 ${
isActive ? 'text-indigo-500' : ''
}`}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setIsOpen(false);
}
}}
onClick={() => setIsOpen(false)}
role="button"
tabIndex={0}
>
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
className: 'h-5 w-5',
})}
<span>{link.content}</span>
</a>
</Link>
);
})}
@@ -173,19 +173,19 @@ const MobileMenu = () => {
const isActive =
router.pathname.match(link.activeRegExp) && !isOpen;
return (
<Link
key={`mobile-menu-link-${link.href}`}
href={link.href}
className={`flex flex-col items-center space-y-1 ${
isActive ? 'text-indigo-500' : ''
}`}
>
{cloneElement(
isActive ? link.svgIconSelected : link.svgIcon,
{
className: 'h-6 w-6',
}
)}
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`flex flex-col items-center space-y-1 ${
isActive ? 'text-indigo-500' : ''
}`}
>
{cloneElement(
isActive ? link.svgIconSelected : link.svgIcon,
{
className: 'h-6 w-6',
}
)}
</a>
</Link>
);
})}

View File

@@ -1,10 +1,9 @@
import useSearchInput from '@app/hooks/useSearchInput';
import defineMessages from '@app/utils/defineMessages';
import { XCircleIcon } from '@heroicons/react/24/outline';
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Layout.SearchInput', {
const messages = defineMessages({
searchPlaceholder: 'Search Movies & TV',
});

View File

@@ -2,7 +2,6 @@ import UserWarnings from '@app/components/Layout/UserWarnings';
import VersionStatus from '@app/components/Layout/VersionStatus';
import useClickOutside from '@app/hooks/useClickOutside';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import {
ClockIcon,
@@ -14,13 +13,12 @@ import {
UsersIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Fragment, useRef } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
export const menuMessages = defineMessages('components.Layout.Sidebar', {
export const menuMessages = defineMessages({
dashboard: 'Discover',
browsemovies: 'Movies',
browsetv: 'Series',
@@ -148,16 +146,16 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
</div>
<div
ref={navRef}
className="flex flex-1 flex-col overflow-y-auto pt-4 pb-8 sm:pb-4"
className="flex flex-1 flex-col overflow-y-auto pt-8 pb-8 sm:pb-4"
>
<div className="flex flex-shrink-0 items-center px-2">
<span className="w-full px-4 text-xl text-gray-50">
<Link href="/" className="relative block h-24 w-64">
<Image src="/logo_full.svg" alt="Logo" fill />
</Link>
<span className="px-4 text-xl text-gray-50">
<a href="/">
<img src="/logo_full.svg" alt="Logo" />
</a>
</span>
</div>
<nav className="mt-10 flex-1 space-y-4 px-4">
<nav className="mt-16 flex-1 space-y-4 px-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission, {
@@ -170,27 +168,32 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
key={`mobile-${sidebarLink.messagesKey}`}
href={sidebarLink.href}
as={sidebarLink.as}
onClick={() => setClosed()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setClosed();
}
}}
role="button"
tabIndex={0}
className={`flex items-center rounded-md px-2 py-2 text-base font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
${
router.pathname.match(sidebarLink.activeRegExp)
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={`${sidebarLink.dataTestId}-mobile`}
>
{sidebarLink.svgIcon}
{intl.formatMessage(
menuMessages[sidebarLink.messagesKey]
)}
<a
onClick={() => setClosed()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setClosed();
}
}}
role="button"
tabIndex={0}
className={`flex items-center rounded-md px-2 py-2 text-base font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
${
router.pathname.match(
sidebarLink.activeRegExp
)
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={`${sidebarLink.dataTestId}-mobile`}
>
{sidebarLink.svgIcon}
{intl.formatMessage(
menuMessages[sidebarLink.messagesKey]
)}
</a>
</Link>
);
})}
@@ -218,15 +221,15 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
<div className="fixed top-0 bottom-0 left-0 z-30 hidden lg:flex lg:flex-shrink-0">
<div className="sidebar flex w-64 flex-col">
<div className="flex h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col overflow-y-auto pb-4">
<div className="flex flex-1 flex-col overflow-y-auto pt-8 pb-4">
<div className="flex flex-shrink-0 items-center">
<span className="w-full px-4 py-2 text-2xl text-gray-50">
<Link href="/" className="relative block h-24">
<Image src="/logo_full.svg" alt="Logo" fill />
</Link>
<span className="px-4 text-2xl text-gray-50">
<a href="/">
<img src="/logo_full.svg" alt="Logo" />
</a>
</span>
</div>
<nav className="mt-8 flex-1 space-y-4 px-4">
<nav className="mt-16 flex-1 space-y-4 px-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission, {
@@ -239,19 +242,24 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
key={`desktop-${sidebarLink.messagesKey}`}
href={sidebarLink.href}
as={sidebarLink.as}
className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
${
router.pathname.match(sidebarLink.activeRegExp)
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={sidebarLink.dataTestId}
>
{sidebarLink.svgIcon}
{intl.formatMessage(
menuMessages[sidebarLink.messagesKey]
)}
<a
className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
${
router.pathname.match(
sidebarLink.activeRegExp
)
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={sidebarLink.dataTestId}
>
{sidebarLink.svgIcon}
{intl.formatMessage(
menuMessages[sidebarLink.messagesKey]
)}
</a>
</Link>
);
})}

View File

@@ -1,18 +1,14 @@
import Infinity from '@app/assets/infinity.svg';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import ProgressCircle from '@app/components/Common/ProgressCircle';
import defineMessages from '@app/utils/defineMessages';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages(
'components.Layout.UserDropdown.MiniQuotaDisplay',
{
movierequests: 'Movie Requests',
seriesrequests: 'Series Requests',
}
);
const messages = defineMessages({
movierequests: 'Movie Requests',
seriesrequests: 'Series Requests',
});
type MiniQuotaDisplayProps = {
userId: number;

View File

@@ -1,6 +1,5 @@
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react';
import {
ArrowRightOnRectangleIcon,
@@ -8,13 +7,12 @@ import {
} from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import Image from 'next/image';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import { forwardRef, Fragment } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Layout.UserDropdown', {
const messages = defineMessages({
myprofile: 'Profile',
settings: 'Settings',
requests: 'Requests',
@@ -26,8 +24,10 @@ const ForwardedLink = forwardRef<
LinkProps & React.ComponentPropsWithoutRef<'a'>
>(({ href, children, ...rest }, ref) => {
return (
<Link href={href} ref={ref} {...rest}>
{children}
<Link href={href}>
<a ref={ref} {...rest}>
{children}
</a>
</Link>
);
});
@@ -53,12 +53,10 @@ const UserDropdown = () => {
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
data-testid="user-menu"
>
<Image
<img
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar || ''}
src={user?.avatar}
alt=""
width={40}
height={40}
/>
</Menu.Button>
</div>
@@ -76,12 +74,10 @@ const UserDropdown = () => {
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
<div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2">
<Image
<img
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar || ''}
src={user?.avatar}
alt=""
width={40}
height={40}
/>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xl font-semibold text-gray-200">

View File

@@ -1,10 +1,10 @@
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import type React from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages('components.Layout.UserWarnings', {
const messages = defineMessages({
emailRequired: 'An email address is required.',
emailInvalid: 'Email address is invalid.',
passwordRequired: 'A password is required.',
@@ -37,23 +37,24 @@ const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
}
res = (
<Link
href={link}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) {
onClick();
}
}}
role="button"
tabIndex={0}
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
>
<ExclamationTriangleIcon className="h-6 w-6" />
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{warningTitle}</span>
<span className="truncate">{warningText}</span>
</div>
<Link href={link}>
<a
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) {
onClick();
}
}}
role="button"
tabIndex={0}
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
>
<ExclamationTriangleIcon className="h-6 w-6" />
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{warningTitle}</span>
<span className="truncate">{warningText}</span>
</div>
</a>
</Link>
);
});

View File

@@ -1,4 +1,3 @@
import defineMessages from '@app/utils/defineMessages';
import {
ArrowUpCircleIcon,
BeakerIcon,
@@ -7,10 +6,10 @@ import {
} from '@heroicons/react/24/outline';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Layout.VersionStatus', {
const messages = defineMessages({
streamdevelop: 'Jellyseerr Develop',
streamstable: 'Jellyseerr Stable',
outofdate: 'Out of Date',
@@ -40,48 +39,49 @@ const VersionStatus = ({ onClick }: VersionStatusProps) => {
: intl.formatMessage(messages.streamstable);
return (
<Link
href="/settings/about"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) {
onClick();
}
}}
role="button"
tabIndex={0}
className={`mx-2 flex items-center rounded-lg p-2 text-xs ring-1 ring-gray-700 transition duration-300 ${
data.updateAvailable
? 'bg-yellow-500 text-white hover:bg-yellow-400'
: 'bg-gray-900 text-gray-300 hover:bg-gray-800'
}`}
>
{data.commitTag === 'local' ? (
<CodeBracketIcon className="h-6 w-6" />
) : data.version.startsWith('develop-') ? (
<BeakerIcon className="h-6 w-6" />
) : (
<ServerIcon className="h-6 w-6" />
)}
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{versionStream}</span>
<span className="truncate">
{data.commitTag === 'local' ? (
'(⌐■_■)'
) : data.commitsBehind > 0 ? (
intl.formatMessage(messages.commitsbehind, {
commitsBehind: data.commitsBehind,
})
) : data.commitsBehind === -1 ? (
intl.formatMessage(messages.outofdate)
) : (
<code className="bg-transparent p-0">
{data.version.replace('develop-', '')}
</code>
)}
</span>
</div>
{data.updateAvailable && <ArrowUpCircleIcon className="h-6 w-6" />}
<Link href="/settings/about">
<a
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) {
onClick();
}
}}
role="button"
tabIndex={0}
className={`mx-2 flex items-center rounded-lg p-2 text-xs ring-1 ring-gray-700 transition duration-300 ${
data.updateAvailable
? 'bg-yellow-500 text-white hover:bg-yellow-400'
: 'bg-gray-900 text-gray-300 hover:bg-gray-800'
}`}
>
{data.commitTag === 'local' ? (
<CodeBracketIcon className="h-6 w-6" />
) : data.version.startsWith('develop-') ? (
<BeakerIcon className="h-6 w-6" />
) : (
<ServerIcon className="h-6 w-6" />
)}
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{versionStream}</span>
<span className="truncate">
{data.commitTag === 'local' ? (
'(⌐■_■)'
) : data.commitsBehind > 0 ? (
intl.formatMessage(messages.commitsbehind, {
commitsBehind: data.commitsBehind,
})
) : data.commitsBehind === -1 ? (
intl.formatMessage(messages.outofdate)
) : (
<code className="bg-transparent p-0">
{data.version.replace('develop-', '')}
</code>
)}
</span>
</div>
{data.updateAvailable && <ArrowUpCircleIcon className="h-6 w-6" />}
</a>
</Link>
);
};

View File

@@ -1,13 +1,13 @@
import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useIntl } from 'react-intl';
import type React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import * as Yup from 'yup';
const messages = defineMessages('components.Login', {
const messages = defineMessages({
title: 'Add Email',
description:
'Since this is your first time logging into {applicationName}, you are required to add a valid email address.',

View File

@@ -1,23 +1,20 @@
import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import { useIntl } from 'react-intl';
import type React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
const messages = defineMessages('components.Login', {
const messages = defineMessages({
username: 'Username',
password: 'Password',
hostname: '{mediaServerName} URL',
port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
host: '{mediaServerName} URL',
email: 'Email',
emailtooltip:
'Address does not need to be associated with your {mediaServerName} instance.',
@@ -27,11 +24,6 @@ const messages = defineMessages('components.Login', {
validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
loginerror: 'Something went wrong while trying to sign in.',
adminerror: 'You must use an admin account to sign in.',
credentialerror: 'The username or password is incorrect.',
@@ -59,23 +51,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
if (initial) {
const LoginSchema = Yup.object().shape({
hostname: Yup.string().required(
intl.formatMessage(messages.validationhostrequired, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
),
port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired)
),
urlBase: Yup.string()
host: Yup.string()
.matches(
/^(\/[^/].*[^/]$)/,
intl.formatMessage(messages.validationUrlBaseLeadingSlash)
/^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/,
intl.formatMessage(messages.validationhostformat)
)
.matches(
/^(.*[^/])$/,
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
.required(
intl.formatMessage(messages.validationhostrequired, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
),
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
@@ -90,16 +75,12 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
};
return (
<Formik
initialValues={{
username: '',
password: '',
hostname: '',
port: 8096,
useSsl: false,
urlBase: '',
host: '',
email: '',
}}
validationSchema={LoginSchema}
@@ -108,10 +89,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
hostname: values.host,
email: values.email,
});
} catch (e) {
@@ -143,100 +121,32 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
}
}}
>
{({
errors,
touched,
values,
setFieldValue,
isSubmitting,
isValid,
}) => (
{({ errors, touched, isSubmitting, isValid }) => (
<Form>
<div className="sm:border-t sm:border-gray-800">
<div className="flex flex-col sm:flex-row sm:gap-4">
<div className="w-full">
<label htmlFor="hostname" className="text-label">
{intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
{values.useSsl ? 'https://' : 'http://'}
</span>
<Field
id="hostname"
name="hostname"
type="text"
className="rounded-r-only flex-1"
placeholder={intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
/>
</div>
{errors.hostname && touched.hostname && (
<div className="error">{errors.hostname}</div>
)}
</div>
</div>
<div className="flex-1">
<label htmlFor="port" className="text-label">
{intl.formatMessage(messages.port)}
</label>
<div className="mt-1 sm:mt-0">
<Field
id="port"
name="port"
inputMode="numeric"
type="text"
className="short flex-1"
placeholder={intl.formatMessage(messages.port)}
/>
{errors.port && touched.port && (
<div className="error">{errors.port}</div>
)}
</div>
</div>
</div>
<label htmlFor="useSsl" className="text-label mt-2">
{intl.formatMessage(messages.enablessl)}
</label>
<div className="mt-1 mb-2 sm:col-span-2">
<div className="flex rounded-md shadow-sm">
<Field
id="useSsl"
name="useSsl"
type="checkbox"
onChange={() => {
setFieldValue('useSsl', !values.useSsl);
setFieldValue('port', values.useSsl ? 8096 : 443);
}}
/>
</div>
</div>
<label htmlFor="urlBase" className="text-label mt-1">
{intl.formatMessage(messages.urlBase)}
<label htmlFor="host" className="text-label">
{intl.formatMessage(messages.host, mediaServerFormatValues)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="host"
name="host"
type="text"
inputMode="url"
id="urlBase"
name="urlBase"
placeholder={intl.formatMessage(messages.urlBase)}
placeholder={intl.formatMessage(
messages.host,
mediaServerFormatValues
)}
/>
</div>
{errors.urlBase && touched.urlBase && (
<div className="error">{errors.urlBase}</div>
{errors.host && touched.host && (
<div className="error">{errors.host}</div>
)}
</div>
<label
htmlFor="email"
className="text-label inline-flex gap-1 align-middle"
className="text-label"
style={{ display: 'inline-flex' }}
>
{intl.formatMessage(messages.email)}
<span className="label-tip">
@@ -252,7 +162,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
</Tooltip>
</span>
</label>
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="email"

View File

@@ -1,7 +1,6 @@
import Button from '@app/components/Common/Button';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import {
ArrowLeftOnRectangleIcon,
LifebuoyIcon,
@@ -10,10 +9,10 @@ import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import * as Yup from 'yup';
const messages = defineMessages('components.Login', {
const messages = defineMessages({
username: 'Username',
email: 'Email Address',
password: 'Password',
@@ -138,7 +137,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
</span>
{passwordResetEnabled && (
<span className="inline-flex rounded-md shadow-sm">
<Link href="/resetpassword" passHref legacyBehavior>
<Link href="/resetpassword" passHref>
<Button as="a" buttonType="ghost">
<LifebuoyIcon />
<span>

View File

@@ -6,20 +6,18 @@ import LocalLogin from '@app/components/Login/LocalLogin';
import PlexLoginButton from '@app/components/PlexLoginButton';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import getConfig from 'next/config';
import { useRouter } from 'next/dist/client/router';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import JellyfinLogin from './JellyfinLogin';
const messages = defineMessages('components.Login', {
const messages = defineMessages({
signin: 'Sign In',
signinheader: 'Sign in to continue',
signinwithplex: 'Use your Plex account',
@@ -88,10 +86,8 @@ const Login = () => {
<LanguagePicker />
</div>
<div className="relative z-40 mt-10 flex flex-col items-center px-4 sm:mx-auto sm:w-full sm:max-w-md">
<div className="relative h-48 w-full max-w-full">
<Image src="/logo_stacked.svg" alt="Logo" fill />
</div>
<h2 className="mt-12 text-center text-3xl font-extrabold leading-9 text-gray-100">
<img src="/logo_stacked.svg" className="mb-10 max-w-full" alt="Logo" />
<h2 className="mt-2 text-center text-3xl font-extrabold leading-9 text-gray-100">
{intl.formatMessage(messages.signinheader)}
</h2>
</div>

Some files were not shown because too many files have changed in this diff Show More