Compare commits
40 Commits
test-fix-i
...
preview-ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da85ca0d1 | ||
|
|
d5e37e4f3f | ||
|
|
f5a50914c8 | ||
|
|
e30a3ea74f | ||
|
|
cc80bf2c56 | ||
|
|
2c52dbcea3 | ||
|
|
1168c77cad | ||
|
|
182aeaa636 | ||
|
|
a58811e675 | ||
|
|
37ba36f2df | ||
|
|
e50df22cbf | ||
|
|
878afb91df | ||
|
|
ffb20ebe93 | ||
|
|
cb7e2f073e | ||
|
|
588b1e36dd | ||
|
|
99ee19c714 | ||
|
|
fac81f75c9 | ||
|
|
ea43e3ba1e | ||
|
|
f3e180afb1 | ||
|
|
b5738b49d6 | ||
|
|
966a721c54 | ||
|
|
43f8260675 | ||
|
|
50700002e2 | ||
|
|
f7de2418e5 | ||
|
|
5c212ae2a8 | ||
|
|
6b248d97a7 | ||
|
|
5efa1d7a46 | ||
|
|
cb8cadae71 | ||
|
|
4d14a15fb6 | ||
|
|
06e465d052 | ||
|
|
5fb1c687fc | ||
|
|
65239a922f | ||
|
|
48d178c1e9 | ||
|
|
5d6e7f09a2 | ||
|
|
c680202008 | ||
|
|
63d8f550c4 | ||
|
|
9ab5fa5972 | ||
|
|
38ad875dd7 | ||
|
|
a9741fa36d | ||
|
|
b5a069901a |
@@ -376,6 +376,33 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ module.exports = {
|
|||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
||||||
'plugin:jsx-a11y/recommended',
|
'plugin:jsx-a11y/recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:@next/next/recommended',
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
'plugin:react/jsx-runtime',
|
|
||||||
'prettier',
|
'prettier',
|
||||||
],
|
],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
|||||||
27
.github/workflows/ci.yml
vendored
27
.github/workflows/ci.yml
vendored
@@ -13,20 +13,35 @@ jobs:
|
|||||||
name: Lint & Test Build
|
name: Lint & Test Build
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
container: node:18.18-alpine
|
container: node:20-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
run: yarn
|
run: pnpm install
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: yarn lint
|
run: pnpm lint
|
||||||
- name: Formatting
|
- name: Formatting
|
||||||
run: yarn format:check
|
run: pnpm format:check
|
||||||
- name: Build
|
- name: Build
|
||||||
run: yarn build
|
run: pnpm build
|
||||||
|
|
||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish Docker Images
|
name: Build & Publish Docker Images
|
||||||
@@ -60,7 +75,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
|||||||
12
.github/workflows/cypress.yml
vendored
12
.github/workflows/cypress.yml
vendored
@@ -14,11 +14,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Cypress run
|
||||||
uses: cypress-io/github-action@v6
|
uses: cypress-io/github-action@v6
|
||||||
with:
|
with:
|
||||||
build: yarn cypress:build
|
build: pnpm cypress:build
|
||||||
start: yarn start
|
start: pnpm start
|
||||||
wait-on: 'http://localhost:5055'
|
wait-on: 'http://localhost:5055'
|
||||||
record: true
|
record: true
|
||||||
env:
|
env:
|
||||||
|
|||||||
2
.github/workflows/preview.yml
vendored
2
.github/workflows/preview.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
|||||||
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -26,8 +26,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
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
|
- name: Install dependencies
|
||||||
run: yarn
|
run: pnpm install
|
||||||
- name: Release
|
- name: Release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
@@ -45,7 +60,6 @@ jobs:
|
|||||||
# architecture:
|
# architecture:
|
||||||
# - amd64
|
# - amd64
|
||||||
# - arm64
|
# - arm64
|
||||||
# - armhf
|
|
||||||
# steps:
|
# steps:
|
||||||
# - name: Checkout Code
|
# - name: Checkout Code
|
||||||
# uses: actions/checkout@v4
|
# uses: actions/checkout@v4
|
||||||
|
|||||||
1
.github/workflows/snap.yaml
vendored
1
.github/workflows/snap.yaml
vendored
@@ -30,7 +30,6 @@ jobs:
|
|||||||
architecture:
|
architecture:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- armhf
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18.18-alpine AS BUILD_IMAGE
|
FROM node:20-alpine AS BUILD_IMAGE
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,22 +10,24 @@ RUN \
|
|||||||
'linux/arm64' | 'linux/arm/v7') \
|
'linux/arm64' | 'linux/arm/v7') \
|
||||||
apk update && \
|
apk update && \
|
||||||
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
|
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
|
||||||
yarn global add node-gyp \
|
npm install --global node-gyp \
|
||||||
;; \
|
;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
Run npm install --global pnpm
|
||||||
RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
ARG COMMIT_TAG
|
ARG COMMIT_TAG
|
||||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||||
|
|
||||||
RUN yarn build
|
RUN pnpm build
|
||||||
|
|
||||||
# remove development dependencies
|
# remove development dependencies
|
||||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
RUN pnpm prune --prod --ignore-scripts
|
||||||
|
|
||||||
RUN rm -rf src server .next/cache
|
RUN rm -rf src server .next/cache
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ RUN touch config/DOCKER
|
|||||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||||
|
|
||||||
|
|
||||||
FROM node:18.18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Metadata for Github Package Registry
|
# Metadata for Github Package Registry
|
||||||
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
|
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
|
||||||
@@ -47,6 +49,6 @@ RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
|
|||||||
COPY --from=BUILD_IMAGE /app ./
|
COPY --from=BUILD_IMAGE /app ./
|
||||||
|
|
||||||
ENTRYPOINT [ "/sbin/tini", "--" ]
|
ENTRYPOINT [ "/sbin/tini", "--" ]
|
||||||
CMD [ "yarn", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|
||||||
EXPOSE 5055
|
EXPOSE 5055
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18.18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ https://hub.docker.com/r/fallenbagel/jellyseerr
|
|||||||
|
|
||||||
Pre-requisites:
|
Pre-requisites:
|
||||||
|
|
||||||
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
|
- Nodejs [v20](https://nodejs.org/en/download)
|
||||||
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
|
- [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)
|
- 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:**
|
**Pre-requisites:**
|
||||||
|
|
||||||
- Nodejs [v18](https://nodejs.org/en/download/package-manager)
|
- Nodejs [v20](https://nodejs.org/en/download)
|
||||||
- [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`)
|
- [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
|
- Git
|
||||||
|
|
||||||
@@ -378,6 +378,9 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
</tr>
|
</tr>
|
||||||
<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="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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
module.exports = function (api) {
|
|
||||||
api.cache(true);
|
|
||||||
|
|
||||||
return {
|
|
||||||
presets: [
|
|
||||||
[
|
|
||||||
'next/babel',
|
|
||||||
{
|
|
||||||
'preset-env': {
|
|
||||||
useBuiltIns: 'entry',
|
|
||||||
corejs: '3',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
plugins: [
|
|
||||||
[
|
|
||||||
'react-intl-auto',
|
|
||||||
{
|
|
||||||
removePrefix: 'src/',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"region": "",
|
"region": "",
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
|
"mediaServerType": 1,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
@@ -37,6 +38,17 @@
|
|||||||
],
|
],
|
||||||
"machineId": "test"
|
"machineId": "test"
|
||||||
},
|
},
|
||||||
|
"jellyfin": {
|
||||||
|
"name": "",
|
||||||
|
"ip": "",
|
||||||
|
"port": 8096,
|
||||||
|
"useSsl": false,
|
||||||
|
"urlBase": "",
|
||||||
|
"externalHostname": "",
|
||||||
|
"jellyfinForgotPasswordUrl": "",
|
||||||
|
"libraries": [],
|
||||||
|
"serverId": ""
|
||||||
|
},
|
||||||
"tautulli": {},
|
"tautulli": {},
|
||||||
"radarr": [],
|
"radarr": [],
|
||||||
"sonarr": [],
|
"sonarr": [],
|
||||||
@@ -139,11 +151,26 @@
|
|||||||
"sonarr-scan": {
|
"sonarr-scan": {
|
||||||
"schedule": "0 30 4 * * *"
|
"schedule": "0 30 4 * * *"
|
||||||
},
|
},
|
||||||
|
"plex-watchlist-sync": {
|
||||||
|
"schedule": "0 */10 * * * *"
|
||||||
|
},
|
||||||
|
"availability-sync": {
|
||||||
|
"schedule": "0 0 5 * * *"
|
||||||
|
},
|
||||||
"download-sync": {
|
"download-sync": {
|
||||||
"schedule": "0 * * * * *"
|
"schedule": "0 * * * * *"
|
||||||
},
|
},
|
||||||
"download-sync-reset": {
|
"download-sync-reset": {
|
||||||
"schedule": "0 0 1 * * *"
|
"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 * * *"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import './commands';
|
|||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
if (Cypress.env('SEED_DATABASE')) {
|
if (Cypress.env('SEED_DATABASE')) {
|
||||||
cy.exec('yarn cypress:prepare');
|
cy.exec('pnpm cypress:prepare');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ module.exports = {
|
|||||||
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
|
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
domains: ['image.tmdb.org'],
|
remotePatterns: [
|
||||||
|
{ hostname: 'gravatar.com' },
|
||||||
|
{ hostname: 'image.tmdb.org' },
|
||||||
|
{ hostname: '*', protocol: 'https' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
|
|||||||
45
package.json
45
package.json
@@ -3,26 +3,27 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"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: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:next": "next build",
|
||||||
"build": "yarn build:next && yarn build:server",
|
"build": "pnpm build:next && pnpm build:server",
|
||||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||||
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
|
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
"i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts",
|
||||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
"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: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",
|
"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": "prettier --loglevel warn --write --cache .",
|
||||||
"format:check": "prettier --check --cache .",
|
"format:check": "prettier --check --cache .",
|
||||||
"typecheck": "yarn typecheck:server && yarn typecheck:client",
|
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
|
||||||
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
||||||
"typecheck:client": "tsc --noEmit",
|
"typecheck:client": "tsc --noEmit",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"cypress:open": "cypress open",
|
"cypress:open": "cypress open",
|
||||||
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
|
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
|
||||||
"cypress:build": "yarn build && yarn cypress:prepare"
|
"cypress:build": "pnpm build && pnpm cypress:prepare"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@formatjs/intl-locale": "3.1.1",
|
"@formatjs/intl-locale": "3.1.1",
|
||||||
"@formatjs/intl-pluralrules": "5.1.10",
|
"@formatjs/intl-pluralrules": "5.1.10",
|
||||||
"@formatjs/intl-utils": "3.8.4",
|
"@formatjs/intl-utils": "3.8.4",
|
||||||
|
"@formatjs/swc-plugin-experimental": "^0.4.0",
|
||||||
"@headlessui/react": "1.7.12",
|
"@headlessui/react": "1.7.12",
|
||||||
"@heroicons/react": "2.0.16",
|
"@heroicons/react": "2.0.16",
|
||||||
"@supercharge/request-ip": "1.2.0",
|
"@supercharge/request-ip": "1.2.0",
|
||||||
@@ -59,11 +61,10 @@
|
|||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
"express-rate-limit": "6.7.0",
|
"express-rate-limit": "6.7.0",
|
||||||
"express-session": "1.17.3",
|
"express-session": "1.17.3",
|
||||||
"formik": "2.2.9",
|
"formik": "^2.4.6",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"intl": "1.2.5",
|
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"next": "12.3.4",
|
"next": "^14.2.4",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.3.1",
|
"node-gyp": "9.3.1",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
@@ -71,13 +72,13 @@
|
|||||||
"openpgp": "5.7.0",
|
"openpgp": "5.7.0",
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.2",
|
||||||
"react": "18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-ace": "10.1.0",
|
"react-ace": "10.1.0",
|
||||||
"react-animate-height": "2.1.2",
|
"react-animate-height": "2.1.2",
|
||||||
"react-aria": "3.23.0",
|
"react-aria": "3.23.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-intersection-observer": "9.4.3",
|
"react-intersection-observer": "9.4.3",
|
||||||
"react-intl": "6.2.10",
|
"react-intl": "^6.6.8",
|
||||||
"react-markdown": "8.0.5",
|
"react-markdown": "8.0.5",
|
||||||
"react-popper-tooltip": "4.4.2",
|
"react-popper-tooltip": "4.4.2",
|
||||||
"react-select": "5.7.0",
|
"react-select": "5.7.0",
|
||||||
@@ -89,9 +90,10 @@
|
|||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"secure-random-password": "0.2.3",
|
"secure-random-password": "0.2.3",
|
||||||
"semver": "7.3.8",
|
"semver": "7.3.8",
|
||||||
|
"sharp": "^0.33.4",
|
||||||
"sqlite3": "5.1.4",
|
"sqlite3": "5.1.4",
|
||||||
"swagger-ui-express": "4.6.2",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.0.4",
|
"swr": "2.2.5",
|
||||||
"typeorm": "0.3.12",
|
"typeorm": "0.3.12",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"winston": "3.8.2",
|
"winston": "3.8.2",
|
||||||
@@ -102,7 +104,6 @@
|
|||||||
"zod": "3.20.6"
|
"zod": "3.20.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "7.21.0",
|
|
||||||
"@commitlint/cli": "17.4.4",
|
"@commitlint/cli": "17.4.4",
|
||||||
"@commitlint/config-conventional": "17.4.4",
|
"@commitlint/config-conventional": "17.4.4",
|
||||||
"@semantic-release/changelog": "6.0.2",
|
"@semantic-release/changelog": "6.0.2",
|
||||||
@@ -123,8 +124,8 @@
|
|||||||
"@types/node": "17.0.36",
|
"@types/node": "17.0.36",
|
||||||
"@types/node-schedule": "2.1.0",
|
"@types/node-schedule": "2.1.0",
|
||||||
"@types/nodemailer": "6.4.7",
|
"@types/nodemailer": "6.4.7",
|
||||||
"@types/react": "18.0.28",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "18.0.11",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-transition-group": "4.4.5",
|
"@types/react-transition-group": "4.4.5",
|
||||||
"@types/secure-random-password": "0.2.1",
|
"@types/secure-random-password": "0.2.1",
|
||||||
"@types/semver": "7.3.13",
|
"@types/semver": "7.3.13",
|
||||||
@@ -136,15 +137,13 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||||
"@typescript-eslint/parser": "5.54.0",
|
"@typescript-eslint/parser": "5.54.0",
|
||||||
"autoprefixer": "10.4.13",
|
"autoprefixer": "10.4.13",
|
||||||
"babel-plugin-react-intl": "8.2.25",
|
|
||||||
"babel-plugin-react-intl-auto": "3.3.0",
|
|
||||||
"commitizen": "4.3.0",
|
"commitizen": "4.3.0",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"cy-mobile-commands": "0.3.0",
|
"cy-mobile-commands": "0.3.0",
|
||||||
"cypress": "12.7.0",
|
"cypress": "12.7.0",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint": "8.35.0",
|
"eslint": "8.35.0",
|
||||||
"eslint-config-next": "12.3.4",
|
"eslint-config-next": "^14.2.4",
|
||||||
"eslint-config-prettier": "8.6.0",
|
"eslint-config-prettier": "8.6.0",
|
||||||
"eslint-plugin-formatjs": "4.9.0",
|
"eslint-plugin-formatjs": "4.9.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||||
@@ -152,7 +151,6 @@
|
|||||||
"eslint-plugin-prettier": "4.2.1",
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
"eslint-plugin-react": "7.32.2",
|
"eslint-plugin-react": "7.32.2",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"extract-react-intl-messages": "4.1.1",
|
|
||||||
"husky": "8.0.3",
|
"husky": "8.0.3",
|
||||||
"lint-staged": "13.1.2",
|
"lint-staged": "13.1.2",
|
||||||
"nodemon": "2.0.20",
|
"nodemon": "2.0.20",
|
||||||
@@ -168,10 +166,12 @@
|
|||||||
"tsconfig-paths": "4.1.2",
|
"tsconfig-paths": "4.1.2",
|
||||||
"typescript": "4.9.5"
|
"typescript": "4.9.5"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"engines": {
|
||||||
|
"node": "^20.0.0",
|
||||||
|
"pnpm": "^9.0.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
"sqlite3/node-gyp": "8.4.1",
|
"sqlite3/node-gyp": "8.4.1",
|
||||||
"@types/react": "18.0.28",
|
|
||||||
"@types/react-dom": "18.0.11",
|
|
||||||
"@types/express-session": "1.17.6"
|
"@types/express-session": "1.17.6"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@@ -237,8 +237,7 @@
|
|||||||
],
|
],
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"linux/amd64",
|
"linux/amd64",
|
||||||
"linux/arm64",
|
"linux/arm64"
|
||||||
"linux/arm/v7"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@semantic-release/github"
|
"@semantic-release/github"
|
||||||
|
|||||||
26267
pnpm-lock.yaml
generated
Normal file
26267
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -126,25 +126,31 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
Password?: string,
|
Password?: string,
|
||||||
ClientIP?: string
|
ClientIP?: string
|
||||||
): Promise<JellyfinLoginResponse> {
|
): Promise<JellyfinLoginResponse> {
|
||||||
try {
|
const authenticate = async (useHeaders: boolean) => {
|
||||||
const headers = ClientIP
|
const headers =
|
||||||
? {
|
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
|
||||||
'X-Forwarded-For': ClientIP,
|
|
||||||
}
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const authResponse = await this.post<JellyfinLoginResponse>(
|
return this.post<JellyfinLoginResponse>(
|
||||||
'/Users/AuthenticateByName',
|
'/Users/AuthenticateByName',
|
||||||
{
|
{
|
||||||
Username: Username,
|
Username,
|
||||||
Pw: Password,
|
Pw: Password,
|
||||||
},
|
},
|
||||||
{
|
{ headers }
|
||||||
headers: headers,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return authResponse;
|
try {
|
||||||
|
return await authenticate(true);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
|
||||||
|
label: 'Jellyfin API',
|
||||||
|
ip: ClientIP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = e.response?.status;
|
const status = e.response?.status;
|
||||||
|
|
||||||
@@ -178,6 +184,16 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return;
|
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> {
|
public async getServerName(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const serverResponse = await this.get<JellyfinUserResponse>(
|
const serverResponse = await this.get<JellyfinUserResponse>(
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ export enum ApiErrorCode {
|
|||||||
InvalidCredentials = 'INVALID_CREDENTIALS',
|
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||||
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
||||||
NotAdmin = 'NOT_ADMIN',
|
NotAdmin = 'NOT_ADMIN',
|
||||||
|
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||||
|
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||||
Unknown = 'UNKNOWN',
|
Unknown = 'UNKNOWN',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
|
|||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
@@ -211,15 +212,12 @@ class Media {
|
|||||||
} else {
|
} else {
|
||||||
const pageName =
|
const pageName =
|
||||||
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||||
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
|
const { serverId, externalHostname } = getSettings().jellyfin;
|
||||||
let jellyfinHost =
|
|
||||||
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: getHostname();
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
|
|
||||||
if (this.jellyfinMediaId) {
|
if (this.jellyfinMediaId) {
|
||||||
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type CacheableLookupType from 'cacheable-lookup';
|
|||||||
import { TypeormStore } from 'connect-typeorm/out';
|
import { TypeormStore } from 'connect-typeorm/out';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import csurf from 'csurf';
|
import csurf from 'csurf';
|
||||||
|
import { lookup } from 'dns';
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as OpenApiValidator from 'express-openapi-validator';
|
import * as OpenApiValidator from 'express-openapi-validator';
|
||||||
@@ -54,6 +55,19 @@ app
|
|||||||
const CacheableLookup = (await _importDynamic('cacheable-lookup'))
|
const CacheableLookup = (await _importDynamic('cacheable-lookup'))
|
||||||
.default as typeof CacheableLookupType;
|
.default as typeof CacheableLookupType;
|
||||||
const cacheable = new CacheableLookup();
|
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(http.globalAgent);
|
||||||
cacheable.install(https.globalAgent);
|
cacheable.install(https.globalAgent);
|
||||||
|
|
||||||
@@ -132,7 +146,7 @@ app
|
|||||||
try {
|
try {
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
|
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
|
||||||
if (descriptor?.writable === true) {
|
if (descriptor?.writable === true) {
|
||||||
req.ip = getClientIp(req) ?? '';
|
(req as any).ip = getClientIp(req) ?? '';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to attach the ip to the request', {
|
logger.error('Failed to attach the ip to the request', {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { User } from '@server/entity/User';
|
|||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
|
|
||||||
class AvailabilitySync {
|
class AvailabilitySync {
|
||||||
public running = false;
|
public running = false;
|
||||||
@@ -84,7 +85,7 @@ class AvailabilitySync {
|
|||||||
) {
|
) {
|
||||||
if (admin) {
|
if (admin) {
|
||||||
this.jellyfinClient = new JellyfinAPI(
|
this.jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
getHostname(),
|
||||||
admin.jellyfinAuthToken,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
interface PushoverPayload {
|
interface PushoverImagePayload {
|
||||||
|
attachment_base64: string;
|
||||||
|
attachment_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PushoverPayload extends PushoverImagePayload {
|
||||||
token: string;
|
token: string;
|
||||||
user: string;
|
user: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -43,10 +48,36 @@ class PushoverAgent
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNotificationPayload(
|
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(
|
||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Partial<PushoverPayload> {
|
): Promise<Partial<PushoverPayload>> {
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
|
|
||||||
const title = payload.event ?? payload.subject;
|
const title = payload.event ?? payload.subject;
|
||||||
@@ -122,6 +153,16 @@ class PushoverAgent
|
|||||||
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
||||||
: undefined;
|
: 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 {
|
return {
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
@@ -129,6 +170,8 @@ class PushoverAgent
|
|||||||
url_title,
|
url_title,
|
||||||
priority,
|
priority,
|
||||||
html: 1,
|
html: 1,
|
||||||
|
attachment_base64,
|
||||||
|
attachment_type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +181,10 @@ class PushoverAgent
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
const endpoint = 'https://api.pushover.net/1/messages.json';
|
const endpoint = 'https://api.pushover.net/1/messages.json';
|
||||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
const notificationPayload = await this.getNotificationPayload(
|
||||||
|
type,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
// Send system notification
|
// Send system notification
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { Library } from '@server/lib/settings';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import AsyncLock from '@server/utils/asyncLock';
|
import AsyncLock from '@server/utils/asyncLock';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { randomUUID as uuid } from 'crypto';
|
import { randomUUID as uuid } from 'crypto';
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
|
|
||||||
@@ -594,8 +595,10 @@ class JellyfinScanner {
|
|||||||
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
|
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostname = getHostname();
|
||||||
|
|
||||||
this.jfClient = new JellyfinAPI(
|
this.jfClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
hostname,
|
||||||
admin.jellyfinAuthToken,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { runMigrations } from '@server/lib/settings/migrator';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { merge } from 'lodash';
|
import { merge } from 'lodash';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { Permission } from './permissions';
|
|
||||||
|
|
||||||
export interface Library {
|
export interface Library {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,7 +39,10 @@ export interface PlexSettings {
|
|||||||
|
|
||||||
export interface JellyfinSettings {
|
export interface JellyfinSettings {
|
||||||
name: string;
|
name: string;
|
||||||
hostname: string;
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
useSsl?: boolean;
|
||||||
|
urlBase?: string;
|
||||||
externalHostname?: string;
|
externalHostname?: string;
|
||||||
jellyfinForgotPasswordUrl?: string;
|
jellyfinForgotPasswordUrl?: string;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
@@ -130,7 +134,6 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
region: string;
|
region: string;
|
||||||
originalLanguage: string;
|
originalLanguage: string;
|
||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
jellyfinHost?: string;
|
|
||||||
jellyfinExternalHost?: string;
|
jellyfinExternalHost?: string;
|
||||||
jellyfinForgotPasswordUrl?: string;
|
jellyfinForgotPasswordUrl?: string;
|
||||||
jellyfinServerName?: string;
|
jellyfinServerName?: string;
|
||||||
@@ -274,7 +277,7 @@ export type JobId =
|
|||||||
| 'image-cache-cleanup'
|
| 'image-cache-cleanup'
|
||||||
| 'availability-sync';
|
| 'availability-sync';
|
||||||
|
|
||||||
interface AllSettings {
|
export interface AllSettings {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
vapidPublic: string;
|
vapidPublic: string;
|
||||||
vapidPrivate: string;
|
vapidPrivate: string;
|
||||||
@@ -291,7 +294,7 @@ interface AllSettings {
|
|||||||
|
|
||||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||||
? `${process.env.CONFIG_DIRECTORY}/settings.json`
|
? `${process.env.CONFIG_DIRECTORY}/settings.json`
|
||||||
: path.join(__dirname, '../../config/settings.json');
|
: path.join(__dirname, '../../../config/settings.json');
|
||||||
|
|
||||||
class Settings {
|
class Settings {
|
||||||
private data: AllSettings;
|
private data: AllSettings;
|
||||||
@@ -331,7 +334,10 @@ class Settings {
|
|||||||
},
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
name: '',
|
name: '',
|
||||||
hostname: '',
|
ip: '',
|
||||||
|
port: 8096,
|
||||||
|
useSsl: false,
|
||||||
|
urlBase: '',
|
||||||
externalHostname: '',
|
externalHostname: '',
|
||||||
jellyfinForgotPasswordUrl: '',
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
@@ -547,8 +553,6 @@ class Settings {
|
|||||||
region: this.data.main.region,
|
region: this.data.main.region,
|
||||||
originalLanguage: this.data.main.originalLanguage,
|
originalLanguage: this.data.main.originalLanguage,
|
||||||
mediaServerType: this.main.mediaServerType,
|
mediaServerType: this.main.mediaServerType,
|
||||||
jellyfinHost: this.jellyfin.hostname,
|
|
||||||
jellyfinExternalHost: this.jellyfin.externalHostname,
|
|
||||||
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
|
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
|
||||||
cacheImages: this.data.main.cacheImages,
|
cacheImages: this.data.main.cacheImages,
|
||||||
vapidPublic: this.vapidPublic,
|
vapidPublic: this.vapidPublic,
|
||||||
@@ -637,7 +641,11 @@ class Settings {
|
|||||||
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
this.data = merge(this.data, JSON.parse(data));
|
const parsedJson = JSON.parse(data);
|
||||||
|
this.data = runMigrations(parsedJson);
|
||||||
|
|
||||||
|
this.data = merge(this.data, parsedJson);
|
||||||
|
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
30
server/lib/settings/migrations/0001_migrate_hostname.ts
Normal file
30
server/lib/settings/migrations/0001_migrate_hostname.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
21
server/lib/settings/migrator.ts
Normal file
21
server/lib/settings/migrator.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { getSettings } from '@server/lib/settings';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import { ApiError } from '@server/types/error';
|
import { ApiError } from '@server/types/error';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import * as EmailValidator from 'email-validator';
|
import * as EmailValidator from 'email-validator';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import gravatarUrl from 'gravatar-url';
|
import gravatarUrl from 'gravatar-url';
|
||||||
@@ -222,30 +223,39 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
|
port?: number;
|
||||||
|
urlBase?: string;
|
||||||
|
useSsl?: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
||||||
if (
|
if (
|
||||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
settings.jellyfin.hostname !== ''
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||||
} else if (!body.username) {
|
} else if (!body.username) {
|
||||||
return res.status(500).json({ error: 'You must provide an username' });
|
return res.status(500).json({ error: 'You must provide an username' });
|
||||||
} else if (settings.jellyfin.hostname !== '' && body.hostname) {
|
} else if (settings.jellyfin.ip !== '' && body.hostname) {
|
||||||
return res
|
return res
|
||||||
.status(500)
|
.status(500)
|
||||||
.json({ error: 'Jellyfin hostname already configured' });
|
.json({ error: 'Jellyfin hostname already configured' });
|
||||||
} else if (settings.jellyfin.hostname === '' && !body.hostname) {
|
} else if (settings.jellyfin.ip === '' && !body.hostname) {
|
||||||
return res.status(500).json({ error: 'No hostname provided.' });
|
return res.status(500).json({ error: 'No hostname provided.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hostname =
|
const hostname =
|
||||||
settings.jellyfin.hostname !== ''
|
settings.jellyfin.ip !== ''
|
||||||
? settings.jellyfin.hostname
|
? getHostname()
|
||||||
: body.hostname ?? '';
|
: getHostname({
|
||||||
|
useSsl: body.useSsl,
|
||||||
|
ip: body.hostname,
|
||||||
|
port: body.port,
|
||||||
|
urlBase: body.urlBase,
|
||||||
|
});
|
||||||
|
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
|
|
||||||
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
||||||
@@ -261,17 +271,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First we need to attempt to log the user in to jellyfin
|
// First we need to attempt to log the user in to jellyfin
|
||||||
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
|
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
||||||
let jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: hostname;
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
|
|
||||||
const ip = req.ip;
|
const ip = req.ip;
|
||||||
let clientIp;
|
let clientIp;
|
||||||
|
|
||||||
@@ -328,8 +335,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
const serverName = await jellyfinserver.getServerName();
|
const serverName = await jellyfinserver.getServerName();
|
||||||
|
|
||||||
settings.jellyfin.name = serverName;
|
settings.jellyfin.name = serverName;
|
||||||
settings.jellyfin.hostname = body.hostname ?? '';
|
|
||||||
settings.jellyfin.serverId = account.User.ServerId;
|
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();
|
settings.save();
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
@@ -444,7 +454,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
label: 'Auth',
|
label: 'Auth',
|
||||||
error: e.errorCode,
|
error: e.errorCode,
|
||||||
status: e.statusCode,
|
status: e.statusCode,
|
||||||
hostname: body.hostname,
|
hostname: getHostname({
|
||||||
|
useSsl: body.useSsl,
|
||||||
|
ip: body.hostname,
|
||||||
|
port: body.port,
|
||||||
|
urlBase: body.urlBase,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return next({
|
return next({
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const collection = await tmdb.getCollection({
|
const collection = await tmdb.getCollection({
|
||||||
collectionId: Number(req.params.id),
|
collectionId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ discoverRoutes.get<{ language: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
originalLanguage: req.params.language,
|
originalLanguage: req.params.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const genres = await tmdb.getMovieGenres({
|
const genres = await tmdb.getMovieGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const genre = genres.find(
|
const genre = genres.find(
|
||||||
@@ -224,7 +224,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
genre: req.params.genreId as string,
|
genre: req.params.genreId as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ discoverRoutes.get<{ studioId: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
studio: req.params.studioId as string,
|
studio: req.params.studioId as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
primaryReleaseDateGte: date,
|
primaryReleaseDateGte: date,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,7 +447,7 @@ discoverRoutes.get<{ language: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
originalLanguage: req.params.language,
|
originalLanguage: req.params.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,7 +492,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const genres = await tmdb.getTvGenres({
|
const genres = await tmdb.getTvGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const genre = genres.find(
|
const genre = genres.find(
|
||||||
@@ -505,7 +505,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
genre: req.params.genreId,
|
genre: req.params.genreId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -553,7 +553,7 @@ discoverRoutes.get<{ networkId: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
network: Number(req.params.networkId),
|
network: Number(req.params.networkId),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -603,7 +603,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
firstAirDateGte: date,
|
firstAirDateGte: date,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -643,7 +643,7 @@ discoverRoutes.get('/trending', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const data = await tmdb.getAllTrending({
|
const data = await tmdb.getAllTrending({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -698,7 +698,7 @@ discoverRoutes.get<{ keywordId: string }>(
|
|||||||
const data = await tmdb.getMoviesByKeyword({
|
const data = await tmdb.getMoviesByKeyword({
|
||||||
keywordId: Number(req.params.keywordId),
|
keywordId: Number(req.params.keywordId),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -743,7 +743,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
const mappedGenres: GenreSliderItem[] = [];
|
const mappedGenres: GenreSliderItem[] = [];
|
||||||
|
|
||||||
const genres = await tmdb.getMovieGenres({
|
const genres = await tmdb.getMovieGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -787,7 +787,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
const mappedGenres: GenreSliderItem[] = [];
|
const mappedGenres: GenreSliderItem[] = [];
|
||||||
|
|
||||||
const genres = await tmdb.getTvGenres({
|
const genres = await tmdb.getTvGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ router.get('/genres/movie', isAuthenticated(), async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const genres = await tmdb.getMovieGenres({
|
const genres = await tmdb.getMovieGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json(genres);
|
return res.status(200).json(genres);
|
||||||
@@ -258,7 +258,7 @@ router.get('/genres/tv', isAuthenticated(), async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const genres = await tmdb.getTvGenres({
|
const genres = await tmdb.getTvGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json(genres);
|
return res.status(200).json(genres);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const tmdbMovie = await tmdb.getMovie({
|
const tmdbMovie = await tmdb.getMovie({
|
||||||
movieId: Number(req.params.id),
|
movieId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
|
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({
|
const results = await tmdb.getMovieRecommendations({
|
||||||
movieId: Number(req.params.id),
|
movieId: Number(req.params.id),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -85,7 +85,7 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
|
|||||||
const results = await tmdb.getMovieSimilar({
|
const results = await tmdb.getMovieSimilar({
|
||||||
movieId: Number(req.params.id),
|
movieId: Number(req.params.id),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const person = await tmdb.getPerson({
|
const person = await tmdb.getPerson({
|
||||||
personId: Number(req.params.id),
|
personId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
return res.status(200).json(mapPersonDetails(person));
|
return res.status(200).json(mapPersonDetails(person));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -38,7 +38,7 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const combinedCredits = await tmdb.getPersonCombinedCredits({
|
const combinedCredits = await tmdb.getPersonCombinedCredits({
|
||||||
personId: Number(req.params.id),
|
personId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const castMedia = await Media.getRelatedMedia(
|
const castMedia = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ searchRoutes.get('/', async (req, res, next) => {
|
|||||||
.match(searchProvider.pattern) as RegExpMatchArray;
|
.match(searchProvider.pattern) as RegExpMatchArray;
|
||||||
results = await searchProvider.search({
|
results = await searchProvider.search({
|
||||||
id,
|
id,
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
query: queryString,
|
query: queryString,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -29,7 +29,7 @@ searchRoutes.get('/', async (req, res, next) => {
|
|||||||
results = await tmdb.searchMulti({
|
results = await tmdb.searchMulti({
|
||||||
query: queryString,
|
query: queryString,
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
|
|||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import PlexTvAPI from '@server/api/plextv';
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
import TautulliAPI from '@server/api/tautulli';
|
import TautulliAPI from '@server/api/tautulli';
|
||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
@@ -24,8 +25,10 @@ import { getSettings } from '@server/lib/settings';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import discoverSettingRoutes from '@server/routes/settings/discover';
|
import discoverSettingRoutes from '@server/routes/settings/discover';
|
||||||
|
import { ApiError } from '@server/types/error';
|
||||||
import { appDataPath } from '@server/utils/appDataVolume';
|
import { appDataPath } from '@server/utils/appDataVolume';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -252,11 +255,59 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
|
|||||||
res.status(200).json(settings.jellyfin);
|
res.status(200).json(settings.jellyfin);
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/jellyfin', (req, res) => {
|
settingsRoutes.post('/jellyfin', async (req, res, next) => {
|
||||||
|
const userRepository = getRepository(User);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.jellyfin = merge(settings.jellyfin, req.body);
|
try {
|
||||||
settings.save();
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json(settings.jellyfin);
|
return res.status(200).json(settings.jellyfin);
|
||||||
});
|
});
|
||||||
@@ -272,7 +323,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
getHostname(),
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
@@ -288,10 +339,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
|
|
||||||
// Automatic Library grouping is not supported when user views are used to get library
|
// Automatic Library grouping is not supported when user views are used to get library
|
||||||
if (account.Configuration.GroupedFolders.length > 0) {
|
if (account.Configuration.GroupedFolders.length > 0) {
|
||||||
return next({ status: 501, message: 'SYNC_ERROR_GROUPED_FOLDERS' });
|
return next({
|
||||||
|
status: 501,
|
||||||
|
message: ApiErrorCode.SyncErrorGroupedFolders,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return next({ status: 404, message: 'SYNC_ERROR_NO_LIBRARIES' });
|
return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLibraries: Library[] = libraries.map((library) => {
|
const newLibraries: Library[] = libraries.map((library) => {
|
||||||
@@ -322,16 +376,12 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||||
const settings = getSettings();
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
const { hostname, externalHostname } = getSettings().jellyfin;
|
const jellyfinHost =
|
||||||
let jellyfinHost =
|
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: getHostname();
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOneOrFail({
|
const admin = await userRepository.findOneOrFail({
|
||||||
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
@@ -339,7 +389,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
|||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const tv = await tmdb.getTvShow({
|
const tv = await tmdb.getTvShow({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getMedia(tv.id, MediaType.TV);
|
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({
|
const season = await tmdb.getTvSeason({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
seasonNumber: Number(req.params.seasonNumber),
|
seasonNumber: Number(req.params.seasonNumber),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json(mapSeasonWithEpisodes(season));
|
return res.status(200).json(mapSeasonWithEpisodes(season));
|
||||||
@@ -65,7 +65,7 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => {
|
|||||||
const results = await tmdb.getTvRecommendations({
|
const results = await tmdb.getTvRecommendations({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -106,7 +106,7 @@ tvRoutes.get('/:id/similar', async (req, res, next) => {
|
|||||||
const results = await tmdb.getTvSimilar({
|
const results = await tmdb.getTvSimilar({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { hasPermission, Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import gravatarUrl from 'gravatar-url';
|
import gravatarUrl from 'gravatar-url';
|
||||||
import { findIndex, sortBy } from 'lodash';
|
import { findIndex, sortBy } from 'lodash';
|
||||||
@@ -496,7 +497,6 @@ router.post(
|
|||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
@@ -504,15 +504,14 @@ router.post(
|
|||||||
|
|
||||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||||
const createdUsers: User[] = [];
|
const createdUsers: User[] = [];
|
||||||
const { hostname, externalHostname } = getSettings().jellyfin;
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
let jellyfinHost =
|
const hostname = getHostname();
|
||||||
|
|
||||||
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: hostname;
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
const jellyfinUsers = await jellyfinClient.getUsers();
|
const jellyfinUsers = await jellyfinClient.getUsers();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"outDir": "../dist",
|
"outDir": "../dist",
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
|
"incremental": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@server/*": ["*"]
|
"@server/*": ["*"]
|
||||||
|
|||||||
18
server/utils/getHostname.ts
Normal file
18
server/utils/getHostname.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -12,7 +12,7 @@ confinement: strict
|
|||||||
architectures:
|
architectures:
|
||||||
- build-on: amd64
|
- build-on: amd64
|
||||||
- build-on: arm64
|
- build-on: arm64
|
||||||
- build-on: armhf
|
# - build-on: armhf
|
||||||
|
|
||||||
parts:
|
parts:
|
||||||
jellyseerr:
|
jellyseerr:
|
||||||
@@ -27,12 +27,12 @@ parts:
|
|||||||
- automake
|
- automake
|
||||||
- python-gi
|
- python-gi
|
||||||
- python-gi-dev
|
- python-gi-dev
|
||||||
- on armhf:
|
# - on armhf:
|
||||||
- libatomic1
|
# - libatomic1
|
||||||
- build-essential
|
# - build-essential
|
||||||
- automake
|
# - automake
|
||||||
- python-gi
|
# - python-gi
|
||||||
- python-gi-dev
|
# - python-gi-dev
|
||||||
source: .
|
source: .
|
||||||
override-pull: |
|
override-pull: |
|
||||||
snapcraftctl 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
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||||
|
|
||||||
# Set Node.js version
|
# Set Node.js version
|
||||||
NODE_MAJOR=18
|
NODE_MAJOR=20
|
||||||
# Add Node.js repository to sources list
|
# 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
|
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/
|
cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/
|
||||||
# Remove .github and gitbook as it will fail snap lint
|
# Remove .github and gitbook as it will fail snap lint
|
||||||
rm -rf $SNAPCRAFT_PART_INSTALL/.github
|
rm -rf $SNAPCRAFT_PART_INSTALL/.github
|
||||||
stage-packages:
|
# stage-packages:
|
||||||
- on armhf:
|
# - on armhf:
|
||||||
- libatomic1
|
# - libatomic1
|
||||||
stage: [.next, ./*]
|
stage: [.next, ./*]
|
||||||
prime: [.next, ./*]
|
prime: [.next, ./*]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.AirDateBadge', {
|
||||||
airedrelative: 'Aired {relativeTime}',
|
airedrelative: 'Aired {relativeTime}',
|
||||||
airsrelative: 'Airing {relativeTime}',
|
airsrelative: 'Airing {relativeTime}',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import Alert from '@app/components/Common/Alert';
|
import Alert from '@app/components/Common/Alert';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.AppDataWarning', {
|
||||||
dockerVolumeMissingDescription:
|
dockerVolumeMissingDescription:
|
||||||
'The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
|
'The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
@@ -18,10 +19,10 @@ import { uniq } from 'lodash';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.CollectionDetails', {
|
||||||
overview: 'Overview',
|
overview: 'Overview',
|
||||||
numberofmovies: '{count} Movies',
|
numberofmovies: '{count} Movies',
|
||||||
requestcollection: 'Request Collection',
|
requestcollection: 'Request Collection',
|
||||||
@@ -166,10 +167,9 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
<Link
|
<Link
|
||||||
href={`/discover/movies/genre/${genreId}`}
|
href={`/discover/movies/genre/${genreId}`}
|
||||||
key={`genre-${genreId}`}
|
key={`genre-${genreId}`}
|
||||||
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
<a className="hover:underline">
|
{genres.find((g) => g.id === genreId)?.name}
|
||||||
{genres.find((g) => g.id === genreId)?.name}
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
.reduce((prev, curr) => (
|
.reduce((prev, curr) => (
|
||||||
@@ -195,8 +195,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
<CachedImage
|
<CachedImage
|
||||||
alt=""
|
alt=""
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||||
layout="fill"
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
objectFit="cover"
|
fill
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -229,7 +229,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/overseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
layout="responsive"
|
sizes="100vw"
|
||||||
|
style={{ width: '100%', height: 'auto' }}
|
||||||
width={600}
|
width={600}
|
||||||
height={900}
|
height={900}
|
||||||
priority
|
priority
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type * as React from 'react';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import AnimateHeight from 'react-animate-height';
|
import AnimateHeight from 'react-animate-height';
|
||||||
|
|
||||||
|
|||||||
@@ -93,13 +93,12 @@ const Badge = (
|
|||||||
);
|
);
|
||||||
} else if (href) {
|
} else if (href) {
|
||||||
return (
|
return (
|
||||||
<Link href={href}>
|
<Link
|
||||||
<a
|
href={href}
|
||||||
className={badgeStyle.join(' ')}
|
className={badgeStyle.join(' ')}
|
||||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
|
|||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
alt=""
|
alt=""
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
layout="fill"
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
objectFit="cover"
|
fill
|
||||||
{...overrides}
|
{...overrides}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
<CachedImage
|
<CachedImage
|
||||||
alt=""
|
alt=""
|
||||||
src={backdrop}
|
src={backdrop}
|
||||||
layout="fill"
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
objectFit="cover"
|
fill
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -55,15 +55,14 @@ const SettingsLink = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={route}>
|
<Link
|
||||||
<a
|
href={route}
|
||||||
className={`${linkClasses} ${
|
className={`${linkClasses} ${
|
||||||
currentPath.match(regex) ? activeLinkColor : inactiveLinkColor
|
currentPath.match(regex) ? activeLinkColor : inactiveLinkColor
|
||||||
}`}
|
}`}
|
||||||
aria-current="page"
|
aria-current="page"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,40 +12,39 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
|
|||||||
const [isHovered, setHovered] = useState(false);
|
const [isHovered, setHovered] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={url}>
|
<Link
|
||||||
<a
|
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 ${
|
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
|
isHovered
|
||||||
? 'scale-105 bg-gray-700 ring-gray-500'
|
? 'scale-105 bg-gray-700 ring-gray-500'
|
||||||
: 'scale-100 bg-gray-800 ring-gray-700'
|
: 'scale-100 bg-gray-800 ring-gray-700'
|
||||||
} rounded-xl`}
|
} rounded-xl`}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
setHovered(true);
|
setHovered(true);
|
||||||
}}
|
}
|
||||||
onMouseLeave={() => setHovered(false)}
|
}}
|
||||||
onKeyDown={(e) => {
|
role="link"
|
||||||
if (e.key === 'Enter') {
|
tabIndex={0}
|
||||||
setHovered(true);
|
>
|
||||||
}
|
<div className="relative h-full w-full">
|
||||||
}}
|
<CachedImage
|
||||||
role="link"
|
src={image}
|
||||||
tabIndex={0}
|
alt={name}
|
||||||
>
|
className="relative z-40 h-full w-full"
|
||||||
<div className="relative h-full w-full">
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
<CachedImage
|
fill
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</a>
|
</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'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { sliderTitles } from '@app/components/Discover/constants';
|
|||||||
import MediaSlider from '@app/components/MediaSlider';
|
import MediaSlider from '@app/components/MediaSlider';
|
||||||
import { WatchProviderSelector } from '@app/components/Selector';
|
import { WatchProviderSelector } from '@app/components/Selector';
|
||||||
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type {
|
import type {
|
||||||
TmdbCompanySearchResponse,
|
TmdbCompanySearchResponse,
|
||||||
TmdbGenre,
|
TmdbGenre,
|
||||||
@@ -16,12 +17,12 @@ import type { Keyword, ProductionCompany } from '@server/models/common';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import AsyncSelect from 'react-select/async';
|
import AsyncSelect from 'react-select/async';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.CreateSlider', {
|
||||||
addSlider: 'Add Slider',
|
addSlider: 'Add Slider',
|
||||||
editSlider: 'Edit Slider',
|
editSlider: 'Edit Slider',
|
||||||
slidernameplaceholder: 'Slider Name',
|
slidernameplaceholder: 'Slider Name',
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { MovieResult } from '@server/models/Search';
|
import type { MovieResult } from '@server/models/Search';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverMovieGenre', {
|
||||||
genreMovies: '{genre} Movies',
|
genreMovies: '{genre} Movies',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
import type { MovieResult } from '@server/models/Search';
|
import type { MovieResult } from '@server/models/Search';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverMovieKeyword', {
|
||||||
keywordMovies: '{keywordTitle} Movies',
|
keywordMovies: '{keywordTitle} Movies',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { MovieResult } from '@server/models/Search';
|
import type { MovieResult } from '@server/models/Search';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverMovieLanguage', {
|
||||||
languageMovies: '{language} Movies',
|
languageMovies: '{language} Movies',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
|||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||||
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||||
import type { MovieResult } from '@server/models/Search';
|
import type { MovieResult } from '@server/models/Search';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverMovies', {
|
||||||
discovermovies: 'Movies',
|
discovermovies: 'Movies',
|
||||||
activefilters:
|
activefilters:
|
||||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { TvNetwork } from '@server/models/common';
|
import type { TvNetwork } from '@server/models/common';
|
||||||
import type { TvResult } from '@server/models/Search';
|
import type { TvResult } from '@server/models/Search';
|
||||||
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverNetwork', {
|
||||||
networkSeries: '{network} Series',
|
networkSeries: '{network} Series',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,10 +49,11 @@ const DiscoverTvNetwork = () => {
|
|||||||
<Header>
|
<Header>
|
||||||
{firstResultData?.network.logoPath ? (
|
{firstResultData?.network.logoPath ? (
|
||||||
<div className="mb-6 flex justify-center">
|
<div className="mb-6 flex justify-center">
|
||||||
<img
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||||
alt={firstResultData.network.name}
|
alt={firstResultData.network.name}
|
||||||
className="max-h-24 sm:max-h-32"
|
className="max-h-24 sm:max-h-32"
|
||||||
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import CreateSlider from '@app/components/Discover/CreateSlider';
|
|||||||
import GenreTag from '@app/components/GenreTag';
|
import GenreTag from '@app/components/GenreTag';
|
||||||
import KeywordTag from '@app/components/KeywordTag';
|
import KeywordTag from '@app/components/KeywordTag';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
import {
|
import {
|
||||||
ArrowUturnLeftIcon,
|
ArrowUturnLeftIcon,
|
||||||
@@ -22,10 +23,10 @@ import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useDrag, useDrop } from 'react-aria';
|
import { useDrag, useDrop } from 'react-aria';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverSliderEdit', {
|
||||||
deletesuccess: 'Sucessfully deleted slider.',
|
deletesuccess: 'Sucessfully deleted slider.',
|
||||||
deletefail: 'Failed to delete slider.',
|
deletefail: 'Failed to delete slider.',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { ProductionCompany } from '@server/models/common';
|
import type { ProductionCompany } from '@server/models/common';
|
||||||
import type { MovieResult } from '@server/models/Search';
|
import type { MovieResult } from '@server/models/Search';
|
||||||
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverStudio', {
|
||||||
studioMovies: '{studio} Movies',
|
studioMovies: '{studio} Movies',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,10 +49,11 @@ const DiscoverMovieStudio = () => {
|
|||||||
<Header>
|
<Header>
|
||||||
{firstResultData?.studio.logoPath ? (
|
{firstResultData?.studio.logoPath ? (
|
||||||
<div className="mb-6 flex justify-center">
|
<div className="mb-6 flex justify-center">
|
||||||
<img
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||||
alt={firstResultData.studio.name}
|
alt={firstResultData.studio.name}
|
||||||
className="max-h-24 sm:max-h-32"
|
className="max-h-24 sm:max-h-32"
|
||||||
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
|||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||||
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||||
import type { TvResult } from '@server/models/Search';
|
import type { TvResult } from '@server/models/Search';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverTv', {
|
||||||
discovertv: 'Series',
|
discovertv: 'Series',
|
||||||
activefilters:
|
activefilters:
|
||||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { TvResult } from '@server/models/Search';
|
import type { TvResult } from '@server/models/Search';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverTvGenre', {
|
||||||
genreSeries: '{genre} Series',
|
genreSeries: '{genre} Series',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
import type { TvResult } from '@server/models/Search';
|
import type { TvResult } from '@server/models/Search';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverTvKeyword', {
|
||||||
keywordSeries: '{keywordTitle} Series',
|
keywordSeries: '{keywordTitle} Series',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { TvResult } from '@server/models/Search';
|
import type { TvResult } from '@server/models/Search';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverTvLanguage', {
|
||||||
languageSeries: '{language} Series',
|
languageSeries: '{language} Series',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import ListView from '@app/components/Common/ListView';
|
|||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { TvResult } from '@server/models/Search';
|
import type { TvResult } from '@server/models/Search';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.DiscoverTvUpcoming', {});
|
||||||
upcomingtv: 'Upcoming Series',
|
|
||||||
});
|
|
||||||
|
|
||||||
const DiscoverTvUpcoming = () => {
|
const DiscoverTvUpcoming = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.DiscoverWatchlist', {
|
||||||
discoverwatchlist: 'Your Watchlist',
|
discoverwatchlist: 'Your Watchlist',
|
||||||
watchlist: 'Plex Watchlist',
|
watchlist: 'Plex Watchlist',
|
||||||
});
|
});
|
||||||
@@ -58,8 +59,8 @@ const DiscoverWatchlist = () => {
|
|||||||
<Header
|
<Header
|
||||||
subtext={
|
subtext={
|
||||||
router.query.userId ? (
|
router.query.userId ? (
|
||||||
<Link href={`/users/${user?.id}`}>
|
<Link href={`/users/${user?.id}`} className="hover:underline">
|
||||||
<a className="hover:underline">{user?.displayName}</a>
|
{user?.displayName}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import {
|
|||||||
useBatchUpdateQueryParams,
|
useBatchUpdateQueryParams,
|
||||||
useUpdateQueryParams,
|
useUpdateQueryParams,
|
||||||
} from '@app/hooks/useUpdateQueryParams';
|
} from '@app/hooks/useUpdateQueryParams';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { XCircleIcon } from '@heroicons/react/24/outline';
|
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import Datepicker from 'react-tailwindcss-datepicker-sct';
|
import Datepicker from 'react-tailwindcss-datepicker-sct';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.FilterSlideover', {
|
||||||
filters: 'Filters',
|
filters: 'Filters',
|
||||||
activefilters:
|
activefilters:
|
||||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import { genreColorMap } from '@app/components/Discover/constants';
|
import { genreColorMap } from '@app/components/Discover/constants';
|
||||||
import GenreCard from '@app/components/GenreCard';
|
import GenreCard from '@app/components/GenreCard';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.MovieGenreList', {
|
||||||
moviegenres: 'Movie Genres',
|
moviegenres: 'Movie Genres',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { genreColorMap } from '@app/components/Discover/constants';
|
import { genreColorMap } from '@app/components/Discover/constants';
|
||||||
import GenreCard from '@app/components/GenreCard';
|
import GenreCard from '@app/components/GenreCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.MovieGenreSlider', {
|
||||||
moviegenres: 'Movie Genres',
|
moviegenres: 'Movie Genres',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,11 +26,9 @@ const MovieGenreSlider = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="slider-header">
|
<div className="slider-header">
|
||||||
<Link href="/discover/movies/genres">
|
<Link href="/discover/movies/genres" className="slider-title">
|
||||||
<a className="slider-title">
|
<span>{intl.formatMessage(messages.moviegenres)}</span>
|
||||||
<span>{intl.formatMessage(messages.moviegenres)}</span>
|
<ArrowRightCircleIcon />
|
||||||
<ArrowRightCircleIcon />
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import CompanyCard from '@app/components/CompanyCard';
|
import CompanyCard from '@app/components/CompanyCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.NetworkSlider', {
|
||||||
networks: 'Networks',
|
networks: 'Networks',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.PlexWatchlistSlider', {
|
||||||
plexwatchlist: 'Your Watchlist',
|
plexwatchlist: 'Your Watchlist',
|
||||||
emptywatchlist:
|
emptywatchlist:
|
||||||
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
||||||
@@ -39,11 +40,9 @@ const PlexWatchlistSlider = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="slider-header">
|
<div className="slider-header">
|
||||||
<Link href="/discover/watchlist">
|
<Link href="/discover/watchlist" className="slider-title">
|
||||||
<a className="slider-title">
|
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
|
||||||
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
|
<ArrowRightCircleIcon />
|
||||||
<ArrowRightCircleIcon />
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
|
|||||||
@@ -24,11 +24,9 @@ const RecentRequestsSlider = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="slider-header">
|
<div className="slider-header">
|
||||||
<Link href="/requests?filter=all">
|
<Link href="/requests?filter=all" className="slider-title">
|
||||||
<a className="slider-title">
|
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
|
||||||
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
|
<ArrowRightCircleIcon />
|
||||||
<ArrowRightCircleIcon />
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.RecentlyAddedSlider', {
|
||||||
recentlyAdded: 'Recently Added',
|
recentlyAdded: 'Recently Added',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import CompanyCard from '@app/components/CompanyCard';
|
import CompanyCard from '@app/components/CompanyCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.StudioSlider', {
|
||||||
studios: 'Studios',
|
studios: 'Studios',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import ListView from '@app/components/Common/ListView';
|
|||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
PersonResult,
|
PersonResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
} from '@server/models/Search';
|
} from '@server/models/Search';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover', {
|
||||||
trending: 'Trending',
|
trending: 'Trending',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import { genreColorMap } from '@app/components/Discover/constants';
|
import { genreColorMap } from '@app/components/Discover/constants';
|
||||||
import GenreCard from '@app/components/GenreCard';
|
import GenreCard from '@app/components/GenreCard';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.TvGenreList', {
|
||||||
seriesgenres: 'Series Genres',
|
seriesgenres: 'Series Genres',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { genreColorMap } from '@app/components/Discover/constants';
|
import { genreColorMap } from '@app/components/Discover/constants';
|
||||||
import GenreCard from '@app/components/GenreCard';
|
import GenreCard from '@app/components/GenreCard';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover.TvGenreSlider', {
|
||||||
tvgenres: 'Series Genres',
|
tvgenres: 'Series Genres',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,11 +26,9 @@ const TvGenreSlider = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="slider-header">
|
<div className="slider-header">
|
||||||
<Link href="/discover/tv/genres">
|
<Link href="/discover/tv/genres" className="slider-title">
|
||||||
<a className="slider-title">
|
<span>{intl.formatMessage(messages.tvgenres)}</span>
|
||||||
<span>{intl.formatMessage(messages.tvgenres)}</span>
|
<ArrowRightCircleIcon />
|
||||||
<ArrowRightCircleIcon />
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import ListView from '@app/components/Common/ListView';
|
|||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
import useDiscover from '@app/hooks/useDiscover';
|
import useDiscover from '@app/hooks/useDiscover';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { MovieResult } from '@server/models/Search';
|
import type { MovieResult } from '@server/models/Search';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover', {
|
||||||
upcomingmovies: 'Upcoming Movies',
|
upcomingmovies: 'Upcoming Movies',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { ParsedUrlQuery } from 'querystring';
|
import type { ParsedUrlQuery } from 'querystring';
|
||||||
import { defineMessages } from 'react-intl';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
type AvailableColors =
|
type AvailableColors =
|
||||||
@@ -66,7 +66,7 @@ export const genreColorMap: Record<number, [string, string]> = {
|
|||||||
10768: colorTones.darkred, // War & Politics
|
10768: colorTones.darkred, // War & Politics
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sliderTitles = defineMessages({
|
export const sliderTitles = defineMessages('components.Discover', {
|
||||||
recentrequests: 'Recent Requests',
|
recentrequests: 'Recent Requests',
|
||||||
popularmovies: 'Popular Movies',
|
popularmovies: 'Popular Movies',
|
||||||
populartv: 'Popular Series',
|
populartv: 'Popular Series',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import MediaSlider from '@app/components/MediaSlider';
|
|||||||
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
ArrowDownOnSquareIcon,
|
ArrowDownOnSquareIcon,
|
||||||
@@ -29,11 +30,11 @@ import { DiscoverSliderType } from '@server/constants/discover';
|
|||||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Discover', {
|
||||||
discover: 'Discover',
|
discover: 'Discover',
|
||||||
emptywatchlist:
|
emptywatchlist:
|
||||||
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.DownloadBlock', {
|
||||||
estimatedtime: 'Estimated {time}',
|
estimatedtime: 'Estimated {time}',
|
||||||
formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}',
|
formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,37 +14,41 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
|
|||||||
const [isHovered, setHovered] = useState(false);
|
const [isHovered, setHovered] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={url}>
|
<Link
|
||||||
<a
|
href={url}
|
||||||
className={`relative flex h-32 items-center justify-center sm:h-36 ${
|
className={`relative flex h-32 items-center justify-center sm:h-36 ${
|
||||||
canExpand ? 'w-full' : 'w-56 sm:w-72'
|
canExpand ? 'w-full' : 'w-56 sm:w-72'
|
||||||
} transform-gpu cursor-pointer p-8 shadow ring-1 transition duration-300 ease-in-out ${
|
} transform-gpu cursor-pointer p-8 shadow ring-1 transition duration-300 ease-in-out ${
|
||||||
isHovered
|
isHovered
|
||||||
? 'scale-105 bg-gray-700 bg-opacity-100 ring-gray-500'
|
? 'scale-105 bg-gray-700 bg-opacity-100 ring-gray-500'
|
||||||
: 'scale-100 bg-gray-800 bg-opacity-80 ring-gray-700'
|
: 'scale-100 bg-gray-800 bg-opacity-80 ring-gray-700'
|
||||||
} overflow-hidden rounded-xl bg-cover bg-center`}
|
} overflow-hidden rounded-xl bg-cover bg-center`}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
setHovered(true);
|
setHovered(true);
|
||||||
}}
|
}
|
||||||
onMouseLeave={() => setHovered(false)}
|
}}
|
||||||
onKeyDown={(e) => {
|
role="link"
|
||||||
if (e.key === 'Enter') {
|
tabIndex={0}
|
||||||
setHovered(true);
|
>
|
||||||
}
|
<CachedImage
|
||||||
}}
|
src={image}
|
||||||
role="link"
|
alt=""
|
||||||
tabIndex={0}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
>
|
fill
|
||||||
<CachedImage src={image} alt="" layout="fill" objectFit="cover" />
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 z-10 h-full w-full bg-gray-800 transition duration-300 ${
|
className={`absolute inset-0 z-10 h-full w-full bg-gray-800 transition duration-300 ${
|
||||||
isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
|
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">
|
<div className="relative z-20 w-full truncate whitespace-normal text-center text-2xl font-bold text-white sm:text-3xl">
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,10 +45,9 @@ const IssueBlock = ({ issue }: IssueBlockProps) => {
|
|||||||
? '/profile'
|
? '/profile'
|
||||||
: `/users/${issue.createdBy.id}`
|
: `/users/${issue.createdBy.id}`
|
||||||
}
|
}
|
||||||
|
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||||
>
|
>
|
||||||
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
{issue.createdBy.displayName}
|
||||||
{issue.createdBy.displayName}
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +63,7 @@ const IssueBlock = ({ issue }: IssueBlockProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
||||||
<Link href={`/issues/${issue.id}`} passHref>
|
<Link href={`/issues/${issue.id}`} passHref legacyBehavior>
|
||||||
<Button buttonType="primary" as="a">
|
<Button buttonType="primary" as="a">
|
||||||
<EyeIcon />
|
<EyeIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||||
import type { default as IssueCommentType } from '@server/entity/IssueComment';
|
import type { default as IssueCommentType } from '@server/entity/IssueComment';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.IssueDetails.IssueComment', {
|
||||||
postedby: 'Posted {relativeTime} by {username}',
|
postedby: 'Posted {relativeTime} by {username}',
|
||||||
postedbyedited: 'Posted {relativeTime} by {username} (Edited)',
|
postedbyedited: 'Posted {relativeTime} by {username} (Edited)',
|
||||||
delete: 'Delete Comment',
|
delete: 'Delete Comment',
|
||||||
@@ -84,13 +86,13 @@ const IssueComment = ({
|
|||||||
</Modal>
|
</Modal>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
|
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
|
||||||
<a>
|
<Image
|
||||||
<img
|
src={comment.user.avatar}
|
||||||
src={comment.user.avatar}
|
alt=""
|
||||||
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"
|
||||||
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>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<div className="w-full rounded-md shadow ring-1 ring-gray-500">
|
<div className="w-full rounded-md shadow ring-1 ring-gray-500">
|
||||||
@@ -242,10 +244,9 @@ const IssueComment = ({
|
|||||||
href={
|
href={
|
||||||
isActiveUser ? '/profile' : `/users/${comment.user.id}`
|
isActiveUser ? '/profile' : `/users/${comment.user.id}`
|
||||||
}
|
}
|
||||||
|
className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"
|
||||||
>
|
>
|
||||||
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
{comment.user.displayName}
|
||||||
{comment.user.displayName}
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
relativeTime: (
|
relativeTime: (
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.IssueDetails.IssueDescription', {
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
edit: 'Edit Description',
|
edit: 'Edit Description',
|
||||||
deleteissue: 'Delete Issue',
|
deleteissue: 'Delete Issue',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
ChatBubbleOvalLeftEllipsisIcon,
|
ChatBubbleOvalLeftEllipsisIcon,
|
||||||
@@ -29,15 +30,16 @@ import type { TvDetails } from '@server/models/Tv';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import getConfig from 'next/config';
|
import getConfig from 'next/config';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.IssueDetails', {
|
||||||
openedby: '#{issueId} opened {relativeTime} by {username}',
|
openedby: '#{issueId} opened {relativeTime} by {username}',
|
||||||
closeissue: 'Close Issue',
|
closeissue: 'Close Issue',
|
||||||
closeissueandcomment: 'Close with Comment',
|
closeissueandcomment: 'Close with Comment',
|
||||||
@@ -210,8 +212,8 @@ const IssueDetails = () => {
|
|||||||
<CachedImage
|
<CachedImage
|
||||||
alt=""
|
alt=""
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||||
layout="fill"
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
objectFit="cover"
|
fill
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -232,7 +234,8 @@ const IssueDetails = () => {
|
|||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/overseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
layout="responsive"
|
sizes="100vw"
|
||||||
|
style={{ width: '100%', height: 'auto' }}
|
||||||
width={600}
|
width={600}
|
||||||
height={900}
|
height={900}
|
||||||
priority
|
priority
|
||||||
@@ -256,8 +259,9 @@ const IssueDetails = () => {
|
|||||||
href={`/${
|
href={`/${
|
||||||
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
|
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
|
||||||
}/${data.id}`}
|
}/${data.id}`}
|
||||||
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
<a className="hover:underline">{title}</a>
|
{title}
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
{releaseYear && (
|
{releaseYear && (
|
||||||
<span className="media-year">({releaseYear.slice(0, 4)})</span>
|
<span className="media-year">({releaseYear.slice(0, 4)})</span>
|
||||||
@@ -273,17 +277,18 @@ const IssueDetails = () => {
|
|||||||
? '/profile'
|
? '/profile'
|
||||||
: `/users/${issueData.createdBy.id}`
|
: `/users/${issueData.createdBy.id}`
|
||||||
}
|
}
|
||||||
|
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
|
||||||
>
|
>
|
||||||
<a className="group ml-1 inline-flex h-full items-center xl:ml-1.5">
|
<Image
|
||||||
<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"
|
||||||
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}
|
||||||
src={issueData.createdBy.avatar}
|
alt=""
|
||||||
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 className="font-semibold text-gray-100 transition duration-300 group-hover:text-white group-hover:underline">
|
||||||
</span>
|
{issueData.createdBy.displayName}
|
||||||
</a>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
relativeTime: (
|
relativeTime: (
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import CachedImage from '@app/components/Common/CachedImage';
|
|||||||
import { issueOptions } from '@app/components/IssueModal/constants';
|
import { issueOptions } from '@app/components/IssueModal/constants';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { EyeIcon } from '@heroicons/react/24/solid';
|
import { EyeIcon } from '@heroicons/react/24/solid';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import type Issue from '@server/entity/Issue';
|
import type Issue from '@server/entity/Issue';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.IssueList.IssueItem', {
|
||||||
openeduserdate: '{date} by {user}',
|
openeduserdate: '{date} by {user}',
|
||||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||||
episodes: '{episodeCount, plural, one {Episode} other {Episodes}}',
|
episodes: '{episodeCount, plural, one {Episode} other {Episodes}}',
|
||||||
@@ -113,8 +115,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
<CachedImage
|
<CachedImage
|
||||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||||
alt=""
|
alt=""
|
||||||
layout="fill"
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
objectFit="cover"
|
fill
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
@@ -133,21 +135,20 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
? `/movie/${issue.media.tmdbId}`
|
? `/movie/${issue.media.tmdbId}`
|
||||||
: `/tv/${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"
|
||||||
>
|
>
|
||||||
<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
|
||||||
<CachedImage
|
src={
|
||||||
src={
|
title.posterPath
|
||||||
title.posterPath
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
: '/images/overseerr_poster_not_found.png'
|
||||||
: '/images/overseerr_poster_not_found.png'
|
}
|
||||||
}
|
alt=""
|
||||||
alt=""
|
sizes="100vw"
|
||||||
layout="responsive"
|
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
|
||||||
width={600}
|
width={600}
|
||||||
height={900}
|
height={900}
|
||||||
objectFit="cover"
|
/>
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
<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">
|
<div className="pt-0.5 text-xs text-white sm:pt-1">
|
||||||
@@ -162,10 +163,9 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
? `/movie/${issue.media.tmdbId}`
|
? `/movie/${issue.media.tmdbId}`
|
||||||
: `/tv/${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"
|
||||||
>
|
>
|
||||||
<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}
|
||||||
{isMovie(title) ? title.title : title.name}
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
{problemSeasonEpisodeLine.length > 0 && (
|
{problemSeasonEpisodeLine.length > 0 && (
|
||||||
<div className="card-field">
|
<div className="card-field">
|
||||||
@@ -222,17 +222,20 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
user: (
|
user: (
|
||||||
<Link href={`/users/${issue.createdBy.id}`}>
|
<Link
|
||||||
<a className="group flex items-center truncate">
|
href={`/users/${issue.createdBy.id}`}
|
||||||
<img
|
className="group flex items-center truncate"
|
||||||
src={issue.createdBy.avatar}
|
>
|
||||||
alt=""
|
<Image
|
||||||
className="avatar-sm ml-1.5 object-cover"
|
src={issue.createdBy.avatar}
|
||||||
/>
|
alt=""
|
||||||
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
|
className="avatar-sm ml-1.5 object-cover"
|
||||||
{issue.createdBy.displayName}
|
width={20}
|
||||||
</span>
|
height={20}
|
||||||
</a>
|
/>
|
||||||
|
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
|
||||||
|
{issue.createdBy.displayName}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
@@ -259,7 +262,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
</div>
|
</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">
|
<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">
|
<span className="w-full">
|
||||||
<Link href={`/issues/${issue.id}`} passHref>
|
<Link href={`/issues/${issue.id}`} passHref legacyBehavior>
|
||||||
<Button as="a" className="w-full" buttonType="primary">
|
<Button as="a" className="w-full" buttonType="primary">
|
||||||
<EyeIcon />
|
<EyeIcon />
|
||||||
<span>{intl.formatMessage(messages.viewissue)}</span>
|
<span>{intl.formatMessage(messages.viewissue)}</span>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import IssueItem from '@app/components/IssueList/IssueItem';
|
import IssueItem from '@app/components/IssueList/IssueItem';
|
||||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import {
|
import {
|
||||||
BarsArrowDownIcon,
|
BarsArrowDownIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
@@ -14,10 +15,10 @@ import {
|
|||||||
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
|
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.IssueList', {
|
||||||
issues: 'Issues',
|
issues: 'Issues',
|
||||||
sortAdded: 'Most Recent',
|
sortAdded: 'Most Recent',
|
||||||
sortModified: 'Last Modified',
|
sortModified: 'Last Modified',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { issueOptions } from '@app/components/IssueModal/constants';
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { RadioGroup } from '@headlessui/react';
|
import { RadioGroup } from '@headlessui/react';
|
||||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
|
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
@@ -13,12 +14,12 @@ import type { TvDetails } from '@server/models/Tv';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.IssueModal.CreateIssueModal', {
|
||||||
validationMessageRequired: 'You must provide a description',
|
validationMessageRequired: 'You must provide a description',
|
||||||
whatswrong: "What's wrong?",
|
whatswrong: "What's wrong?",
|
||||||
providedetail:
|
providedetail:
|
||||||
@@ -118,7 +119,7 @@ const CreateIssueModal = ({
|
|||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/issues/${newIssue.data.id}`}>
|
<Link href={`/issues/${newIssue.data.id}`} legacyBehavior>
|
||||||
<Button as="a" className="mt-4">
|
<Button as="a" className="mt-4">
|
||||||
<span>{intl.formatMessage(messages.toastviewissue)}</span>
|
<span>{intl.formatMessage(messages.toastviewissue)}</span>
|
||||||
<ArrowRightCircleIcon />
|
<ArrowRightCircleIcon />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { IssueType } from '@server/constants/issue';
|
import { IssueType } from '@server/constants/issue';
|
||||||
import type { MessageDescriptor } from 'react-intl';
|
import type { MessageDescriptor } from 'react-intl';
|
||||||
import { defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.IssueModal', {
|
||||||
issueAudio: 'Audio',
|
issueAudio: 'Audio',
|
||||||
issueVideo: 'Video',
|
issueVideo: 'Video',
|
||||||
issueSubtitles: 'Subtitle',
|
issueSubtitles: 'Subtitle',
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { Language } from '@server/lib/settings';
|
import type { Language } from '@server/lib/settings';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import type { CSSObjectWithLabel } from 'react-select';
|
import type { CSSObjectWithLabel } from 'react-select';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.LanguageSelector', {
|
||||||
originalLanguageDefault: 'All Languages',
|
originalLanguageDefault: 'All Languages',
|
||||||
languageServerDefault: 'Default ({language})',
|
languageServerDefault: 'Default ({language})',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import type { AvailableLocale } from '@app/context/LanguageContext';
|
|||||||
import { availableLanguages } from '@app/context/LanguageContext';
|
import { availableLanguages } from '@app/context/LanguageContext';
|
||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { LanguageIcon } from '@heroicons/react/24/solid';
|
import { LanguageIcon } from '@heroicons/react/24/solid';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Layout.LanguagePicker', {
|
||||||
displaylanguage: 'Display Language',
|
displaylanguage: 'Display Language',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -142,25 +142,25 @@ const MobileMenu = () => {
|
|||||||
{filteredLinks.map((link) => {
|
{filteredLinks.map((link) => {
|
||||||
const isActive = router.pathname.match(link.activeRegExp);
|
const isActive = router.pathname.match(link.activeRegExp);
|
||||||
return (
|
return (
|
||||||
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
<Link
|
||||||
<a
|
key={`mobile-menu-link-${link.href}`}
|
||||||
className={`flex items-center space-x-2 ${
|
href={link.href}
|
||||||
isActive ? 'text-indigo-500' : ''
|
className={`flex items-center space-x-2 ${
|
||||||
}`}
|
isActive ? 'text-indigo-500' : ''
|
||||||
onKeyDown={(e) => {
|
}`}
|
||||||
if (e.key === 'Enter') {
|
onKeyDown={(e) => {
|
||||||
setIsOpen(false);
|
if (e.key === 'Enter') {
|
||||||
}
|
setIsOpen(false);
|
||||||
}}
|
}
|
||||||
onClick={() => setIsOpen(false)}
|
}}
|
||||||
role="button"
|
onClick={() => setIsOpen(false)}
|
||||||
tabIndex={0}
|
role="button"
|
||||||
>
|
tabIndex={0}
|
||||||
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
|
>
|
||||||
className: 'h-5 w-5',
|
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
|
||||||
})}
|
className: 'h-5 w-5',
|
||||||
<span>{link.content}</span>
|
})}
|
||||||
</a>
|
<span>{link.content}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -173,19 +173,19 @@ const MobileMenu = () => {
|
|||||||
const isActive =
|
const isActive =
|
||||||
router.pathname.match(link.activeRegExp) && !isOpen;
|
router.pathname.match(link.activeRegExp) && !isOpen;
|
||||||
return (
|
return (
|
||||||
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
<Link
|
||||||
<a
|
key={`mobile-menu-link-${link.href}`}
|
||||||
className={`flex flex-col items-center space-y-1 ${
|
href={link.href}
|
||||||
isActive ? 'text-indigo-500' : ''
|
className={`flex flex-col items-center space-y-1 ${
|
||||||
}`}
|
isActive ? 'text-indigo-500' : ''
|
||||||
>
|
}`}
|
||||||
{cloneElement(
|
>
|
||||||
isActive ? link.svgIconSelected : link.svgIcon,
|
{cloneElement(
|
||||||
{
|
isActive ? link.svgIconSelected : link.svgIcon,
|
||||||
className: 'h-6 w-6',
|
{
|
||||||
}
|
className: 'h-6 w-6',
|
||||||
)}
|
}
|
||||||
</a>
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import useSearchInput from '@app/hooks/useSearchInput';
|
import useSearchInput from '@app/hooks/useSearchInput';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { XCircleIcon } from '@heroicons/react/24/outline';
|
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Layout.SearchInput', {
|
||||||
searchPlaceholder: 'Search Movies & TV',
|
searchPlaceholder: 'Search Movies & TV',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import UserWarnings from '@app/components/Layout/UserWarnings';
|
|||||||
import VersionStatus from '@app/components/Layout/VersionStatus';
|
import VersionStatus from '@app/components/Layout/VersionStatus';
|
||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
@@ -13,12 +14,13 @@ import {
|
|||||||
UsersIcon,
|
UsersIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { Fragment, useRef } from 'react';
|
import { Fragment, useRef } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
export const menuMessages = defineMessages({
|
export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
||||||
dashboard: 'Discover',
|
dashboard: 'Discover',
|
||||||
browsemovies: 'Movies',
|
browsemovies: 'Movies',
|
||||||
browsetv: 'Series',
|
browsetv: 'Series',
|
||||||
@@ -146,16 +148,16 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={navRef}
|
ref={navRef}
|
||||||
className="flex flex-1 flex-col overflow-y-auto pt-8 pb-8 sm:pb-4"
|
className="flex flex-1 flex-col overflow-y-auto pt-4 pb-8 sm:pb-4"
|
||||||
>
|
>
|
||||||
<div className="flex flex-shrink-0 items-center px-2">
|
<div className="flex flex-shrink-0 items-center px-2">
|
||||||
<span className="px-4 text-xl text-gray-50">
|
<span className="w-full px-4 text-xl text-gray-50">
|
||||||
<a href="/">
|
<Link href="/" className="relative block h-24 w-64">
|
||||||
<img src="/logo_full.svg" alt="Logo" />
|
<Image src="/logo_full.svg" alt="Logo" fill />
|
||||||
</a>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mt-16 flex-1 space-y-4 px-4">
|
<nav className="mt-10 flex-1 space-y-4 px-4">
|
||||||
{SidebarLinks.filter((link) =>
|
{SidebarLinks.filter((link) =>
|
||||||
link.requiredPermission
|
link.requiredPermission
|
||||||
? hasPermission(link.requiredPermission, {
|
? hasPermission(link.requiredPermission, {
|
||||||
@@ -168,32 +170,27 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
|||||||
key={`mobile-${sidebarLink.messagesKey}`}
|
key={`mobile-${sidebarLink.messagesKey}`}
|
||||||
href={sidebarLink.href}
|
href={sidebarLink.href}
|
||||||
as={sidebarLink.as}
|
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`}
|
||||||
>
|
>
|
||||||
<a
|
{sidebarLink.svgIcon}
|
||||||
onClick={() => setClosed()}
|
{intl.formatMessage(
|
||||||
onKeyDown={(e) => {
|
menuMessages[sidebarLink.messagesKey]
|
||||||
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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -221,15 +218,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="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="sidebar flex w-64 flex-col">
|
||||||
<div className="flex h-0 flex-1 flex-col">
|
<div className="flex h-0 flex-1 flex-col">
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto pt-8 pb-4">
|
<div className="flex flex-1 flex-col overflow-y-auto pb-4">
|
||||||
<div className="flex flex-shrink-0 items-center">
|
<div className="flex flex-shrink-0 items-center">
|
||||||
<span className="px-4 text-2xl text-gray-50">
|
<span className="w-full px-4 py-2 text-2xl text-gray-50">
|
||||||
<a href="/">
|
<Link href="/" className="relative block h-24">
|
||||||
<img src="/logo_full.svg" alt="Logo" />
|
<Image src="/logo_full.svg" alt="Logo" fill />
|
||||||
</a>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mt-16 flex-1 space-y-4 px-4">
|
<nav className="mt-8 flex-1 space-y-4 px-4">
|
||||||
{SidebarLinks.filter((link) =>
|
{SidebarLinks.filter((link) =>
|
||||||
link.requiredPermission
|
link.requiredPermission
|
||||||
? hasPermission(link.requiredPermission, {
|
? hasPermission(link.requiredPermission, {
|
||||||
@@ -242,24 +239,19 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
|||||||
key={`desktop-${sidebarLink.messagesKey}`}
|
key={`desktop-${sidebarLink.messagesKey}`}
|
||||||
href={sidebarLink.href}
|
href={sidebarLink.href}
|
||||||
as={sidebarLink.as}
|
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}
|
||||||
>
|
>
|
||||||
<a
|
{sidebarLink.svgIcon}
|
||||||
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
|
{intl.formatMessage(
|
||||||
${
|
menuMessages[sidebarLink.messagesKey]
|
||||||
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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import Infinity from '@app/assets/infinity.svg';
|
import Infinity from '@app/assets/infinity.svg';
|
||||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||||
import ProgressCircle from '@app/components/Common/ProgressCircle';
|
import ProgressCircle from '@app/components/Common/ProgressCircle';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages(
|
||||||
movierequests: 'Movie Requests',
|
'components.Layout.UserDropdown.MiniQuotaDisplay',
|
||||||
seriesrequests: 'Series Requests',
|
{
|
||||||
});
|
movierequests: 'Movie Requests',
|
||||||
|
seriesrequests: 'Series Requests',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
type MiniQuotaDisplayProps = {
|
type MiniQuotaDisplayProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Menu, Transition } from '@headlessui/react';
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
ArrowRightOnRectangleIcon,
|
ArrowRightOnRectangleIcon,
|
||||||
@@ -7,12 +8,13 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import Image from 'next/image';
|
||||||
import type { LinkProps } from 'next/link';
|
import type { LinkProps } from 'next/link';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { forwardRef, Fragment } from 'react';
|
import { forwardRef, Fragment } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Layout.UserDropdown', {
|
||||||
myprofile: 'Profile',
|
myprofile: 'Profile',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
requests: 'Requests',
|
requests: 'Requests',
|
||||||
@@ -24,10 +26,8 @@ const ForwardedLink = forwardRef<
|
|||||||
LinkProps & React.ComponentPropsWithoutRef<'a'>
|
LinkProps & React.ComponentPropsWithoutRef<'a'>
|
||||||
>(({ href, children, ...rest }, ref) => {
|
>(({ href, children, ...rest }, ref) => {
|
||||||
return (
|
return (
|
||||||
<Link href={href}>
|
<Link href={href} ref={ref} {...rest}>
|
||||||
<a ref={ref} {...rest}>
|
{children}
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -53,10 +53,12 @@ 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"
|
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"
|
data-testid="user-menu"
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||||
src={user?.avatar}
|
src={user?.avatar || ''}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
/>
|
/>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,10 +76,12 @@ 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="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 flex-col space-y-4 px-4 py-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<img
|
<Image
|
||||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||||
src={user?.avatar}
|
src={user?.avatar || ''}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
/>
|
/>
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<span className="truncate text-xl font-semibold text-gray-200">
|
<span className="truncate text-xl font-semibold text-gray-200">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type React from 'react';
|
import { useIntl } from 'react-intl';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Layout.UserWarnings', {
|
||||||
emailRequired: 'An email address is required.',
|
emailRequired: 'An email address is required.',
|
||||||
emailInvalid: 'Email address is invalid.',
|
emailInvalid: 'Email address is invalid.',
|
||||||
passwordRequired: 'A password is required.',
|
passwordRequired: 'A password is required.',
|
||||||
@@ -37,24 +37,23 @@ const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res = (
|
res = (
|
||||||
<Link href={link}>
|
<Link
|
||||||
<a
|
href={link}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && onClick) {
|
if (e.key === 'Enter' && onClick) {
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
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"
|
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" />
|
<ExclamationTriangleIcon className="h-6 w-6" />
|
||||||
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
||||||
<span className="font-bold">{warningTitle}</span>
|
<span className="font-bold">{warningTitle}</span>
|
||||||
<span className="truncate">{warningText}</span>
|
<span className="truncate">{warningText}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import {
|
import {
|
||||||
ArrowUpCircleIcon,
|
ArrowUpCircleIcon,
|
||||||
BeakerIcon,
|
BeakerIcon,
|
||||||
@@ -6,10 +7,10 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Layout.VersionStatus', {
|
||||||
streamdevelop: 'Jellyseerr Develop',
|
streamdevelop: 'Jellyseerr Develop',
|
||||||
streamstable: 'Jellyseerr Stable',
|
streamstable: 'Jellyseerr Stable',
|
||||||
outofdate: 'Out of Date',
|
outofdate: 'Out of Date',
|
||||||
@@ -39,49 +40,48 @@ const VersionStatus = ({ onClick }: VersionStatusProps) => {
|
|||||||
: intl.formatMessage(messages.streamstable);
|
: intl.formatMessage(messages.streamstable);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href="/settings/about">
|
<Link
|
||||||
<a
|
href="/settings/about"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && onClick) {
|
if (e.key === 'Enter' && onClick) {
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={`mx-2 flex items-center rounded-lg p-2 text-xs ring-1 ring-gray-700 transition duration-300 ${
|
className={`mx-2 flex items-center rounded-lg p-2 text-xs ring-1 ring-gray-700 transition duration-300 ${
|
||||||
data.updateAvailable
|
data.updateAvailable
|
||||||
? 'bg-yellow-500 text-white hover:bg-yellow-400'
|
? 'bg-yellow-500 text-white hover:bg-yellow-400'
|
||||||
: 'bg-gray-900 text-gray-300 hover:bg-gray-800'
|
: 'bg-gray-900 text-gray-300 hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{data.commitTag === 'local' ? (
|
{data.commitTag === 'local' ? (
|
||||||
<CodeBracketIcon className="h-6 w-6" />
|
<CodeBracketIcon className="h-6 w-6" />
|
||||||
) : data.version.startsWith('develop-') ? (
|
) : data.version.startsWith('develop-') ? (
|
||||||
<BeakerIcon className="h-6 w-6" />
|
<BeakerIcon className="h-6 w-6" />
|
||||||
) : (
|
) : (
|
||||||
<ServerIcon 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">
|
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
||||||
<span className="font-bold">{versionStream}</span>
|
<span className="font-bold">{versionStream}</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{data.commitTag === 'local' ? (
|
{data.commitTag === 'local' ? (
|
||||||
'(⌐■_■)'
|
'(⌐■_■)'
|
||||||
) : data.commitsBehind > 0 ? (
|
) : data.commitsBehind > 0 ? (
|
||||||
intl.formatMessage(messages.commitsbehind, {
|
intl.formatMessage(messages.commitsbehind, {
|
||||||
commitsBehind: data.commitsBehind,
|
commitsBehind: data.commitsBehind,
|
||||||
})
|
})
|
||||||
) : data.commitsBehind === -1 ? (
|
) : data.commitsBehind === -1 ? (
|
||||||
intl.formatMessage(messages.outofdate)
|
intl.formatMessage(messages.outofdate)
|
||||||
) : (
|
) : (
|
||||||
<code className="bg-transparent p-0">
|
<code className="bg-transparent p-0">
|
||||||
{data.version.replace('develop-', '')}
|
{data.version.replace('develop-', '')}
|
||||||
</code>
|
</code>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{data.updateAvailable && <ArrowUpCircleIcon className="h-6 w-6" />}
|
{data.updateAvailable && <ArrowUpCircleIcon className="h-6 w-6" />}
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
import type React from 'react';
|
import { useIntl } from 'react-intl';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Login', {
|
||||||
title: 'Add Email',
|
title: 'Add Email',
|
||||||
description:
|
description:
|
||||||
'Since this is your first time logging into {applicationName}, you are required to add a valid email address.',
|
'Since this is your first time logging into {applicationName}, you are required to add a valid email address.',
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import Tooltip from '@app/components/Common/Tooltip';
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import getConfig from 'next/config';
|
import getConfig from 'next/config';
|
||||||
import type React from 'react';
|
import { useIntl } from 'react-intl';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Login', {
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
host: '{mediaServerName} URL',
|
hostname: '{mediaServerName} URL',
|
||||||
|
port: 'Port',
|
||||||
|
enablessl: 'Use SSL',
|
||||||
|
urlBase: 'URL Base',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
emailtooltip:
|
emailtooltip:
|
||||||
'Address does not need to be associated with your {mediaServerName} instance.',
|
'Address does not need to be associated with your {mediaServerName} instance.',
|
||||||
@@ -24,6 +27,11 @@ const messages = defineMessages({
|
|||||||
validationemailformat: 'Valid email required',
|
validationemailformat: 'Valid email required',
|
||||||
validationusernamerequired: 'Username required',
|
validationusernamerequired: 'Username required',
|
||||||
validationpasswordrequired: 'Password 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.',
|
loginerror: 'Something went wrong while trying to sign in.',
|
||||||
adminerror: 'You must use an admin account to sign in.',
|
adminerror: 'You must use an admin account to sign in.',
|
||||||
credentialerror: 'The username or password is incorrect.',
|
credentialerror: 'The username or password is incorrect.',
|
||||||
@@ -51,16 +59,23 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
|
|
||||||
if (initial) {
|
if (initial) {
|
||||||
const LoginSchema = Yup.object().shape({
|
const LoginSchema = Yup.object().shape({
|
||||||
host: Yup.string()
|
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()
|
||||||
.matches(
|
.matches(
|
||||||
/^(?:(?:(?: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)
|
intl.formatMessage(messages.validationUrlBaseLeadingSlash)
|
||||||
)
|
)
|
||||||
.required(
|
.matches(
|
||||||
intl.formatMessage(messages.validationhostrequired, {
|
/^(.*[^/])$/,
|
||||||
mediaServerName:
|
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
|
||||||
})
|
|
||||||
),
|
),
|
||||||
email: Yup.string()
|
email: Yup.string()
|
||||||
.email(intl.formatMessage(messages.validationemailformat))
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
@@ -75,12 +90,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
mediaServerName:
|
mediaServerName:
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
host: '',
|
hostname: '',
|
||||||
|
port: 8096,
|
||||||
|
useSsl: false,
|
||||||
|
urlBase: '',
|
||||||
email: '',
|
email: '',
|
||||||
}}
|
}}
|
||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
@@ -89,7 +108,10 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
await axios.post('/api/v1/auth/jellyfin', {
|
await axios.post('/api/v1/auth/jellyfin', {
|
||||||
username: values.username,
|
username: values.username,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
hostname: values.host,
|
hostname: values.hostname,
|
||||||
|
port: values.port,
|
||||||
|
useSsl: values.useSsl,
|
||||||
|
urlBase: values.urlBase,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -121,32 +143,100 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ errors, touched, isSubmitting, isValid }) => (
|
{({
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
values,
|
||||||
|
setFieldValue,
|
||||||
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
|
}) => (
|
||||||
<Form>
|
<Form>
|
||||||
<div className="sm:border-t sm:border-gray-800">
|
<div className="sm:border-t sm:border-gray-800">
|
||||||
<label htmlFor="host" className="text-label">
|
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||||
{intl.formatMessage(messages.host, mediaServerFormatValues)}
|
<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>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||||
<div className="flex rounded-md shadow-sm">
|
<div className="flex rounded-md shadow-sm">
|
||||||
<Field
|
<Field
|
||||||
id="host"
|
|
||||||
name="host"
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={intl.formatMessage(
|
inputMode="url"
|
||||||
messages.host,
|
id="urlBase"
|
||||||
mediaServerFormatValues
|
name="urlBase"
|
||||||
)}
|
placeholder={intl.formatMessage(messages.urlBase)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.host && touched.host && (
|
{errors.urlBase && touched.urlBase && (
|
||||||
<div className="error">{errors.host}</div>
|
<div className="error">{errors.urlBase}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label
|
<label
|
||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
className="text-label"
|
className="text-label inline-flex gap-1 align-middle"
|
||||||
style={{ display: 'inline-flex' }}
|
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.email)}
|
{intl.formatMessage(messages.email)}
|
||||||
<span className="label-tip">
|
<span className="label-tip">
|
||||||
@@ -162,7 +252,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
|
||||||
<div className="flex rounded-md shadow-sm">
|
<div className="flex rounded-md shadow-sm">
|
||||||
<Field
|
<Field
|
||||||
id="email"
|
id="email"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import {
|
import {
|
||||||
ArrowLeftOnRectangleIcon,
|
ArrowLeftOnRectangleIcon,
|
||||||
LifebuoyIcon,
|
LifebuoyIcon,
|
||||||
@@ -9,10 +10,10 @@ import axios from 'axios';
|
|||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Login', {
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
email: 'Email Address',
|
email: 'Email Address',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
@@ -137,7 +138,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
|||||||
</span>
|
</span>
|
||||||
{passwordResetEnabled && (
|
{passwordResetEnabled && (
|
||||||
<span className="inline-flex rounded-md shadow-sm">
|
<span className="inline-flex rounded-md shadow-sm">
|
||||||
<Link href="/resetpassword" passHref>
|
<Link href="/resetpassword" passHref legacyBehavior>
|
||||||
<Button as="a" buttonType="ghost">
|
<Button as="a" buttonType="ghost">
|
||||||
<LifebuoyIcon />
|
<LifebuoyIcon />
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -6,18 +6,20 @@ import LocalLogin from '@app/components/Login/LocalLogin';
|
|||||||
import PlexLoginButton from '@app/components/PlexLoginButton';
|
import PlexLoginButton from '@app/components/PlexLoginButton';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { XCircleIcon } from '@heroicons/react/24/solid';
|
import { XCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import getConfig from 'next/config';
|
import getConfig from 'next/config';
|
||||||
import { useRouter } from 'next/dist/client/router';
|
import { useRouter } from 'next/dist/client/router';
|
||||||
|
import Image from 'next/image';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import JellyfinLogin from './JellyfinLogin';
|
import JellyfinLogin from './JellyfinLogin';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages('components.Login', {
|
||||||
signin: 'Sign In',
|
signin: 'Sign In',
|
||||||
signinheader: 'Sign in to continue',
|
signinheader: 'Sign in to continue',
|
||||||
signinwithplex: 'Use your Plex account',
|
signinwithplex: 'Use your Plex account',
|
||||||
@@ -86,8 +88,10 @@ const Login = () => {
|
|||||||
<LanguagePicker />
|
<LanguagePicker />
|
||||||
</div>
|
</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 z-40 mt-10 flex flex-col items-center px-4 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<img src="/logo_stacked.svg" className="mb-10 max-w-full" alt="Logo" />
|
<div className="relative h-48 w-full max-w-full">
|
||||||
<h2 className="mt-2 text-center text-3xl font-extrabold leading-9 text-gray-100">
|
<Image src="/logo_stacked.svg" alt="Logo" fill />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-12 text-center text-3xl font-extrabold leading-9 text-gray-100">
|
||||||
{intl.formatMessage(messages.signinheader)}
|
{intl.formatMessage(messages.signinheader)}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user