Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9144a2167 | ||
|
|
59822d6e42 | ||
|
|
18925decaf | ||
|
|
67146c33ef | ||
|
|
d5eb4d8d43 | ||
|
|
fc12ab84d9 | ||
|
|
0d088e085e | ||
|
|
2fe53ec5a8 | ||
|
|
ce0266f74e | ||
|
|
c21fa5b535 | ||
|
|
647d5efb09 | ||
|
|
bb89f679b6 | ||
|
|
3210ac29d0 | ||
|
|
e9c899ce41 | ||
|
|
0bd1bee1fa | ||
|
|
46326f9d16 | ||
|
|
ad18a40969 | ||
|
|
ae2ede8952 | ||
|
|
675060bcdf | ||
|
|
f8f388db6d | ||
|
|
2948f9360e | ||
|
|
df4ac8361f | ||
|
|
92a874e36d | ||
|
|
6d7907e844 | ||
|
|
d9788c4aa9 | ||
|
|
db0a5c44f6 | ||
|
|
c545ec16e7 | ||
|
|
961d110720 | ||
|
|
e8776fd336 | ||
|
|
7c08809501 | ||
|
|
7fd43c9529 | ||
|
|
33f8831e88 | ||
|
|
20b119c1ac | ||
|
|
6cd20491d2 | ||
|
|
cb8a50bd66 | ||
|
|
fddbb3cdfe | ||
|
|
01c179f762 | ||
|
|
e08fa35548 | ||
|
|
d96d65b8af | ||
|
|
54b96cc451 | ||
|
|
7d2a187865 | ||
|
|
900827be97 | ||
|
|
b9faa6486b | ||
|
|
3f2a04c881 | ||
|
|
ff618956b5 | ||
|
|
b5988f9a5f | ||
|
|
3ba09d07eb | ||
|
|
f998873fc5 | ||
|
|
33b54c08b5 | ||
|
|
34136ef617 | ||
|
|
17df5dd072 | ||
|
|
4b48a36ad3 | ||
|
|
2d3359a7e3 | ||
|
|
6d08b10820 | ||
|
|
78af1a3e6d | ||
|
|
16bd3e9fd5 | ||
|
|
6360c82898 | ||
|
|
67a8f7fbfe | ||
|
|
e17c637483 | ||
|
|
15013d6c5d | ||
|
|
8cb05c413a | ||
|
|
2098a2d3d2 | ||
|
|
6c1ee830a1 | ||
|
|
0658b7943e | ||
|
|
f1df9eeb2f | ||
|
|
edbbccf3ae | ||
|
|
a3973791fb | ||
|
|
cb5c0403c1 | ||
|
|
7bf924f7e9 | ||
|
|
c53dc3b15d | ||
|
|
b96bd33982 | ||
|
|
92c9001c9d |
@@ -70,6 +70,80 @@
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jvennik",
|
||||
"name": "jvennik",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/6672637?v=4",
|
||||
"profile": "https://github.com/jvennik",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "darknessgp",
|
||||
"name": "darknessgp",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/1521243?v=4",
|
||||
"profile": "https://github.com/darknessgp",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "saltydk",
|
||||
"name": "salty",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/6587950?v=4",
|
||||
"profile": "https://github.com/saltydk",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Shutruk",
|
||||
"name": "Shutruk",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/9198633?v=4",
|
||||
"profile": "https://github.com/Shutruk",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "krystiancharubin",
|
||||
"name": "Krystian Charubin",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/17775600?v=4",
|
||||
"profile": "https://github.com/krystiancharubin",
|
||||
"contributions": [
|
||||
"design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kieron",
|
||||
"name": "Kieron Boswell",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/8655212?v=4",
|
||||
"profile": "https://github.com/kieron",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "samwiseg0",
|
||||
"name": "samwiseg0",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/2241731?v=4",
|
||||
"profile": "https://github.com/samwiseg0",
|
||||
"contributions": [
|
||||
"question",
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ecelebi29",
|
||||
"name": "ecelebi29",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/8337120?v=4",
|
||||
"profile": "https://github.com/ecelebi29",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -9,6 +9,11 @@ assignees: ''
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**What version of Overseerr are you running?**
|
||||
Please fill in the version you are currently running.
|
||||
|
||||
You can find it under: Settings -> About -> Version
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -37,4 +37,5 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: sctx/overseerr
|
||||
build_args: COMMIT_TAG=${{ github.sha }}
|
||||
tags: develop
|
||||
|
||||
19
.github/workflows/invalid_template.yml
vendored
Normal file
19
.github/workflows/invalid_template.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'Invalid Template'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, reopened]
|
||||
|
||||
jobs:
|
||||
support:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/support-requests@v2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
support-label: 'invalid:template-incomplete'
|
||||
issue-comment: >
|
||||
:wave: @{issue-author}, please edit your issue and follow the template provided.
|
||||
close-issue: false
|
||||
lock-issue: true
|
||||
issue-lock-reason: 'resolved'
|
||||
92
CHANGELOG.md
92
CHANGELOG.md
@@ -1,3 +1,95 @@
|
||||
# [1.7.0](https://github.com/sct/overseerr/compare/v1.6.0...v1.7.0) (2020-12-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email:** do not pass auth object to transport if no auth data present ([d5eb4d8](https://github.com/sct/overseerr/commit/d5eb4d8d438a159266b2de66b6bcdd9440a0c8ef)), closes [#312](https://github.com/sct/overseerr/issues/312)
|
||||
* **frontend:** add http/https prefix to hostname fields for plex/radarr/sonarr ([ce0266f](https://github.com/sct/overseerr/commit/ce0266f74ea3979b291ff962271a928682892788)), closes [#357](https://github.com/sct/overseerr/issues/357)
|
||||
* **frontend:** clarify that radarr/sonnarr servers must be tested before profiles/folders appear ([fc12ab8](https://github.com/sct/overseerr/commit/fc12ab84d9482eb3a11f117f8cab6fd48a9401cd)), closes [#326](https://github.com/sct/overseerr/issues/326) [#328](https://github.com/sct/overseerr/issues/328)
|
||||
* **frontend:** correctly show an unauthorized error when a user fails to login ([18925de](https://github.com/sct/overseerr/commit/18925decafdac518f52a354c594cc378d2529022)), closes [#322](https://github.com/sct/overseerr/issues/322)
|
||||
* **frontend:** fix tv shows failing to open when firstAirDate is undefined ([c21fa5b](https://github.com/sct/overseerr/commit/c21fa5b5350abdd8e03c077fde7246fa398e176e)), closes [#347](https://github.com/sct/overseerr/issues/347)
|
||||
* **frontend:** make minimum availability required for Radarr servers ([2fe53ec](https://github.com/sct/overseerr/commit/2fe53ec5a8534e75c7d0cef31a8b46065111e0a7)), closes [#345](https://github.com/sct/overseerr/issues/345)
|
||||
* **plex-sync:** bundle duplicate ratingKeys to speed up recently added sync ([67146c3](https://github.com/sct/overseerr/commit/67146c33ef7f28d520ba2c50b32673d43f4525c8)), closes [#360](https://github.com/sct/overseerr/issues/360)
|
||||
* **sonarr.ts, mediarequest.ts:** add missing seasonFolder option ([#358](https://github.com/sct/overseerr/issues/358)) ([e9c899c](https://github.com/sct/overseerr/commit/e9c899ce419d149dde2ad9a0f7d5a2f2545b3ebf))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** show alert when there are no default radarr/sonarr servers ([0d088e0](https://github.com/sct/overseerr/commit/0d088e085e68d39455fda21d1fd08ebcaef2c06b)), closes [#344](https://github.com/sct/overseerr/issues/344)
|
||||
|
||||
# [1.6.0](https://github.com/sct/overseerr/compare/v1.5.0...v1.6.0) (2020-12-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** accept the api key to perform actions on the api with X-API-Key header ([33f8831](https://github.com/sct/overseerr/commit/33f8831e880dc7fd3f69d951246cada5c6c0ffe7))
|
||||
* **api:** filter out libraries that do not have any metadata agent or are not movie/show ([01c179f](https://github.com/sct/overseerr/commit/01c179f762e686a1e5a3d4dab3a5bea53425b575))
|
||||
* **api:** only run recently added sync on enabled libraries ([e08fa35](https://github.com/sct/overseerr/commit/e08fa35548bb8644afa8df3124e6f9cc3a2c8f4a)), closes [#259](https://github.com/sct/overseerr/issues/259)
|
||||
* **api:** set plex libraries to disabled if the name changes ([675060b](https://github.com/sct/overseerr/commit/675060bcdf23acbfd4de2900a65f95e74f4966a5)), closes [#324](https://github.com/sct/overseerr/issues/324)
|
||||
* **frontend:** adds a tip to plex setup to clarify that syncing runs in the background ([df4ac83](https://github.com/sct/overseerr/commit/df4ac8361f82971ee845f3be217408a9123a0bf3)), closes [#325](https://github.com/sct/overseerr/issues/325)
|
||||
* **frontend:** aligned movie and tv details ([#331](https://github.com/sct/overseerr/issues/331)) ([db0a5c4](https://github.com/sct/overseerr/commit/db0a5c44f678e76eee7f5582381016306d1f46a2))
|
||||
* **frontend:** close sidebar when clicking outside ([#333](https://github.com/sct/overseerr/issues/333)) ([6d7907e](https://github.com/sct/overseerr/commit/6d7907e844a909993d185759d660632f55aeaa35))
|
||||
* spelling mistake on the word 'requested' fixed ([#319](https://github.com/sct/overseerr/issues/319)) ([961d110](https://github.com/sct/overseerr/commit/961d1107208069a6fc820a1ba97ffda7336677cb))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add version to startup logs ([2948f93](https://github.com/sct/overseerr/commit/2948f9360eb484d1d6c0740a840135ca97e7240a))
|
||||
* **frontend:** temporary logs page to clear up confusion about it 404ing ([d9788c4](https://github.com/sct/overseerr/commit/d9788c4aa9f87e2eda3f7e3f1adc985f16039552)), closes [#272](https://github.com/sct/overseerr/issues/272)
|
||||
* **lang:** add support for Spanish language ([6cd2049](https://github.com/sct/overseerr/commit/6cd20491d2a0ceb995c4744eeb92a6e2f57a4893))
|
||||
* **lang:** Translations update from Weblate ([#291](https://github.com/sct/overseerr/issues/291)) ([fddbb3c](https://github.com/sct/overseerr/commit/fddbb3cdfe3d50b2835c248556139c769dc2b805))
|
||||
|
||||
# [1.5.0](https://github.com/sct/overseerr/compare/v1.4.0...v1.5.0) (2020-12-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** require package.json directly so typescript doesnt compile it into dist folder ([b9faa64](https://github.com/sct/overseerr/commit/b9faa6486b35aa865019aa8af9d307531054bc1d))
|
||||
* **frontend:** add validation for Radarr/Sonarr server name ([b5988f9](https://github.com/sct/overseerr/commit/b5988f9a5ff274e97f208c2726abe76c22c858ee))
|
||||
* **frontend:** only show alpha notice to admins ([ff61895](https://github.com/sct/overseerr/commit/ff618956b5d9cf933d867ea979b612c3d8a6f30b))
|
||||
* add support for ssl when connecting to plex ([3ba09d0](https://github.com/sct/overseerr/commit/3ba09d07eb0367c41603cd55e7ff41c66fb641c4)), closes [#275](https://github.com/sct/overseerr/issues/275)
|
||||
* **services:** improve logging for when Radarr movie already exists ([#285](https://github.com/sct/overseerr/issues/285)) ([f998873](https://github.com/sct/overseerr/commit/f998873fc5669a547901f2733c9c785d744d27ca)), closes [#260](https://github.com/sct/overseerr/issues/260)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** add i18n strings for new about page ([900827b](https://github.com/sct/overseerr/commit/900827be97845688e4bea72a8c5d9611a3e9d069))
|
||||
* about page initial version ([3f2a04c](https://github.com/sct/overseerr/commit/3f2a04c881bf06b73a952181fa463af84454b0dd))
|
||||
|
||||
# [1.4.0](https://github.com/sct/overseerr/compare/v1.3.2...v1.4.0) (2020-12-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* changing parameter name to use correct 'port' [#276](https://github.com/sct/overseerr/issues/276) ([#277](https://github.com/sct/overseerr/issues/277)) ([6d08b10](https://github.com/sct/overseerr/commit/6d08b108200177ca3068c852e60a0df75ce2232a))
|
||||
* **services:** include radarr/sonarr baseUrl when adding media ([78af1a3](https://github.com/sct/overseerr/commit/78af1a3e6d00a5645a05e7bf3cf56a59439b6cc9))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **lang:** Translations update from Weblate ([#240](https://github.com/sct/overseerr/issues/240)) ([e17c637](https://github.com/sct/overseerr/commit/e17c63748362b6a480693e003ef5eec614dcec43))
|
||||
|
||||
## [1.3.2](https://github.com/sct/overseerr/compare/v1.3.1...v1.3.2) (2020-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** convert plex port to a number before posting to the api ([8cb05c4](https://github.com/sct/overseerr/commit/8cb05c413a15a4b74e37ece5e24367d115995b32))
|
||||
* **frontend:** converts email smtp port to a number before posting to the api ([2098a2d](https://github.com/sct/overseerr/commit/2098a2d3d2981fd2ae54392aec3ef81327f2858e)), closes [#251](https://github.com/sct/overseerr/issues/251)
|
||||
* **frontend:** encode special characters in search input to prevent crashing router ([15013d6](https://github.com/sct/overseerr/commit/15013d6c5dbff15704c7c30d261d68a265e7f2d7)), closes [#252](https://github.com/sct/overseerr/issues/252)
|
||||
* **plex sync:** catch errors that occur during processMovie ([edbbccf](https://github.com/sct/overseerr/commit/edbbccf3ae623430294f1a5c3fd2728dbd42e555)), closes [#244](https://github.com/sct/overseerr/issues/244) [#246](https://github.com/sct/overseerr/issues/246) [#250](https://github.com/sct/overseerr/issues/250)
|
||||
* **services:** improve logging for adding movies to Radarr ([6c1ee83](https://github.com/sct/overseerr/commit/6c1ee830a183f89bb1fe96a181a7d61684e23b22))
|
||||
* **services:** radarr/sonarr will use the correct default server ([0658b79](https://github.com/sct/overseerr/commit/0658b7943e1ab25816db9da34d4c9ea808d9203d))
|
||||
|
||||
## [1.3.1](https://github.com/sct/overseerr/compare/v1.3.0...v1.3.1) (2020-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** also convert activeProfileId to a number for radarr/sonarr submissions ([7bf924f](https://github.com/sct/overseerr/commit/7bf924f7e94a0e0834f41b4ec067ed277c652766))
|
||||
* **frontend:** also convert ports to numbers when saving radarr/sonarr servers ([c53dc3b](https://github.com/sct/overseerr/commit/c53dc3b15da522c6e6ab76bbc9d15008a8a9fb9d))
|
||||
* **frontend:** new radarr/sonarr ports will be converted to a number before posting ([92c9001](https://github.com/sct/overseerr/commit/92c9001c9d1f2cbd272a5897ea1157d2cadbce2d))
|
||||
|
||||
# [1.3.0](https://github.com/sct/overseerr/compare/v1.2.0...v1.3.0) (2020-12-14)
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ You can also run the development environment in [Docker](https://www.docker.com/
|
||||
- PRs with commits not following this standard will not be merged.
|
||||
- Please make meaningful commits, or squash them
|
||||
- Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch.
|
||||
- It is your responsbility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
|
||||
- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
|
||||
- You can create a Draft pull request early to get feedback on your work.
|
||||
- Your code must be formatted correctly or the tests will fail.
|
||||
- We use Prettier to format our codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save.
|
||||
|
||||
@@ -12,6 +12,9 @@ RUN yarn cache clean
|
||||
|
||||
FROM node:12.18-alpine
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
@@ -21,3 +24,5 @@ COPY --from=BUILD_IMAGE /app/.next ./.next
|
||||
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
|
||||
|
||||
CMD yarn start
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
29
README.md
29
README.md
@@ -16,7 +16,7 @@
|
||||
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-7-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-15-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
- More notification types (Slack/Telegram/etc.).
|
||||
- Issues system. This will allow users to report issues with content on your media server.
|
||||
- Local user system (for those who don't use Plex).
|
||||
- Compatiblity APIs (to work with existing tools in your system).
|
||||
- Compatibility APIs (to work with existing tools in your system).
|
||||
|
||||
## Running Overseerr
|
||||
|
||||
@@ -86,7 +86,13 @@ If you would like to chat with community members you can join the [Overseerr Dis
|
||||
|
||||
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
|
||||
|
||||
## Contributors
|
||||
## Contributing
|
||||
|
||||
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
@@ -101,13 +107,20 @@ Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_COND
|
||||
<td align="center"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4" width="100px;" alt=""/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4" width="100px;" alt=""/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4" width="100px;" alt=""/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4" width="100px;" alt=""/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
||||
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
|
||||
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4" width="100px;" alt=""/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-enable -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
## Contributing
|
||||
|
||||
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
|
||||
@@ -5,6 +5,7 @@ const devConfig = {
|
||||
logging: false,
|
||||
entities: ['server/entity/**/*.ts'],
|
||||
migrations: ['server/migration/**/*.ts'],
|
||||
subscribers: ['server/subscriber/**/*.ts'],
|
||||
cli: {
|
||||
entitiesDir: 'server/entity',
|
||||
migrationsDir: 'server/migration',
|
||||
@@ -19,6 +20,7 @@ const prodConfig = {
|
||||
entities: ['dist/entity/**/*.js'],
|
||||
migrations: ['dist/migration/**/*.js'],
|
||||
migrationsRun: true,
|
||||
subscribers: ['dist/subscriber/**/*.js'],
|
||||
cli: {
|
||||
entitiesDir: 'dist/entity',
|
||||
migrationsDir: 'dist/migration',
|
||||
|
||||
@@ -643,7 +643,6 @@ components:
|
||||
readOnly: true
|
||||
requestedBy:
|
||||
$ref: '#/components/schemas/User'
|
||||
readOnly: true
|
||||
modifiedBy:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/User'
|
||||
@@ -967,6 +966,10 @@ components:
|
||||
type: apiKey
|
||||
name: connect.sid
|
||||
in: cookie
|
||||
apiKey:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Api-Key
|
||||
|
||||
paths:
|
||||
/settings/main:
|
||||
@@ -1478,6 +1481,29 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DiscordSettings'
|
||||
/settings/about:
|
||||
get:
|
||||
summary: Return current about stats
|
||||
description: Returns current server stats in JSON format
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned about settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
example: '1.0.0'
|
||||
totalRequests:
|
||||
type: number
|
||||
example: 100
|
||||
totalMediaItems:
|
||||
type: number
|
||||
example: 100
|
||||
/auth/me:
|
||||
get:
|
||||
summary: Returns the currently logged in user
|
||||
@@ -2463,3 +2489,4 @@ paths:
|
||||
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.3.0",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
||||
@@ -11,6 +11,7 @@
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false './src/**/!(*.test).{ts,tsx}'",
|
||||
"migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate",
|
||||
"migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create",
|
||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#1e2937","display":"standalone"}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { getSettings } from '../lib/settings';
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
parentRatingKey?: string;
|
||||
grandparentRatingKey?: string;
|
||||
title: string;
|
||||
guid: string;
|
||||
parentGuid?: string;
|
||||
type: 'movie' | 'show' | 'season';
|
||||
grandparentGuid?: string;
|
||||
type: 'movie' | 'show' | 'season' | 'episode';
|
||||
}
|
||||
|
||||
interface PlexLibraryResponse {
|
||||
@@ -20,6 +22,7 @@ export interface PlexLibrary {
|
||||
type: 'show' | 'movie';
|
||||
key: string;
|
||||
title: string;
|
||||
agent: string;
|
||||
}
|
||||
|
||||
interface PlexLibrariesResponse {
|
||||
@@ -61,7 +64,8 @@ class PlexAPI {
|
||||
|
||||
this.plexClient = new NodePlexAPI({
|
||||
hostname: settings.plex.ip,
|
||||
post: settings.plex.port,
|
||||
port: settings.plex.port,
|
||||
https: settings.plex.useSsl,
|
||||
token: plexToken,
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
@@ -119,9 +123,9 @@ class PlexAPI {
|
||||
return response.MediaContainer.Metadata[0];
|
||||
}
|
||||
|
||||
public async getRecentlyAdded(): Promise<PlexLibraryItem[]> {
|
||||
public async getRecentlyAdded(id: string): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>(
|
||||
'/library/recentlyAdded'
|
||||
`/library/sections/${id}/recentlyAdded`
|
||||
);
|
||||
|
||||
return response.MediaContainer.Metadata;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Axios, { AxiosInstance } from 'axios';
|
||||
import logger from '../logger';
|
||||
|
||||
interface RadarrMovieOptions {
|
||||
title: string;
|
||||
@@ -75,11 +76,9 @@ class RadarrAPI {
|
||||
}
|
||||
};
|
||||
|
||||
public addMovie = async (
|
||||
options: RadarrMovieOptions
|
||||
): Promise<RadarrMovie> => {
|
||||
public addMovie = async (options: RadarrMovieOptions): Promise<void> => {
|
||||
try {
|
||||
const response = await this.axios.post<RadarrMovie>(`/movie`, {
|
||||
await this.axios.post<RadarrMovie>(`/movie`, {
|
||||
title: options.title,
|
||||
qualityProfileId: options.qualityProfileId,
|
||||
profileId: options.profileId,
|
||||
@@ -93,10 +92,15 @@ class RadarrAPI {
|
||||
searchForMovie: options.searchNow,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to add movie: ${e.message}`);
|
||||
logger.error(
|
||||
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
||||
{
|
||||
label: 'Radarr',
|
||||
errorMessage: e.message,
|
||||
options,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ interface AddSeriesOptions {
|
||||
title: string;
|
||||
profileId: number;
|
||||
seasons: number[];
|
||||
seasonFolder: boolean;
|
||||
rootFolderPath: string;
|
||||
monitored?: boolean;
|
||||
searchNow?: boolean;
|
||||
@@ -149,6 +150,7 @@ class SonarrAPI {
|
||||
monitored: false,
|
||||
}))
|
||||
),
|
||||
seasonFolder: options.seasonFolder,
|
||||
monitored: options.monitored,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
addOptions: {
|
||||
|
||||
@@ -8,14 +8,11 @@ import {
|
||||
UpdateDateColumn,
|
||||
getRepository,
|
||||
In,
|
||||
AfterUpdate,
|
||||
} from 'typeorm';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import Season from './Season';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
|
||||
@Entity()
|
||||
class Media {
|
||||
@@ -98,32 +95,6 @@ class Media {
|
||||
constructor(init?: Partial<Media>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
private async _notifyAvailable() {
|
||||
if (this.status === MediaStatus.AVAILABLE) {
|
||||
if (this.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: { media: this },
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const movie = await tmdb.getMovie({ movieId: this.tmdbId });
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
notifyUser: request.requestedBy,
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Media;
|
||||
|
||||
@@ -234,13 +234,24 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && !radarr.is4k
|
||||
);
|
||||
|
||||
if (!radarrSettings) {
|
||||
logger.info(
|
||||
'There is no default radarr configured. Did you set any of your Radarr servers as default?',
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const radarrSettings = settings.radarr[0];
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
radarrSettings.hostname
|
||||
}:${radarrSettings.port}/api`,
|
||||
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||
|
||||
@@ -283,6 +294,18 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
const sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && !sonarr.is4k
|
||||
);
|
||||
|
||||
if (!sonarrSettings) {
|
||||
logger.info(
|
||||
'There is no default sonarr configured. Did you set any of your Sonarr servers as default?',
|
||||
{ label: 'Media Request' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: ['requests'],
|
||||
@@ -293,12 +316,11 @@ export class MediaRequest {
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const sonarrSettings = settings.sonarr[0];
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}/api`,
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
|
||||
@@ -313,6 +335,7 @@ export class MediaRequest {
|
||||
title: series.name,
|
||||
tvdbid: series.external_ids.tvdb_id,
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||
monitored: true,
|
||||
searchNow: true,
|
||||
});
|
||||
|
||||
@@ -5,15 +5,9 @@ import {
|
||||
ManyToOne,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
AfterInsert,
|
||||
AfterUpdate,
|
||||
getRepository,
|
||||
} from 'typeorm';
|
||||
import { MediaStatus } from '../constants/media';
|
||||
import Media from './Media';
|
||||
import logger from '../logger';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@Entity()
|
||||
class Season {
|
||||
@@ -38,60 +32,6 @@ class Season {
|
||||
constructor(init?: Partial<Season>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterInsert()
|
||||
@AfterUpdate()
|
||||
private async _sendSeasonAvailableNotification() {
|
||||
if (this.status === MediaStatus.AVAILABLE) {
|
||||
try {
|
||||
const lazyMedia = await this.media;
|
||||
const tmdb = new TheMovieDb();
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { id: lazyMedia.id },
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
const availableSeasons = media.seasons.map(
|
||||
(season) => season.seasonNumber
|
||||
);
|
||||
|
||||
const request = media.requests.find(
|
||||
(request) =>
|
||||
// Check if the season is complete AND it contains the current season that was just marked available
|
||||
request.seasons.every((season) =>
|
||||
availableSeasons.includes(season.seasonNumber)
|
||||
) &&
|
||||
request.seasons.some(
|
||||
(season) => season.seasonNumber === this.seasonNumber
|
||||
)
|
||||
);
|
||||
|
||||
if (request) {
|
||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
subject: tv.name,
|
||||
message: tv.overview,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending season available notice', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Season;
|
||||
|
||||
@@ -17,9 +17,11 @@ import { startJobs } from './job/schedule';
|
||||
import notificationManager from './lib/notifications';
|
||||
import DiscordAgent from './lib/notifications/agents/discord';
|
||||
import EmailAgent from './lib/notifications/agents/email';
|
||||
import { getAppVersion } from './utils/appVersion';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const app = next({ dev });
|
||||
const handle = app.getRequestHandler();
|
||||
@@ -101,7 +103,7 @@ app
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
server.listen(port, () => {
|
||||
logger.info(`Server ready on port ${port}`, {
|
||||
label: 'SERVER',
|
||||
label: 'Server',
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
5
server/interfaces/api/settingsInterfaces.ts
Normal file
5
server/interfaces/api/settingsInterfaces.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface SettingsAboutResponse {
|
||||
version: string;
|
||||
totalRequests: number;
|
||||
totalMediaItems: number;
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import logger from '../../logger';
|
||||
import { getSettings, Library } from '../../lib/settings';
|
||||
import Season from '../../entity/Season';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
const BUNDLE_SIZE = 10;
|
||||
const BUNDLE_SIZE = 20;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
@@ -51,64 +53,81 @@ class JobPlexSync {
|
||||
|
||||
private async processMovie(plexitem: PlexLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const newMedia = new Media();
|
||||
try {
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const newMedia = new Media();
|
||||
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
newMedia.tmdbId = Number(tmdbMatch);
|
||||
if (!metadata.Guid) {
|
||||
logger.debug('No Guid metadata for this title. Skipping', {
|
||||
label: 'Plex Sync',
|
||||
ratingKey: plexitem.ratingKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(newMedia.tmdbId);
|
||||
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${metadata.title}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
} else {
|
||||
const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/);
|
||||
|
||||
if (matchedid?.[1]) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: matchedid[1],
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
newMedia.tmdbId = Number(tmdbMatch);
|
||||
}
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(tmdbMovie.id);
|
||||
const existing = await this.getExisting(newMedia.tmdbId);
|
||||
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${plexitem.title}`);
|
||||
this.log(`Title exists and is already available ${metadata.title}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
await mediaRepository.save(existing);
|
||||
mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
|
||||
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else if (tmdbMovie) {
|
||||
const newMedia = new Media();
|
||||
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
|
||||
newMedia.tmdbId = tmdbMovie.id;
|
||||
} else {
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tmdbMovie.title}`);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
} else {
|
||||
const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/);
|
||||
|
||||
if (matchedid?.[1]) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: matchedid[1],
|
||||
});
|
||||
|
||||
const existing = await this.getExisting(tmdbMovie.id);
|
||||
if (existing && existing.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Title exists and is already available ${plexitem.title}`);
|
||||
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else if (tmdbMovie) {
|
||||
const newMedia = new Media();
|
||||
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
|
||||
newMedia.tmdbId = tmdbMovie.id;
|
||||
newMedia.status = MediaStatus.AVAILABLE;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tmdbMovie.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey
|
||||
}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +138,9 @@ class JobPlexSync {
|
||||
|
||||
try {
|
||||
const metadata = await this.plexClient.getMetadata(
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey,
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey,
|
||||
{ includeChildren: true }
|
||||
);
|
||||
if (metadata.guid.match(tvdbRegex)) {
|
||||
@@ -222,9 +243,15 @@ class JobPlexSync {
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey
|
||||
}`,
|
||||
'error'
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
plexitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,7 +261,11 @@ class JobPlexSync {
|
||||
slicedItems.map(async (plexitem) => {
|
||||
if (plexitem.type === 'movie') {
|
||||
await this.processMovie(plexitem);
|
||||
} else if (plexitem.type === 'show') {
|
||||
} else if (
|
||||
plexitem.type === 'show' ||
|
||||
plexitem.type === 'episode' ||
|
||||
plexitem.type === 'season'
|
||||
) {
|
||||
await this.processShow(plexitem);
|
||||
}
|
||||
})
|
||||
@@ -260,16 +291,17 @@ class JobPlexSync {
|
||||
end: end + BUNDLE_SIZE,
|
||||
});
|
||||
resolve();
|
||||
}, 5000)
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' = 'debug'
|
||||
level: 'info' | 'error' | 'debug' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Plex Sync' });
|
||||
logger[level](message, { label: 'Plex Sync', ...optional });
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
@@ -283,20 +315,40 @@ class JobPlexSync {
|
||||
});
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
if (this.isRecentOnly) {
|
||||
this.currentLibrary = {
|
||||
id: '0',
|
||||
name: 'Recently Added',
|
||||
enabled: true,
|
||||
};
|
||||
this.log(`Beginning to process recently added`, 'info');
|
||||
this.items = await this.plexClient.getRecentlyAdded();
|
||||
await this.loop();
|
||||
} else {
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
if (this.isRecentOnly) {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'info'
|
||||
);
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
||||
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
||||
return (
|
||||
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
|
||||
return mediaA.parentRatingKey === mediaB.parentRatingKey;
|
||||
}
|
||||
|
||||
return mediaA.ratingKey === mediaB.ratingKey;
|
||||
});
|
||||
|
||||
await this.loop();
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const startJobs = (): void => {
|
||||
// Run recently added plex sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Recently Added Sync',
|
||||
job: schedule.scheduleJob('0 */10 * * * *', () => {
|
||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
@@ -23,7 +23,7 @@ export const startJobs = (): void => {
|
||||
// Run full plex sync every 6 hours
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Full Library Sync',
|
||||
job: schedule.scheduleJob('0 0 */6 * * *', () => {
|
||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
|
||||
jobPlexFullSync.run();
|
||||
}),
|
||||
|
||||
@@ -29,10 +29,13 @@ class EmailAgent implements NotificationAgent {
|
||||
host: emailSettings.smtpHost,
|
||||
port: emailSettings.smtpPort,
|
||||
secure: emailSettings.secure,
|
||||
auth: {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
},
|
||||
auth:
|
||||
emailSettings.authUser && emailSettings.authPass
|
||||
? {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +138,7 @@ class EmailAgent implements NotificationAgent {
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your requsested media is now available!',
|
||||
body: 'Your requested media is now available!',
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PlexSettings {
|
||||
machineId?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
useSsl?: boolean;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
@@ -109,6 +110,7 @@ class Settings {
|
||||
name: '',
|
||||
ip: '127.0.0.1',
|
||||
port: 32400,
|
||||
useSsl: false,
|
||||
libraries: [],
|
||||
},
|
||||
radarr: [],
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../entity/User';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
|
||||
export const checkUser: Middleware = async (req, _res, next) => {
|
||||
if (req.session?.userId) {
|
||||
const settings = getSettings();
|
||||
if (req.header('X-API-Key') === settings.main.apiKey) {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
let userId = 1; // Work on original administrator account
|
||||
|
||||
// If a User ID is provided, we will act on that users behalf
|
||||
if (req.header('X-API-User')) {
|
||||
userId = Number(req.header('X-API-User'));
|
||||
}
|
||||
const user = await userRepository.findOne({ where: { id: userId } });
|
||||
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
} else if (req.session?.userId) {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const user = await userRepository.findOne({
|
||||
|
||||
20
server/migration/1607928251245-DropImdbIdConstraint.ts
Normal file
20
server/migration/1607928251245-DropImdbIdConstraint.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm';
|
||||
|
||||
export class DropImdbIdConstraint1607928251245 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropUniqueConstraint(
|
||||
'media',
|
||||
'UQ_7ff2d11f6a83cb52386eaebe74b'
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createUniqueConstraint(
|
||||
'media',
|
||||
new TableUnique({
|
||||
name: 'UQ_7ff2d11f6a83cb52386eaebe74b',
|
||||
columnNames: ['imdbId'],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export interface TvDetails {
|
||||
profilePath?: string;
|
||||
}[];
|
||||
episodeRunTime: number[];
|
||||
firstAirDate: string;
|
||||
firstAirDate?: string;
|
||||
genres: Genre[];
|
||||
homepage: string;
|
||||
inProduction: boolean;
|
||||
|
||||
@@ -24,7 +24,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
return res.status(200).json(user.filter());
|
||||
});
|
||||
|
||||
authRoutes.post('/login', async (req, res) => {
|
||||
authRoutes.post('/login', async (req, res, next) => {
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { authToken?: string };
|
||||
|
||||
@@ -86,6 +86,22 @@ authRoutes.post('/login', async (req, res) => {
|
||||
avatar: account.thumb,
|
||||
});
|
||||
await userRepository.save(user);
|
||||
} else {
|
||||
logger.info(
|
||||
'Failed login attempt from user without access to plex server',
|
||||
{
|
||||
label: 'Auth',
|
||||
account: {
|
||||
...account,
|
||||
authentication_token: '__REDACTED__',
|
||||
authToken: '__REDACTED__',
|
||||
},
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have access to this Plex server',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,9 +113,10 @@ authRoutes.post('/login', async (req, res) => {
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error(e.message, { label: 'Auth' });
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: 'Something went wrong. Is your auth token valid?' });
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong. Is your auth token valid?',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ import { scheduledJobs } from '../job/schedule';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
import { merge } from 'lodash';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import { getAppVersion } from '../utils/appVersion';
|
||||
|
||||
const settingsRoutes = Router();
|
||||
|
||||
@@ -91,17 +94,22 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
||||
|
||||
const libraries = await plexapi.getLibraries();
|
||||
|
||||
const newLibraries: Library[] = libraries.map((library) => {
|
||||
const existing = settings.plex.libraries.find(
|
||||
(l) => l.id === library.key
|
||||
);
|
||||
const newLibraries: Library[] = libraries
|
||||
// Remove libraries that are not movie or show
|
||||
.filter((library) => library.type === 'movie' || library.type === 'show')
|
||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||
.map((library) => {
|
||||
const existing = settings.plex.libraries.find(
|
||||
(l) => l.id === library.key && l.name === library.title
|
||||
);
|
||||
|
||||
return {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
settings.plex.libraries = newLibraries;
|
||||
}
|
||||
@@ -431,4 +439,18 @@ settingsRoutes.post('/notifications/email', (req, res) => {
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/about', async (req, res) => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const mediaRequestRepository = getRepository(MediaRequest);
|
||||
|
||||
const totalMediaItems = await mediaRepository.count();
|
||||
const totalRequests = await mediaRequestRepository.count();
|
||||
|
||||
return res.status(200).json({
|
||||
version: getAppVersion(),
|
||||
totalMediaItems,
|
||||
totalRequests,
|
||||
});
|
||||
});
|
||||
|
||||
export default settingsRoutes;
|
||||
|
||||
112
server/subscriber/MediaSubscriber.ts
Normal file
112
server/subscriber/MediaSubscriber.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
getRepository,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaRequest } from '../entity/MediaRequest';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
private async notifyAvailableMovie(entity: Media) {
|
||||
if (entity.status === MediaStatus.AVAILABLE) {
|
||||
if (entity.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: { media: entity },
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
notifyUser: request.requestedBy,
|
||||
subject: movie.title,
|
||||
message: movie.overview,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(entity: Media, dbEntity: Media) {
|
||||
const newAvailableSeasons = entity.seasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.map((season) => season.seasonNumber);
|
||||
const oldAvailableSeasons = dbEntity.seasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
const changedSeasons = newAvailableSeasons.filter(
|
||||
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
|
||||
);
|
||||
|
||||
if (changedSeasons.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const processedSeasons: number[] = [];
|
||||
|
||||
for (const changedSeasonNumber of changedSeasons) {
|
||||
const requests = await requestRepository.find({
|
||||
where: { media: entity },
|
||||
});
|
||||
const request = requests.find(
|
||||
(request) =>
|
||||
// Check if the season is complete AND it contains the current season that was just marked available
|
||||
request.seasons.every((season) =>
|
||||
newAvailableSeasons.includes(season.seasonNumber)
|
||||
) &&
|
||||
request.seasons.some(
|
||||
(season) => season.seasonNumber === changedSeasonNumber
|
||||
)
|
||||
);
|
||||
|
||||
if (request && !processedSeasons.includes(changedSeasonNumber)) {
|
||||
processedSeasons.push(
|
||||
...request.seasons.map((season) => season.seasonNumber)
|
||||
);
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
subject: tv.name,
|
||||
message: tv.overview,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public beforeUpdate(event: UpdateEvent<Media>): void {
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(event.entity);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.TV &&
|
||||
(event.entity.status === MediaStatus.AVAILABLE ||
|
||||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(event.entity, event.databaseEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
server/types/plex-api.d.ts
vendored
3
server/types/plex-api.d.ts
vendored
@@ -2,8 +2,9 @@ declare module 'plex-api' {
|
||||
export default class PlexAPI {
|
||||
constructor(intiialOptions: {
|
||||
hostname: string;
|
||||
post: number;
|
||||
port: number;
|
||||
token?: string;
|
||||
https?: boolean;
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
_plexApi: PlexAPI,
|
||||
|
||||
12
server/utils/appVersion.ts
Normal file
12
server/utils/appVersion.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const getAppVersion = (): string => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
let finalVersion = version;
|
||||
|
||||
if (version === '0.1.0') {
|
||||
finalVersion = `develop-${process.env.COMMIT_TAG ?? 'local'}`;
|
||||
}
|
||||
|
||||
return finalVersion;
|
||||
};
|
||||
40
src/components/Common/List/index.tsx
Normal file
40
src/components/Common/List/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { withProperties } from '../../../utils/typeHelpers';
|
||||
|
||||
interface ListItemProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
|
||||
return (
|
||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt className="text-sm font-medium text-gray-200">{title}</dt>
|
||||
<dd className="mt-1 flex text-sm text-gray-400 sm:mt-0 sm:col-span-2">
|
||||
<span className="flex-grow">{children}</span>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ListProps {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
}
|
||||
|
||||
const List: React.FC<ListProps> = ({ title, subTitle, children }) => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-100">{title}</h3>
|
||||
{subTitle && (
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-300">{subTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 border-t border-gray-800">
|
||||
<dl className="divide-y divide-gray-800">{children}</dl>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default withProperties(List, { Item: ListItem });
|
||||
@@ -63,126 +63,122 @@ const Modal: React.FC<ModalProps> = ({
|
||||
});
|
||||
useLockBodyScroll(true, disableScrollLock);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ReactDOM.createPortal(
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
return ReactDOM.createPortal(
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 bottom-0 bg-gray-800 bg-opacity-50 w-full h-full z-50 flex justify-center items-center"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
typeof onCancel === 'function' && backgroundClickable
|
||||
? onCancel()
|
||||
: undefined;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={loading}
|
||||
>
|
||||
<div style={{ position: 'absolute' }}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={!loading}
|
||||
>
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 bottom-0 bg-gray-800 bg-opacity-50 w-full h-full z-50 flex justify-center items-center"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
typeof onCancel === 'function' && backgroundClickable
|
||||
? onCancel()
|
||||
: undefined;
|
||||
}
|
||||
}}
|
||||
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full sm:p-6 max-h-full"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
ref={modalRef}
|
||||
>
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={loading}
|
||||
>
|
||||
<div style={{ position: 'absolute' }}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={!loading}
|
||||
>
|
||||
<div
|
||||
className="inline-block align-bottom bg-gray-700 sm:rounded-lg px-4 pt-5 pb-4 text-left overflow-auto shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl w-full sm:p-6 max-h-full"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className="sm:flex sm:items-center">
|
||||
{iconSvg && (
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-gray-600 text-white sm:mx-0 sm:h-10 sm:w-10">
|
||||
{iconSvg}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mt-3 text-center sm:mt-0 sm:text-left ${
|
||||
iconSvg ? 'sm:ml-4' : 'mb-6'
|
||||
}`}
|
||||
>
|
||||
{title && (
|
||||
<h3
|
||||
className="text-lg leading-6 font-medium text-white"
|
||||
id="modal-headline"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:flex sm:items-center">
|
||||
{iconSvg && (
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-gray-600 text-white sm:mx-0 sm:h-10 sm:w-10">
|
||||
{iconSvg}
|
||||
</div>
|
||||
{children && (
|
||||
<div className="mt-4 text-sm leading-5 text-gray-300">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{(onCancel || onOk || onSecondary || onTertiary) && (
|
||||
<div className="mt-5 sm:mt-4 flex justify-center sm:justify-start flex-row-reverse">
|
||||
{typeof onOk === 'function' && (
|
||||
<Button
|
||||
buttonType={okButtonType}
|
||||
onClick={onOk}
|
||||
className="ml-3"
|
||||
disabled={okDisabled}
|
||||
>
|
||||
{okText ? okText : 'Ok'}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onSecondary === 'function' && secondaryText && (
|
||||
<Button
|
||||
buttonType={secondaryButtonType}
|
||||
onClick={onSecondary}
|
||||
className="ml-3"
|
||||
disabled={secondaryDisabled}
|
||||
>
|
||||
{secondaryText}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onTertiary === 'function' && tertiaryText && (
|
||||
<Button
|
||||
buttonType={tertiaryButtonType}
|
||||
onClick={onTertiary}
|
||||
className="ml-3"
|
||||
disabled={tertiaryDisabled}
|
||||
>
|
||||
{tertiaryText}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onCancel === 'function' && (
|
||||
<Button
|
||||
buttonType={cancelButtonType}
|
||||
onClick={onCancel}
|
||||
className="ml-3 sm:ml-0 sm:px-4"
|
||||
>
|
||||
{cancelText
|
||||
? cancelText
|
||||
: intl.formatMessage(globalMessages.cancel)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mt-3 text-center sm:mt-0 sm:text-left ${
|
||||
iconSvg ? 'sm:ml-4' : 'mb-6'
|
||||
}`}
|
||||
>
|
||||
{title && (
|
||||
<h3
|
||||
className="text-lg leading-6 font-medium text-white"
|
||||
id="modal-headline"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="mt-4 text-sm leading-5 text-gray-300">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{(onCancel || onOk || onSecondary || onTertiary) && (
|
||||
<div className="mt-5 sm:mt-4 flex justify-center sm:justify-start flex-row-reverse">
|
||||
{typeof onOk === 'function' && (
|
||||
<Button
|
||||
buttonType={okButtonType}
|
||||
onClick={onOk}
|
||||
className="ml-3"
|
||||
disabled={okDisabled}
|
||||
>
|
||||
{okText ? okText : 'Ok'}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onSecondary === 'function' && secondaryText && (
|
||||
<Button
|
||||
buttonType={secondaryButtonType}
|
||||
onClick={onSecondary}
|
||||
className="ml-3"
|
||||
disabled={secondaryDisabled}
|
||||
>
|
||||
{secondaryText}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onTertiary === 'function' && tertiaryText && (
|
||||
<Button
|
||||
buttonType={tertiaryButtonType}
|
||||
onClick={onTertiary}
|
||||
className="ml-3"
|
||||
disabled={tertiaryDisabled}
|
||||
>
|
||||
{tertiaryText}
|
||||
</Button>
|
||||
)}
|
||||
{typeof onCancel === 'function' && (
|
||||
<Button
|
||||
buttonType={cancelButtonType}
|
||||
onClick={onCancel}
|
||||
className="ml-3 sm:ml-0 sm:px-4"
|
||||
>
|
||||
{cancelText
|
||||
? cancelText
|
||||
: intl.formatMessage(globalMessages.cancel)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ const availableLanguages: AvailableLanguageObject = {
|
||||
code: 'nl',
|
||||
display: 'Nederlands',
|
||||
},
|
||||
es: {
|
||||
code: 'es',
|
||||
display: 'Spanish',
|
||||
},
|
||||
};
|
||||
|
||||
const LanguagePicker: React.FC = () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useRef } from 'react';
|
||||
import Transition from '../../Transition';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { useUser, Permission } from '../../../hooks/useUser';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
|
||||
const messages = defineMessages({
|
||||
dashboard: 'Discover',
|
||||
@@ -116,8 +117,10 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
];
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
const navRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const { hasPermission } = useUser();
|
||||
useClickOutside(navRef, () => setClosed());
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden">
|
||||
@@ -166,7 +169,10 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
|
||||
<div
|
||||
ref={navRef}
|
||||
className="flex-1 h-0 pt-5 pb-4 overflow-y-auto"
|
||||
>
|
||||
<div className="flex-shrink-0 flex items-center px-4">
|
||||
<span className="text-xl text-gray-50">
|
||||
<Link href="/">
|
||||
|
||||
@@ -6,6 +6,7 @@ import Notifications from './Notifications';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
|
||||
const messages = defineMessages({
|
||||
alphawarning:
|
||||
@@ -14,6 +15,7 @@ const messages = defineMessages({
|
||||
|
||||
const Layout: React.FC = ({ children }) => {
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { hasPermission } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
@@ -57,7 +59,7 @@ const Layout: React.FC = ({ children }) => {
|
||||
>
|
||||
<div className="pt-2 pb-6 md:py-6">
|
||||
<div className="max-w-8xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{router.pathname === '/' && (
|
||||
{router.pathname === '/' && hasPermission(Permission.ADMIN) && (
|
||||
<div className="rounded-md bg-indigo-700 p-4 mt-2">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -85,7 +87,7 @@ const Layout: React.FC = ({ children }) => {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Github →
|
||||
GitHub →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,15 @@ import axios from 'axios';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import Transition from '../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
signinplex: 'Sign in to continue',
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [error, setError] = useState('');
|
||||
const [isProcessing, setProcessing] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const { user, revalidate } = useUser();
|
||||
const router = useRouter();
|
||||
@@ -20,10 +23,17 @@ const Login: React.FC = () => {
|
||||
// ask swr to revalidate the user which _shouid_ come back with a valid user.
|
||||
useEffect(() => {
|
||||
const login = async () => {
|
||||
const response = await axios.post('/api/v1/auth/login', { authToken });
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await axios.post('/api/v1/auth/login', { authToken });
|
||||
|
||||
if (response.data?.email) {
|
||||
revalidate();
|
||||
if (response.data?.email) {
|
||||
revalidate();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.response.data.message);
|
||||
setAuthToken(undefined);
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
if (authToken) {
|
||||
@@ -64,7 +74,40 @@ const Login: React.FC = () => {
|
||||
className="bg-gray-800 bg-opacity-50 py-8 px-4 shadow sm:rounded-lg sm:px-10"
|
||||
style={{ backdropFilter: 'blur(5px)' }}
|
||||
>
|
||||
<Transition
|
||||
show={!!error}
|
||||
enter="opacity-0 transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="rounded-md bg-red-600 p-4 mb-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-red-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-300">{error}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<PlexLoginButton
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -193,14 +193,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
)}
|
||||
</SlideOver>
|
||||
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
|
||||
<div className="mr-4 flex-shrink-0">
|
||||
<div className="md:mr-4 flex-shrink-0">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
alt=""
|
||||
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<div className="text-white flex flex-col md:mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<div className="mb-2">
|
||||
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
@@ -352,7 +352,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -12,23 +12,23 @@ const plexOAuth = new PlexOAuth();
|
||||
|
||||
interface PlexLoginButtonProps {
|
||||
onAuthToken: (authToken: string) => void;
|
||||
isProcessing?: boolean;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
const PlexLoginButton: React.FC<PlexLoginButtonProps> = ({
|
||||
onAuthToken,
|
||||
onError,
|
||||
isProcessing,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const getPlexLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const authToken = await plexOAuth.login();
|
||||
setLoading(false);
|
||||
setIsProcessing(true);
|
||||
onAuthToken(authToken);
|
||||
} catch (e) {
|
||||
if (onError) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchresults: 'Search Results',
|
||||
@@ -66,25 +65,7 @@ const Search: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:flex md:items-center md:justify-between mb-6 sm:mb-0 mt-6">
|
||||
<Header>{intl.formatMessage(messages.searchresults)}</Header>
|
||||
<div className="mt-4 flex md:mt-0 md:ml-4">
|
||||
<span className="relative z-0 inline-flex shadow-sm rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex items-center px-4 py-2 rounded-l-md border border-indigo-900 bg-indigo-500 hover:bg-indigo-400 text-sm leading-5 font-medium text-gray-100 hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:ring-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
|
||||
>
|
||||
{intl.formatMessage(globalMessages.movies)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-px relative inline-flex items-center px-4 py-2 rounded-r-md border border-indigo-900 bg-indigo-500 text-sm leading-5 font-medium text-gray-100 hover:text-white focus:z-10 focus:outline-none focus:border-blue-300 focus:ring-blue active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
|
||||
>
|
||||
{intl.formatMessage(globalMessages.tvshows)}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Header>{intl.formatMessage(messages.searchresults)}</Header>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Button from '../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
@@ -14,10 +15,13 @@ const messages = defineMessages({
|
||||
webhookUrl: 'Webhook URL',
|
||||
validationWebhookUrlRequired: 'You must provide a webhook URL',
|
||||
webhookUrlPlaceholder: 'Server Settings -> Integrations -> Webhooks',
|
||||
discordsettingssaved: 'Discord notification settings saved!',
|
||||
discordsettingsfailed: 'Discord notification settings failed to save.',
|
||||
});
|
||||
|
||||
const NotificationsDiscord: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/discord'
|
||||
);
|
||||
@@ -49,8 +53,15 @@ const NotificationsDiscord: React.FC = () => {
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.discordsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// TODO show error
|
||||
addToast(intl.formatMessage(messages.discordsettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Button from '../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
@@ -20,10 +21,13 @@ const messages = defineMessages({
|
||||
enableSsl: 'Enable SSL',
|
||||
authUser: 'Auth User',
|
||||
authPass: 'Auth Pass',
|
||||
emailsettingssaved: 'Email notification settings saved!',
|
||||
emailsettingsfailed: 'Email notification settings failed to save.',
|
||||
});
|
||||
|
||||
const NotificationsEmail: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/email'
|
||||
);
|
||||
@@ -65,14 +69,21 @@ const NotificationsEmail: React.FC = () => {
|
||||
options: {
|
||||
emailFrom: values.emailFrom,
|
||||
smtpHost: values.smtpHost,
|
||||
smtpPort: values.smtpPort,
|
||||
smtpPort: Number(values.smtpPort),
|
||||
secure: values.secure,
|
||||
authUser: values.authUser,
|
||||
authPass: values.authPass,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.emailsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// TODO show error
|
||||
addToast(intl.formatMessage(messages.emailsettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
const messages = defineMessages({
|
||||
createradarr: 'Create New Radarr Server',
|
||||
editradarr: 'Edit Radarr Server',
|
||||
validationNameRequired: 'You must provide a server name',
|
||||
validationHostnameRequired: 'You must provide a hostname/IP',
|
||||
validationPortRequired: 'You must provide a port',
|
||||
validationApiKeyRequired: 'You must provide an API key',
|
||||
validationRootFolderRequired: 'You must select a root folder',
|
||||
validationProfileRequired: 'You must select a profile',
|
||||
validationMinimumAvailabilityRequired: 'You must select minimum availability',
|
||||
toastRadarrTestSuccess: 'Radarr connection established!',
|
||||
toastRadarrTestFailure: 'Failed to connect to Radarr Server',
|
||||
saving: 'Saving...',
|
||||
@@ -40,6 +42,10 @@ const messages = defineMessages({
|
||||
selectQualityProfile: 'Select a Quality Profile',
|
||||
selectRootFolder: 'Select a Root Folder',
|
||||
selectMinimumAvailability: 'Select minimum availability',
|
||||
loadingprofiles: 'Loading quality profiles…',
|
||||
testFirstQualityProfiles: 'Test your connection to load quality profiles',
|
||||
loadingrootfolders: 'Loading root folders…',
|
||||
testFirstRootFolders: 'Test your connection to load root folders',
|
||||
});
|
||||
|
||||
interface TestResponse {
|
||||
@@ -74,6 +80,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
rootFolders: [],
|
||||
});
|
||||
const RadarrSettingsSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.validationNameRequired)
|
||||
),
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
@@ -81,10 +90,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
apiKey: Yup.string().required(intl.formatMessage(messages.apiKey)),
|
||||
rootFolder: Yup.string().required(intl.formatMessage(messages.rootfolder)),
|
||||
rootFolder: Yup.string().required(
|
||||
intl.formatMessage(messages.validationRootFolderRequired)
|
||||
),
|
||||
activeProfileId: Yup.string().required(
|
||||
intl.formatMessage(messages.validationProfileRequired)
|
||||
),
|
||||
minimumAvailability: Yup.string().required(
|
||||
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
||||
),
|
||||
});
|
||||
|
||||
const testConnection = useCallback(
|
||||
@@ -108,7 +122,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
{
|
||||
hostname,
|
||||
apiKey,
|
||||
port,
|
||||
port: Number(port),
|
||||
baseUrl,
|
||||
useSsl,
|
||||
}
|
||||
@@ -171,7 +185,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
baseUrl: radarr?.baseUrl,
|
||||
activeProfileId: radarr?.activeProfileId,
|
||||
rootFolder: radarr?.activeDirectory,
|
||||
minimumAvailability: radarr?.minimumAvailability,
|
||||
minimumAvailability: radarr?.minimumAvailability ?? 'released',
|
||||
isDefault: radarr?.isDefault ?? false,
|
||||
is4k: radarr?.is4k ?? false,
|
||||
}}
|
||||
@@ -185,11 +199,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
const submission = {
|
||||
name: values.name,
|
||||
hostname: values.hostname,
|
||||
port: values.port,
|
||||
port: Number(values.port),
|
||||
apiKey: values.apiKey,
|
||||
useSsl: values.ssl,
|
||||
baseUrl: values.baseUrl,
|
||||
activeProfileId: values.activeProfileId,
|
||||
activeProfileId: Number(values.activeProfileId),
|
||||
activeProfileName: profileName,
|
||||
activeDirectory: values.rootFolder,
|
||||
is4k: values.is4k,
|
||||
@@ -218,6 +232,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -250,7 +265,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
secondaryDisabled={
|
||||
!values.apiKey || !values.hostname || !values.port || isTesting
|
||||
}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||
onOk={() => handleSubmit()}
|
||||
title={
|
||||
!radarr
|
||||
@@ -312,6 +327,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-600 text-gray-100 sm:text-sm cursor-default">
|
||||
{values.ssl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
@@ -321,7 +339,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('hostname', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
@@ -442,10 +460,17 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
name="activeProfileId"
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectQualityProfile)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
@@ -478,10 +503,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
name="rootFolder"
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectRootFolder)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(messages.testFirstRootFolders)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
@@ -516,17 +546,18 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
name="minimumAvailability"
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(
|
||||
messages.selectMinimumAvailability
|
||||
)}
|
||||
</option>
|
||||
<option value="announced">Announced</option>
|
||||
<option value="inCinemas">In Cinemas</option>
|
||||
<option value="released">Released</option>
|
||||
<option value="preDB">PreDB</option>
|
||||
</Field>
|
||||
</div>
|
||||
{errors.minimumAvailability &&
|
||||
touched.minimumAvailability && (
|
||||
<div className="text-red-500 mt-2">
|
||||
{errors.minimumAvailability}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
|
||||
|
||||
80
src/components/Settings/SettingsAbout/index.tsx
Normal file
80
src/components/Settings/SettingsAbout/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import Error from '../../../pages/_error';
|
||||
import List from '../../Common/List';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import { SettingsAboutResponse } from '../../../../server/interfaces/api/settingsInterfaces';
|
||||
import { defineMessages, FormattedNumber, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
overseerrinformation: 'Overseerr Information',
|
||||
version: 'Version',
|
||||
totalmedia: 'Total Media',
|
||||
totalrequests: 'Total Requests',
|
||||
gettingsupport: 'Getting Support',
|
||||
githubdiscussions: 'GitHub Discussions',
|
||||
clickheretojoindiscord: 'Click here to join our Discord server.',
|
||||
});
|
||||
|
||||
const SettingsAbout: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<SettingsAboutResponse>(
|
||||
'/api/v1/settings/about'
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<List title={intl.formatMessage(messages.overseerrinformation)}>
|
||||
<List.Item title={intl.formatMessage(messages.version)}>
|
||||
{data.version}
|
||||
</List.Item>
|
||||
<List.Item title={intl.formatMessage(messages.totalmedia)}>
|
||||
<FormattedNumber value={data.totalMediaItems} />
|
||||
</List.Item>
|
||||
<List.Item title={intl.formatMessage(messages.totalrequests)}>
|
||||
<FormattedNumber value={data.totalRequests} />
|
||||
</List.Item>
|
||||
</List>
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<List title={intl.formatMessage(messages.gettingsupport)}>
|
||||
<List.Item title={intl.formatMessage(messages.githubdiscussions)}>
|
||||
<a
|
||||
href="https://github.com/sct/overseerr/discussions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-500 hover:underline"
|
||||
>
|
||||
https://github.com/sct/overseerr/discussions
|
||||
</a>
|
||||
</List.Item>
|
||||
<List.Item title="Discord">
|
||||
<a
|
||||
href="https://discord.gg/PkCWJSeCk7"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-500 hover:underline"
|
||||
>
|
||||
{intl.formatMessage(messages.clickheretojoindiscord)}
|
||||
</a>
|
||||
</List.Item>
|
||||
</List>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAbout;
|
||||
@@ -46,7 +46,7 @@ const SettingsJobs: React.FC = () => {
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD alignText="right">
|
||||
<Button buttonType="primary">
|
||||
<Button buttonType="primary" disabled>
|
||||
{intl.formatMessage(messages.runnow)}
|
||||
</Button>
|
||||
</Table.TD>
|
||||
|
||||
17
src/components/Settings/SettingsLogs/index.tsx
Normal file
17
src/components/Settings/SettingsLogs/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
// We will localize this file when the complete version is released.
|
||||
|
||||
const SettingsLogs: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="leading-loose text-gray-300 text-sm">
|
||||
Logs page is still being built. For now, you can access your logs
|
||||
directly in <code>stdout</code> (container logs) or looking in{' '}
|
||||
<code>/app/config/logs/overseerr.logs</code>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsLogs;
|
||||
@@ -2,21 +2,23 @@ import React, { useState } from 'react';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import type { PlexSettings } from '../../../server/lib/settings';
|
||||
import useSWR from 'swr';
|
||||
import { useFormik } from 'formik';
|
||||
import { Formik, Field } from 'formik';
|
||||
import Button from '../Common/Button';
|
||||
import axios from 'axios';
|
||||
import LibraryItem from './LibraryItem';
|
||||
import Badge from '../Common/Badge';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages({
|
||||
plexsettings: 'Plex Settings',
|
||||
plexsettingsDescription:
|
||||
'Configure the settings for your Plex server. Overseerr uses your Plex server to scan your library at an interval and see what content is available.',
|
||||
servername: 'Server Name (Automatically Set)',
|
||||
servername: 'Server Name (Automatically set after you save)',
|
||||
servernamePlaceholder: 'Plex Server Name',
|
||||
hostname: 'Hostname/IP',
|
||||
port: 'Port',
|
||||
ssl: 'SSL',
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving...',
|
||||
plexlibraries: 'Plex Libraries',
|
||||
@@ -32,6 +34,8 @@ const messages = defineMessages({
|
||||
librariesRemaining: 'Libraries Remaining: {count}',
|
||||
startscan: 'Start Scan',
|
||||
cancelscan: 'Cancel Scan',
|
||||
validationHostnameRequired: 'You must provide a hostname/IP',
|
||||
validationPortRequired: 'You must provide a port',
|
||||
});
|
||||
|
||||
interface Library {
|
||||
@@ -64,33 +68,15 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
}
|
||||
);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
hostname: data?.ip,
|
||||
port: data?.port,
|
||||
},
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values) => {
|
||||
setSubmitError(null);
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await axios.post('/api/v1/settings/plex', {
|
||||
ip: values.hostname,
|
||||
port: values.port,
|
||||
} as PlexSettings);
|
||||
|
||||
revalidate();
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (e) {
|
||||
setSubmitError(e.response.data.message);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
const PlexSettingsSchema = Yup.object().shape({
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
port: Yup.number().required(
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
});
|
||||
|
||||
const activeLibraries =
|
||||
@@ -164,91 +150,157 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
<FormattedMessage {...messages.plexsettingsDescription} />
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{submitError && (
|
||||
<div className="bg-red-700 text-white p-4 rounded-md mb-6">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.servername} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.servernamePlaceholder
|
||||
)}
|
||||
value={data?.name}
|
||||
readOnly
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
<Formik
|
||||
initialValues={{
|
||||
hostname: data?.ip,
|
||||
port: data?.port,
|
||||
useSsl: data?.useSsl,
|
||||
}}
|
||||
enableReinitialize
|
||||
validationSchema={PlexSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await axios.post('/api/v1/settings/plex', {
|
||||
ip: values.hostname,
|
||||
port: Number(values.port),
|
||||
useSsl: values.useSsl,
|
||||
} as PlexSettings);
|
||||
|
||||
revalidate();
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (e) {
|
||||
setSubmitError(e.response.data.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
values,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{submitError && (
|
||||
<div className="bg-red-700 text-white p-4 rounded-md mb-6">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.servername} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.servernamePlaceholder
|
||||
)}
|
||||
value={data?.name}
|
||||
readOnly
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="hostname"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.hostname} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-800 text-gray-100 sm:text-sm cursor-default">
|
||||
{values.useSsl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
type="text"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
placeholder="127.0.0.1"
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
<div className="text-red-500 mt-2">{errors.hostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="port"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.port} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg rounded-md shadow-sm sm:max-w-xs">
|
||||
<Field
|
||||
type="text"
|
||||
id="port"
|
||||
name="port"
|
||||
placeholder="32400"
|
||||
className="form-input block w-24 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{errors.port && touched.port && (
|
||||
<div className="text-red-500 mt-2">{errors.port}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||
<label
|
||||
htmlFor="hostname"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.hostname} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
placeholder="127.0.0.1"
|
||||
value={formik.values.hostname}
|
||||
onChange={formik.handleChange}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="ssl"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
{intl.formatMessage(messages.ssl)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="useSsl"
|
||||
name="useSsl"
|
||||
onChange={() => {
|
||||
setFieldValue('useSsl', !values.useSsl);
|
||||
}}
|
||||
className="form-checkbox h-6 w-6 rounded-md text-indigo-600 transition duration-150 ease-in-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
|
||||
<label
|
||||
htmlFor="port"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||
>
|
||||
<FormattedMessage {...messages.port} />
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg rounded-md shadow-sm sm:max-w-xs">
|
||||
<input
|
||||
type="text"
|
||||
id="port"
|
||||
name="port"
|
||||
placeholder="32400"
|
||||
value={formik.values.port}
|
||||
onChange={formik.handleChange}
|
||||
className="form-input block w-24 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button buttonType="primary" type="submit" disabled={isUpdating}>
|
||||
{isUpdating
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
<div className="mt-10">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-200">
|
||||
<FormattedMessage {...messages.plexlibraries} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import useSWR from 'swr';
|
||||
@@ -31,8 +31,48 @@ const messages = defineMessages({
|
||||
activeProfile: 'Active Profile',
|
||||
addradarr: 'Add Radarr Server',
|
||||
addsonarr: 'Add Sonarr Server',
|
||||
nodefault: 'No default server selected!',
|
||||
nodefaultdescription:
|
||||
'At least one server must be marked as default before any requests will make it to your services.',
|
||||
no4kimplemented: '(Default 4K servers are not currently implemented)',
|
||||
});
|
||||
|
||||
const NoDefaultAlert: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<div className="rounded-md bg-yellow-600 p-4 mb-8">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-yellow-200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-200">
|
||||
{intl.formatMessage(messages.nodefault)}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-300">
|
||||
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
|
||||
<p className="mt-2">
|
||||
{intl.formatMessage(messages.no4kimplemented)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ServerInstanceProps {
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
@@ -249,51 +289,57 @@ const SettingsServices: React.FC = () => {
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{!radarrData && !radarrError && <LoadingSpinner />}
|
||||
{radarrData && !radarrError && (
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{radarrData.map((radarr) => (
|
||||
<ServerInstance
|
||||
key={`radarr-config-${radarr.id}`}
|
||||
name={radarr.name}
|
||||
address={radarr.hostname}
|
||||
profileName={radarr.activeProfileName}
|
||||
isSSL={radarr.useSsl}
|
||||
isDefault={radarr.isDefault && !radarr.is4k}
|
||||
isDefault4K={radarr.is4k && radarr.isDefault}
|
||||
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: radarr.id,
|
||||
type: 'radarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditRadarrModal({ open: true, radarr: null })
|
||||
<>
|
||||
{radarrData.length > 0 &&
|
||||
!radarrData.some(
|
||||
(radarr) => radarr.isDefault && !radarr.is4k
|
||||
) && <NoDefaultAlert />}
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{radarrData.map((radarr) => (
|
||||
<ServerInstance
|
||||
key={`radarr-config-${radarr.id}`}
|
||||
name={radarr.name}
|
||||
address={radarr.hostname}
|
||||
profileName={radarr.activeProfileName}
|
||||
isSSL={radarr.useSsl}
|
||||
isDefault={radarr.isDefault && !radarr.is4k}
|
||||
isDefault4K={radarr.is4k && radarr.isDefault}
|
||||
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: radarr.id,
|
||||
type: 'radarr',
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditRadarrModal({ open: true, radarr: null })
|
||||
}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addradarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addradarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
@@ -307,52 +353,58 @@ const SettingsServices: React.FC = () => {
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{!sonarrData && !sonarrError && <LoadingSpinner />}
|
||||
{sonarrData && !sonarrError && (
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sonarrData.map((sonarr) => (
|
||||
<ServerInstance
|
||||
key={`sonarr-config-${sonarr.id}`}
|
||||
name={sonarr.name}
|
||||
address={sonarr.hostname}
|
||||
profileName={sonarr.activeProfileName}
|
||||
isSSL={sonarr.useSsl}
|
||||
isSonarr
|
||||
isDefault4K={sonarr.isDefault && sonarr.is4k}
|
||||
isDefault={sonarr.isDefault && !sonarr.is4k}
|
||||
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: sonarr.id,
|
||||
type: 'sonarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditSonarrModal({ open: true, sonarr: null })
|
||||
<>
|
||||
{sonarrData.length > 0 &&
|
||||
!sonarrData.some(
|
||||
(sonarr) => sonarr.isDefault && !sonarr.is4k
|
||||
) && <NoDefaultAlert />}
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sonarrData.map((sonarr) => (
|
||||
<ServerInstance
|
||||
key={`sonarr-config-${sonarr.id}`}
|
||||
name={sonarr.name}
|
||||
address={sonarr.hostname}
|
||||
profileName={sonarr.activeProfileName}
|
||||
isSSL={sonarr.useSsl}
|
||||
isSonarr
|
||||
isDefault4K={sonarr.isDefault && sonarr.is4k}
|
||||
isDefault={sonarr.isDefault && !sonarr.is4k}
|
||||
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: sonarr.id,
|
||||
type: 'sonarr',
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditSonarrModal({ open: true, sonarr: null })
|
||||
}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addsonarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addsonarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useIntl, defineMessages } from 'react-intl';
|
||||
const messages = defineMessages({
|
||||
createsonarr: 'Create New Sonarr Server',
|
||||
editsonarr: 'Edit Sonarr Server',
|
||||
validationNameRequired: 'You must provide a server name',
|
||||
validationHostnameRequired: 'You must provide a hostname/IP',
|
||||
validationPortRequired: 'You must provide a port',
|
||||
validationApiKeyRequired: 'You must provide an API key',
|
||||
@@ -39,6 +40,10 @@ const messages = defineMessages({
|
||||
server4k: '4K Server',
|
||||
selectQualityProfile: 'Select a Quality Profile',
|
||||
selectRootFolder: 'Select a Root Folder',
|
||||
loadingprofiles: 'Loading quality profiles…',
|
||||
testFirstQualityProfiles: 'Test your connection to load quality profiles',
|
||||
loadingrootfolders: 'Loading root folders…',
|
||||
testFirstRootFolders: 'Test your connection to load root folders',
|
||||
});
|
||||
|
||||
interface TestResponse {
|
||||
@@ -73,6 +78,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
rootFolders: [],
|
||||
});
|
||||
const SonarrSettingsSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.validationNameRequired)
|
||||
),
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
@@ -111,7 +119,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
{
|
||||
hostname,
|
||||
apiKey,
|
||||
port,
|
||||
port: Number(port),
|
||||
baseUrl,
|
||||
useSsl,
|
||||
}
|
||||
@@ -188,11 +196,11 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
const submission = {
|
||||
name: values.name,
|
||||
hostname: values.hostname,
|
||||
port: values.port,
|
||||
port: Number(values.port),
|
||||
apiKey: values.apiKey,
|
||||
useSsl: values.ssl,
|
||||
baseUrl: values.baseUrl,
|
||||
activeProfileId: values.activeProfileId,
|
||||
activeProfileId: Number(values.activeProfileId),
|
||||
activeProfileName: profileName,
|
||||
activeDirectory: values.rootFolder,
|
||||
is4k: values.is4k,
|
||||
@@ -221,6 +229,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -253,7 +262,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
secondaryDisabled={
|
||||
!values.apiKey || !values.hostname || !values.port || isTesting
|
||||
}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||
onOk={() => handleSubmit()}
|
||||
title={
|
||||
!sonarr
|
||||
@@ -315,6 +324,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-600 text-gray-100 sm:text-sm cursor-default">
|
||||
{values.ssl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
<Field
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
@@ -324,7 +336,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
setFieldValue('hostname', e.target.value);
|
||||
}}
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
@@ -445,10 +457,17 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
name="activeProfileId"
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectQualityProfile)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
@@ -481,10 +500,15 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
name="rootFolder"
|
||||
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
||||
disabled={!isValidated || isTesting}
|
||||
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.selectRootFolder)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(messages.testFirstRootFolders)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
|
||||
@@ -8,6 +8,7 @@ import LoginWithPlex from './LoginWithPlex';
|
||||
import SetupSteps from './SetupSteps';
|
||||
import axios from 'axios';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import Badge from '../Common/Badge';
|
||||
|
||||
const messages = defineMessages({
|
||||
finish: 'Finish Setup',
|
||||
@@ -16,6 +17,9 @@ const messages = defineMessages({
|
||||
loginwithplex: 'Login with Plex',
|
||||
configureplex: 'Configure Plex',
|
||||
configureservices: 'Configure Services',
|
||||
tip: 'Tip',
|
||||
syncingbackground:
|
||||
'Syncing will run in the background. You can continue the setup process in the meantime.',
|
||||
});
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
@@ -85,6 +89,12 @@ const Setup: React.FC = () => {
|
||||
{currentStep === 2 && (
|
||||
<div>
|
||||
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
|
||||
<div className="mt-4 text-gray-500 text-sm">
|
||||
<span className="mr-2">
|
||||
<Badge>{intl.formatMessage(messages.tip)}</Badge>
|
||||
</span>
|
||||
{intl.formatMessage(messages.syncingbackground)}
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
|
||||
@@ -10,17 +10,18 @@ import Link from 'next/link';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import RequestModal from '../RequestModal';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useIsTouch } from '../../hooks/useIsTouch';
|
||||
|
||||
const messages = defineMessages({
|
||||
movie: 'MOVIE',
|
||||
tvshow: 'SERIES',
|
||||
movie: 'Movie',
|
||||
tvshow: 'Series',
|
||||
});
|
||||
|
||||
interface TitleCardProps {
|
||||
id: number;
|
||||
image?: string;
|
||||
summary?: string;
|
||||
year: string;
|
||||
year?: string;
|
||||
title: string;
|
||||
userScore: number;
|
||||
mediaType: MediaType;
|
||||
@@ -38,6 +39,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
mediaType,
|
||||
canExpand = false,
|
||||
}) => {
|
||||
const isTouch = useIsTouch();
|
||||
const intl = useIntl();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [currentStatus, setCurrentStatus] = useState(status);
|
||||
@@ -74,9 +76,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
<div
|
||||
className="titleCard outline-none cursor-default"
|
||||
style={{
|
||||
backgroundImage: `url(//image.tmdb.org/t/p/w600_and_h900_bestv2${image})`,
|
||||
backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!isTouch) {
|
||||
setShowDetail(true);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setShowDetail(true)}
|
||||
onMouseLeave={() => setShowDetail(false)}
|
||||
onClick={() => setShowDetail(true)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -146,158 +152,162 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
|
||||
<Transition
|
||||
show={!image || showDetail || showRequestModal}
|
||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||
enter="transition transform opacity-0"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||
leave="transition transform opacity-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 w-full left-0 right-0">
|
||||
<div className="px-2 text-white">
|
||||
<div className="text-sm">{year}</div>
|
||||
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
|
||||
<a
|
||||
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden cursor-pointer"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 w-full left-0 right-0">
|
||||
<div className="px-2 text-white">
|
||||
{year && <div className="text-sm">{year}</div>}
|
||||
|
||||
<h1 className="text-xl leading-tight whitespace-normal">
|
||||
{title}
|
||||
</h1>
|
||||
<div
|
||||
className="text-xs whitespace-normal"
|
||||
style={{
|
||||
WebkitLineClamp: 3,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
<h1 className="text-xl leading-tight whitespace-normal">
|
||||
{title}
|
||||
</h1>
|
||||
<div
|
||||
className="text-xs whitespace-normal"
|
||||
style={{
|
||||
WebkitLineClamp: 3,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
|
||||
<Link
|
||||
href={
|
||||
mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`
|
||||
}
|
||||
>
|
||||
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
{(!currentStatus ||
|
||||
currentStatus === MediaStatus.UNKNOWN) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowRequestModal(true);
|
||||
}}
|
||||
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{currentStatus === MediaStatus.PENDING && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{currentStatus === MediaStatus.PROCESSING && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{(currentStatus === MediaStatus.AVAILABLE ||
|
||||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
|
||||
<Link
|
||||
href={
|
||||
mediaType === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'
|
||||
}
|
||||
as={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
|
||||
>
|
||||
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
{(!currentStatus ||
|
||||
currentStatus === MediaStatus.UNKNOWN) && (
|
||||
<button
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{currentStatus === MediaStatus.PENDING && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{currentStatus === MediaStatus.PROCESSING && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{(currentStatus === MediaStatus.AVAILABLE ||
|
||||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
|
||||
<button
|
||||
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-4 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,14 +196,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
)}
|
||||
</SlideOver>
|
||||
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
|
||||
<div className="mr-4 flex-shrink-0">
|
||||
<div className="md:mr-4 flex-shrink-0">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
alt=""
|
||||
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<div className="text-white flex flex-col md:mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<div className="mb-2">
|
||||
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
@@ -227,8 +227,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl">
|
||||
{data.name}{' '}
|
||||
<span className="text-2xl">({data.firstAirDate.slice(0, 4)})</span>
|
||||
<span>{data.name}</span>
|
||||
{data.firstAirDate && (
|
||||
<span className="text-2xl ml-2">
|
||||
({data.firstAirDate.slice(0, 4)})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<span className="text-xs md:text-base mt-1 md:mt-0">
|
||||
{data.genres.map((g) => g.name).join(', ')}
|
||||
@@ -344,7 +348,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<svg
|
||||
|
||||
20
src/context/InteractionContext.tsx
Normal file
20
src/context/InteractionContext.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import useInteraction from '../hooks/useInteraction';
|
||||
|
||||
interface InteractionContextProps {
|
||||
isTouch: boolean;
|
||||
}
|
||||
|
||||
export const InteractionContext = React.createContext<InteractionContextProps>({
|
||||
isTouch: false,
|
||||
});
|
||||
|
||||
export const InteractionProvider: React.FC = ({ children }) => {
|
||||
const isTouch = useInteraction();
|
||||
|
||||
return (
|
||||
<InteractionContext.Provider value={{ isTouch }}>
|
||||
{children}
|
||||
</InteractionContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,8 @@ export type AvailableLocales =
|
||||
| 'nb-NO'
|
||||
| 'de'
|
||||
| 'ru'
|
||||
| 'nl';
|
||||
| 'nl'
|
||||
| 'es';
|
||||
|
||||
interface LanguageContextProps {
|
||||
locale: AvailableLocales;
|
||||
|
||||
@@ -15,7 +15,7 @@ const useClickOutside = (
|
||||
): void => {
|
||||
useEffect(() => {
|
||||
const handleBodyClick = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
78
src/hooks/useInteraction.ts
Normal file
78
src/hooks/useInteraction.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const INTERACTION_TYPE = {
|
||||
MOUSE: 'mouse',
|
||||
PEN: 'pen',
|
||||
TOUCH: 'touch',
|
||||
};
|
||||
|
||||
const UPDATE_INTERVAL = 1000; // Throttle updates to the type to prevent flip flopping
|
||||
|
||||
const useInteraction = (): boolean => {
|
||||
const [isTouch, setIsTouch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hasTapEvent = 'ontouchstart' in window;
|
||||
setIsTouch(hasTapEvent);
|
||||
|
||||
let localTouch = hasTapEvent;
|
||||
let lastTouchUpdate = Date.now();
|
||||
|
||||
const shouldUpdate = (): boolean =>
|
||||
lastTouchUpdate + UPDATE_INTERVAL < Date.now();
|
||||
|
||||
const onMouseMove = (): void => {
|
||||
if (localTouch && shouldUpdate()) {
|
||||
setTimeout(() => {
|
||||
if (shouldUpdate()) {
|
||||
setIsTouch(false);
|
||||
localTouch = false;
|
||||
}
|
||||
}, UPDATE_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchStart = (): void => {
|
||||
lastTouchUpdate = Date.now();
|
||||
|
||||
if (!localTouch) {
|
||||
setIsTouch(true);
|
||||
localTouch = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent): void => {
|
||||
const { pointerType } = e;
|
||||
|
||||
switch (pointerType) {
|
||||
case INTERACTION_TYPE.TOUCH:
|
||||
case INTERACTION_TYPE.PEN:
|
||||
return onTouchStart();
|
||||
default:
|
||||
return onMouseMove();
|
||||
}
|
||||
};
|
||||
|
||||
if (hasTapEvent) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchstart', onTouchStart);
|
||||
} else {
|
||||
window.addEventListener('pointerdown', onPointerMove);
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hasTapEvent) {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchstart', onTouchStart);
|
||||
} else {
|
||||
window.removeEventListener('pointerdown', onPointerMove);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isTouch;
|
||||
};
|
||||
|
||||
export default useInteraction;
|
||||
7
src/hooks/useIsTouch.ts
Normal file
7
src/hooks/useIsTouch.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useContext } from 'react';
|
||||
import { InteractionContext } from '../context/InteractionContext';
|
||||
|
||||
export const useIsTouch = (): boolean => {
|
||||
const { isTouch } = useContext(InteractionContext);
|
||||
return isTouch;
|
||||
};
|
||||
@@ -7,6 +7,10 @@ import type { Nullable } from '../utils/typeHelpers';
|
||||
|
||||
type Url = string | UrlObject;
|
||||
|
||||
const encodeURIExtraParams = (string: string): string => {
|
||||
return encodeURIComponent(string).replace(/!/g, '%21');
|
||||
};
|
||||
|
||||
interface SearchObject {
|
||||
searchValue: string;
|
||||
searchOpen: boolean;
|
||||
@@ -35,14 +39,17 @@ const useSearchInput = (): SearchObject => {
|
||||
if (router.pathname.startsWith('/search')) {
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, query: debouncedValue },
|
||||
query: {
|
||||
...router.query,
|
||||
query: encodeURIExtraParams(debouncedValue),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setLastRoute(router.asPath);
|
||||
router
|
||||
.push({
|
||||
pathname: '/search',
|
||||
query: { query: debouncedValue },
|
||||
query: { query: encodeURIExtraParams(debouncedValue) },
|
||||
})
|
||||
.then(() => window.scrollTo(0, 0));
|
||||
}
|
||||
@@ -85,8 +92,12 @@ const useSearchInput = (): SearchObject => {
|
||||
* is on /search
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (router.query.query !== debouncedValue) {
|
||||
setSearchValue((router.query.query as string) ?? '');
|
||||
if (router.query.query !== encodeURIExtraParams(debouncedValue)) {
|
||||
setSearchValue(
|
||||
router.query.query
|
||||
? decodeURIComponent(router.query.query as string)
|
||||
: ''
|
||||
);
|
||||
|
||||
if (!router.pathname.startsWith('/search') && !router.query.query) {
|
||||
setIsOpen(false);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"components.Discover.discovermovies": "Beliebte Filme",
|
||||
"components.Discover.discovertv": "Beliebte Serie",
|
||||
"components.Discover.discovertv": "Beliebte Serien",
|
||||
"components.Discover.nopending": "Keine ausstehenden Anträge",
|
||||
"components.Discover.popularmovies": "Beliebte Filme",
|
||||
"components.Discover.populartv": "Beliebte Serie",
|
||||
"components.Discover.populartv": "Beliebte Serien",
|
||||
"components.Discover.recentlyAdded": "Kürzlich hinzugefügt",
|
||||
"components.Discover.recentrequests": "Letzte Anträge",
|
||||
"components.Discover.trending": "Trends",
|
||||
@@ -58,7 +58,7 @@
|
||||
"components.RequestList.RequestItem.notavailable": "entf.",
|
||||
"components.RequestList.RequestItem.requestedby": "Antrag von {username}",
|
||||
"components.RequestList.RequestItem.seasons": "Staffeln",
|
||||
"components.RequestList.mediaInfo": "Medieninformationen",
|
||||
"components.RequestList.mediaInfo": "Medieninfos",
|
||||
"components.RequestList.modifiedBy": "Zuletzt geändert von",
|
||||
"components.RequestList.next": "Nächste",
|
||||
"components.RequestList.previous": "Vorherige",
|
||||
@@ -88,8 +88,8 @@
|
||||
"components.RequestModal.status": "Status",
|
||||
"components.Search.searchresults": "Suchergebnisse",
|
||||
"components.Settings.Notifications.agentenabled": "Agent aktiviert",
|
||||
"components.Settings.Notifications.authPass": "Auth Pass",
|
||||
"components.Settings.Notifications.authUser": "Auth User",
|
||||
"components.Settings.Notifications.authPass": "Authentifizierungspasswort",
|
||||
"components.Settings.Notifications.authUser": "Authentifizierungsbenutzername",
|
||||
"components.Settings.Notifications.emailsender": "E-Mail-Absenderadresse",
|
||||
"components.Settings.Notifications.enableSsl": "SSL aktivieren",
|
||||
"components.Settings.Notifications.save": "Änderungen speichern",
|
||||
@@ -304,5 +304,9 @@
|
||||
"pages.serviceUnavailable": "{statusCode} – Dienst nicht verfügbar",
|
||||
"pages.somethingWentWrong": "{statusCode} – Es ist ein Fehler aufgetreten",
|
||||
"components.TvDetails.TvCast.fullseriescast": "Vollserienbesetzung",
|
||||
"components.MovieDetails.MovieCast.fullcast": "Vollständige Besetzung"
|
||||
"components.MovieDetails.MovieCast.fullcast": "Vollständige Besetzung",
|
||||
"components.Settings.Notifications.emailsettingssaved": "E-Mail-Benachrichtigungseinstellungen gespeichert!",
|
||||
"components.Settings.Notifications.emailsettingsfailed": "Die Einstellungen für die E-Mail-Benachrichtigungen konnten nicht gespeichert werden.",
|
||||
"components.Settings.Notifications.discordsettingssaved": "Discord-Benachrichtigungseinstellungen gespeichert!",
|
||||
"components.Settings.Notifications.discordsettingsfailed": "Die Einstellungen für die Discord-Benachrichtigungen konnten nicht gespeichert werden."
|
||||
}
|
||||
|
||||
@@ -91,7 +91,11 @@
|
||||
"components.Settings.Notifications.agentenabled": "Agent Enabled",
|
||||
"components.Settings.Notifications.authPass": "Auth Pass",
|
||||
"components.Settings.Notifications.authUser": "Auth User",
|
||||
"components.Settings.Notifications.discordsettingsfailed": "Discord notification settings failed to save.",
|
||||
"components.Settings.Notifications.discordsettingssaved": "Discord notification settings saved!",
|
||||
"components.Settings.Notifications.emailsender": "Email Sender Address",
|
||||
"components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.",
|
||||
"components.Settings.Notifications.emailsettingssaved": "Email notification settings saved!",
|
||||
"components.Settings.Notifications.enableSsl": "Enable SSL",
|
||||
"components.Settings.Notifications.save": "Save Changes",
|
||||
"components.Settings.Notifications.saving": "Saving...",
|
||||
@@ -112,6 +116,8 @@
|
||||
"components.Settings.RadarrModal.defaultserver": "Default Server",
|
||||
"components.Settings.RadarrModal.editradarr": "Edit Radarr Server",
|
||||
"components.Settings.RadarrModal.hostname": "Hostname",
|
||||
"components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…",
|
||||
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
|
||||
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
|
||||
"components.Settings.RadarrModal.port": "Port",
|
||||
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
|
||||
@@ -126,14 +132,25 @@
|
||||
"components.Settings.RadarrModal.servernamePlaceholder": "A Radarr Server",
|
||||
"components.Settings.RadarrModal.ssl": "SSL",
|
||||
"components.Settings.RadarrModal.test": "Test",
|
||||
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test your connection to load quality profiles",
|
||||
"components.Settings.RadarrModal.testFirstRootFolders": "Test your connection to load root folders",
|
||||
"components.Settings.RadarrModal.testing": "Testing...",
|
||||
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr Server",
|
||||
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established!",
|
||||
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
|
||||
"components.Settings.RadarrModal.validationHostnameRequired": "You must provide a hostname/IP",
|
||||
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "You must select minimum availability",
|
||||
"components.Settings.RadarrModal.validationNameRequired": "You must provide a server name",
|
||||
"components.Settings.RadarrModal.validationPortRequired": "You must provide a port",
|
||||
"components.Settings.RadarrModal.validationProfileRequired": "You must select a profile",
|
||||
"components.Settings.RadarrModal.validationRootFolderRequired": "You must select a root folder",
|
||||
"components.Settings.SettingsAbout.clickheretojoindiscord": "Click here to join our Discord server.",
|
||||
"components.Settings.SettingsAbout.gettingsupport": "Getting Support",
|
||||
"components.Settings.SettingsAbout.githubdiscussions": "GitHub Discussions",
|
||||
"components.Settings.SettingsAbout.overseerrinformation": "Overseerr Information",
|
||||
"components.Settings.SettingsAbout.totalmedia": "Total Media",
|
||||
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
|
||||
"components.Settings.SettingsAbout.version": "Version",
|
||||
"components.Settings.SonarrModal.add": "Add Server",
|
||||
"components.Settings.SonarrModal.apiKey": "API Key",
|
||||
"components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API Key",
|
||||
@@ -143,6 +160,8 @@
|
||||
"components.Settings.SonarrModal.defaultserver": "Default Server",
|
||||
"components.Settings.SonarrModal.editsonarr": "Edit Sonarr Server",
|
||||
"components.Settings.SonarrModal.hostname": "Hostname",
|
||||
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
|
||||
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
|
||||
"components.Settings.SonarrModal.port": "Port",
|
||||
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
|
||||
"components.Settings.SonarrModal.rootfolder": "Root Folder",
|
||||
@@ -156,11 +175,14 @@
|
||||
"components.Settings.SonarrModal.servernamePlaceholder": "A Sonarr Server",
|
||||
"components.Settings.SonarrModal.ssl": "SSL",
|
||||
"components.Settings.SonarrModal.test": "Test",
|
||||
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test your connection to load quality profiles",
|
||||
"components.Settings.SonarrModal.testFirstRootFolders": "Test your connection to load root folders",
|
||||
"components.Settings.SonarrModal.testing": "Testing...",
|
||||
"components.Settings.SonarrModal.toastRadarrTestFailure": "Could not connect to Sonarr Server",
|
||||
"components.Settings.SonarrModal.toastRadarrTestSuccess": "Sonarr connection established!",
|
||||
"components.Settings.SonarrModal.validationApiKeyRequired": "You must provide an API key",
|
||||
"components.Settings.SonarrModal.validationHostnameRequired": "You must provide a hostname/IP",
|
||||
"components.Settings.SonarrModal.validationNameRequired": "You must provide a server name",
|
||||
"components.Settings.SonarrModal.validationPortRequired": "You must provide a port",
|
||||
"components.Settings.SonarrModal.validationProfileRequired": "You must select a profile",
|
||||
"components.Settings.SonarrModal.validationRootFolderRequired": "You must select a root folder",
|
||||
@@ -193,6 +215,9 @@
|
||||
"components.Settings.menuPlexSettings": "Plex",
|
||||
"components.Settings.menuServices": "Services",
|
||||
"components.Settings.nextexecution": "Next Execution",
|
||||
"components.Settings.no4kimplemented": "(Default 4K servers are not currently implemented)",
|
||||
"components.Settings.nodefault": "No default server selected!",
|
||||
"components.Settings.nodefaultdescription": "At least one server must be marked as default before any requests will make it to your services.",
|
||||
"components.Settings.notificationsettings": "Notification Settings",
|
||||
"components.Settings.notificationsettingsDescription": "Here you can pick and choose what types of notifications to send and through what types of services.",
|
||||
"components.Settings.notrunning": "Not Running",
|
||||
@@ -206,7 +231,7 @@
|
||||
"components.Settings.runnow": "Run Now",
|
||||
"components.Settings.save": "Save Changes",
|
||||
"components.Settings.saving": "Saving...",
|
||||
"components.Settings.servername": "Server Name (Automatically Set)",
|
||||
"components.Settings.servername": "Server Name (Automatically set after you save)",
|
||||
"components.Settings.servernamePlaceholder": "Plex Server Name",
|
||||
"components.Settings.sonarrSettingsDescription": "Set up your Sonarr connection below. You can have multiple, but only two active as defaults at any time (one for standard HD and one for 4K). Administrators can override which server is used for new requests.",
|
||||
"components.Settings.sonarrsettings": "Sonarr Settings",
|
||||
@@ -214,6 +239,8 @@
|
||||
"components.Settings.startscan": "Start Scan",
|
||||
"components.Settings.sync": "Sync Plex Libraries",
|
||||
"components.Settings.syncing": "Syncing…",
|
||||
"components.Settings.validationHostnameRequired": "You must provide a hostname/IP",
|
||||
"components.Settings.validationPortRequired": "You must provide a port",
|
||||
"components.Setup.configureplex": "Configure Plex",
|
||||
"components.Setup.configureservices": "Configure Services",
|
||||
"components.Setup.continue": "Continue",
|
||||
@@ -221,6 +248,8 @@
|
||||
"components.Setup.finishing": "Finishing...",
|
||||
"components.Setup.loginwithplex": "Login with Plex",
|
||||
"components.Setup.signinMessage": "Get started by logging in with your Plex account",
|
||||
"components.Setup.syncingbackground": "Syncing will run in the background. You can continue the setup process in the meantime.",
|
||||
"components.Setup.tip": "Tip",
|
||||
"components.Setup.welcome": "Welcome to Overseerr",
|
||||
"components.Slider.noresults": "No Results",
|
||||
"components.TitleCard.movie": "Movie",
|
||||
|
||||
323
src/i18n/locale/es.json
Normal file
323
src/i18n/locale/es.json
Normal file
@@ -0,0 +1,323 @@
|
||||
{
|
||||
"components.Settings.SonarrModal.toastRadarrTestFailure": "Error al connectar al Servidor Sonarr",
|
||||
"components.Settings.SonarrModal.testing": "Comprobando...",
|
||||
"components.Settings.SonarrModal.test": "Comprobar",
|
||||
"components.Settings.SonarrModal.ssl": "SSL",
|
||||
"components.Settings.SonarrModal.servernamePlaceholder": "Un servidor Sonarr",
|
||||
"components.Settings.SonarrModal.servername": "Nombre del Servidor",
|
||||
"components.Settings.SonarrModal.server4k": "Servidor 4K",
|
||||
"components.Settings.SonarrModal.selectRootFolder": "Selecciona la Carpeta Raíz",
|
||||
"components.Settings.SonarrModal.selectQualityProfile": "Selecciona un Perfil de Calidad",
|
||||
"components.Settings.SonarrModal.seasonfolders": "Carpetas por Temporada",
|
||||
"components.Settings.SonarrModal.saving": "Guardando...",
|
||||
"components.Settings.SonarrModal.save": "Guardar Cambios",
|
||||
"components.Settings.SonarrModal.rootfolder": "Carpeta Raíz",
|
||||
"components.Settings.SonarrModal.qualityprofile": "Perfil de Calidad",
|
||||
"components.Settings.SonarrModal.port": "Puerto",
|
||||
"components.Settings.SonarrModal.hostname": "Nombre de Host",
|
||||
"components.Settings.SonarrModal.editsonarr": "Editar Servidor Sonarr",
|
||||
"components.Settings.SonarrModal.defaultserver": "Servidor por Defecto",
|
||||
"components.Settings.SonarrModal.createsonarr": "Crear Nuevo Servidor Sonarr",
|
||||
"components.Settings.SonarrModal.baseUrlPlaceholder": "Ejemplo: /sonarr",
|
||||
"components.Settings.SonarrModal.baseUrl": "URL Base",
|
||||
"components.Settings.SonarrModal.apiKeyPlaceholder": "Tu Clave API de Sonarr",
|
||||
"components.Settings.SonarrModal.apiKey": "Clave API",
|
||||
"components.Settings.SonarrModal.add": "Agregar Servidor",
|
||||
"components.Settings.SettingsAbout.version": "Versión",
|
||||
"components.Settings.SettingsAbout.totalrequests": "Peticiones Totales",
|
||||
"components.Settings.SettingsAbout.totalmedia": "Contenido Total",
|
||||
"components.Settings.SettingsAbout.overseerrinformation": "Información de Overseerr",
|
||||
"components.Settings.SettingsAbout.githubdiscussions": "Discursiones en GitHub",
|
||||
"components.Settings.SettingsAbout.gettingsupport": "Soporte",
|
||||
"components.Settings.SettingsAbout.clickheretojoindiscord": "Click aquín para unirte a nuestro servidor de Discord.",
|
||||
"components.Settings.RadarrModal.validationRootFolderRequired": "Debes seleccionar una carpeta raíz",
|
||||
"components.Settings.RadarrModal.validationProfileRequired": "Debes seleccionar un perfil",
|
||||
"components.Settings.RadarrModal.validationPortRequired": "Debes proporcionar un puerto",
|
||||
"components.Settings.RadarrModal.validationNameRequired": "Debes proporcionar un nombre de servidor",
|
||||
"components.Settings.RadarrModal.validationHostnameRequired": "Debes proporcionar un hostname/IP",
|
||||
"components.Settings.RadarrModal.validationApiKeyRequired": "Debes proporcionar la clave API",
|
||||
"components.Settings.RadarrModal.toastRadarrTestSuccess": "¡Conexión con Radarr establecida!",
|
||||
"components.Settings.RadarrModal.toastRadarrTestFailure": "Error al connectar al Servidor Radarr",
|
||||
"components.Settings.RadarrModal.testing": "Comprobando...",
|
||||
"components.Settings.RadarrModal.test": "Comprobar",
|
||||
"components.Settings.RadarrModal.ssl": "SSL",
|
||||
"components.Settings.RadarrModal.servernamePlaceholder": "Un Servidor Radarr",
|
||||
"components.Settings.RadarrModal.servername": "Nombre del Servidor",
|
||||
"components.Settings.RadarrModal.server4k": "Servidor 4K",
|
||||
"components.Settings.RadarrModal.selectRootFolder": "Selecciona la Carpeta Raíz",
|
||||
"components.Settings.RadarrModal.selectQualityProfile": "Selecciona un Perfil de Calidad",
|
||||
"components.Settings.RadarrModal.selectMinimumAvailability": "Selecciona Disponibilidad Mínima",
|
||||
"components.Settings.RadarrModal.saving": "Guardando...",
|
||||
"components.Settings.RadarrModal.save": "Guardar Cambios",
|
||||
"components.Settings.RadarrModal.rootfolder": "Carpeta Raíz",
|
||||
"components.Settings.RadarrModal.qualityprofile": "Perfil de Calidad",
|
||||
"components.Settings.RadarrModal.port": "Puerto",
|
||||
"components.Settings.RadarrModal.minimumAvailability": "Disponibilidad Mínima",
|
||||
"components.Settings.RadarrModal.hostname": "Nombre de Host",
|
||||
"components.Settings.RadarrModal.editradarr": "Editar Servidor Radarr",
|
||||
"components.Settings.RadarrModal.defaultserver": "Servidor por Defecto",
|
||||
"components.Settings.RadarrModal.createradarr": "Crear Nuevo Servidor Radarr",
|
||||
"components.Settings.RadarrModal.baseUrlPlaceholder": "Ejemplo: /radarr",
|
||||
"components.Settings.RadarrModal.baseUrl": "URL Base",
|
||||
"components.Settings.RadarrModal.apiKeyPlaceholder": "Tu clave API de Radarr",
|
||||
"components.Settings.RadarrModal.apiKey": "Clave API",
|
||||
"components.Settings.RadarrModal.add": "Agregar Servidor",
|
||||
"components.Settings.Notifications.webhookUrlPlaceholder": "Ajustes del servidor -> Integraciones -> Webhooks",
|
||||
"components.Settings.Notifications.webhookUrl": "URL de Webhook",
|
||||
"components.Settings.Notifications.validationWebhookUrlRequired": "Debes proporcionar una URL del webhook",
|
||||
"components.Settings.Notifications.validationSmtpPortRequired": "Debes proporcionar un puerto SMTP",
|
||||
"components.Settings.Notifications.validationSmtpHostRequired": "Debes proporcionar un host SMTP",
|
||||
"components.Settings.Notifications.validationFromRequired": "Debes proporcionar una dirección de envío de email",
|
||||
"components.Settings.Notifications.smtpPort": "Puerto SMTP",
|
||||
"components.Settings.Notifications.smtpHost": "Host SMTP",
|
||||
"components.Settings.Notifications.saving": "Guardando...",
|
||||
"components.Settings.Notifications.save": "Guardar Cambios",
|
||||
"components.Settings.Notifications.enableSsl": "Activar SSL",
|
||||
"components.Settings.Notifications.emailsettingssaved": "¡Ajustes de notificación de Email guardados!",
|
||||
"components.Settings.Notifications.emailsettingsfailed": "Fallo al guardar ajustes de notificación de Email.",
|
||||
"components.Settings.Notifications.emailsender": "Dirección del Remitente de Email",
|
||||
"components.Settings.Notifications.discordsettingssaved": "¡Ajustes de notificación de Discord guardados!",
|
||||
"components.Settings.Notifications.discordsettingsfailed": "Fallo al guardar ajustes de notificación de Discord.",
|
||||
"components.Settings.Notifications.authUser": "Usuario",
|
||||
"components.Settings.Notifications.authPass": "Contraseña",
|
||||
"components.Settings.Notifications.agentenabled": "Agente Activado",
|
||||
"components.Search.searchresults": "Resultado de la búsqueda",
|
||||
"components.RequestModal.status": "Estado",
|
||||
"components.RequestModal.selectseason": "Seleccionar temporada(s)",
|
||||
"components.RequestModal.seasonnumber": "Temporada {number}",
|
||||
"components.RequestModal.season": "Temporada",
|
||||
"components.RequestModal.requesttitle": "Solicitar {title}",
|
||||
"components.RequestModal.requestseasons": "Solicitar {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}",
|
||||
"components.RequestModal.requesting": "Solicitando…",
|
||||
"components.RequestModal.requestfrom": "Hay una petición pendiente de {username}",
|
||||
"components.RequestModal.requestadmin": "Tu petición será aprovada inmediatamente.",
|
||||
"components.RequestModal.requestSuccess": "<strong>{title}</strong> solicitado.",
|
||||
"components.RequestList.showingresults": "Mostrando <strong>{from}</strong> a <strong>{to}</strong> de <strong>{total}</strong> resultados",
|
||||
"components.RequestModal.requestCancel": "Solicitud para <strong>{title}</strong> cancelada",
|
||||
"components.RequestModal.request": "Solicitar",
|
||||
"components.RequestModal.pendingrequest": "Solicitud pendiente para {title}",
|
||||
"components.RequestModal.numberofepisodes": "# de Episodios",
|
||||
"components.RequestModal.notrequested": "No Solicitado",
|
||||
"components.RequestModal.extras": "Extras",
|
||||
"components.RequestModal.close": "Cerrar",
|
||||
"components.RequestModal.cancelrequest": "Esto borrará tu petición. ¿Quieres continuar?",
|
||||
"components.RequestModal.cancelling": "Cancelando...",
|
||||
"components.RequestModal.cancel": "Cancelar Petición",
|
||||
"components.RequestList.status": "Estado",
|
||||
"components.RequestList.requests": "Peticiones",
|
||||
"components.RequestList.requestedAt": "Solicitado",
|
||||
"components.RequestList.previous": "Anterior",
|
||||
"components.RequestList.next": "Siguiente",
|
||||
"components.RequestList.modifiedBy": "Última Edición Por",
|
||||
"components.RequestList.mediaInfo": "Información",
|
||||
"components.RequestList.RequestItem.seasons": "Temporadas",
|
||||
"components.RequestList.RequestItem.requestedby": "Solicitado por {username}",
|
||||
"components.RequestList.RequestItem.notavailable": "N/D",
|
||||
"components.RequestCard.seasons": "Temporadas",
|
||||
"components.RequestCard.requestedby": "Solicitado por {username}",
|
||||
"components.RequestCard.all": "Todas",
|
||||
"components.RequestBlock.seasons": "Temporadas",
|
||||
"components.PlexLoginButton.loginwithplex": "Iniciar Sesión con Plex",
|
||||
"components.PlexLoginButton.loggingin": "Iniciando Sesión...",
|
||||
"components.PlexLoginButton.loading": "Cargando...",
|
||||
"components.PersonDetails.nobiography": "No hay biografía disponible.",
|
||||
"components.PersonDetails.ascharacter": "como {character}",
|
||||
"components.PersonDetails.appearsin": "Aparece en",
|
||||
"components.MovieDetails.viewrequest": "Ver Petición",
|
||||
"components.MovieDetails.userrating": "Puntuación de los Usuarios",
|
||||
"components.MovieDetails.unavailable": "No Disponible",
|
||||
"components.MovieDetails.status": "Estado",
|
||||
"components.MovieDetails.similarsubtext": "Otras películas parecidas a {title}",
|
||||
"components.MovieDetails.similar": "Títulos Similares",
|
||||
"components.MovieDetails.runtime": "{minutes} minutos",
|
||||
"components.MovieDetails.revenue": "Recaudado",
|
||||
"components.MovieDetails.request": "Solicitar",
|
||||
"components.MovieDetails.releasedate": "Fecha de Lanzamiento",
|
||||
"components.MovieDetails.recommendationssubtext": "Si te gustó {title}, también te puede gustar...",
|
||||
"components.MovieDetails.cast": "Reparto",
|
||||
"components.MovieDetails.MovieCast.fullcast": "Reparto Completo",
|
||||
"components.MovieDetails.recommendations": "Recomendaciones",
|
||||
"components.MovieDetails.pending": "Pendiente",
|
||||
"components.MovieDetails.overviewunavailable": "Resumen no disponible",
|
||||
"components.MovieDetails.overview": "Resumen",
|
||||
"components.MovieDetails.originallanguage": "Idioma Original",
|
||||
"components.MovieDetails.manageModalTitle": "Gestionar Película",
|
||||
"components.MovieDetails.manageModalRequests": "Peticiones",
|
||||
"components.MovieDetails.manageModalNoRequests": "Sin Peticiones",
|
||||
"components.MovieDetails.manageModalClearMediaWarning": "Esto borrará todos los datos de los medios, incluyendo las peticiones. Si el elemento existe en tu librería de Plex, la información del elemento se recreará en la siguiente sincronización.",
|
||||
"components.MovieDetails.manageModalClearMedia": "Borrar todos los datos de medios",
|
||||
"components.MovieDetails.decline": "Rechazar",
|
||||
"components.MovieDetails.cancelrequest": "Cancelar Petición",
|
||||
"components.MovieDetails.budget": "Presupuesto",
|
||||
"components.MovieDetails.available": "Disponible",
|
||||
"components.MovieDetails.approve": "Aprobar",
|
||||
"components.Login.signinplex": "Inicia Sesión para continuar",
|
||||
"components.Layout.UserDropdown.signout": "Cerrar Sesión",
|
||||
"components.Layout.Sidebar.users": "Usuarios",
|
||||
"components.Layout.Sidebar.settings": "Ajustes",
|
||||
"components.Layout.Sidebar.requests": "Peticiones",
|
||||
"components.Layout.Sidebar.dashboard": "Descubrir",
|
||||
"components.Layout.SearchInput.searchPlaceholder": "Buscar Películas y Series",
|
||||
"components.Layout.LanguagePicker.changelanguage": "Cambiar Idioma",
|
||||
"components.Discover.upcomingmovies": "Próximas Películas",
|
||||
"components.Discover.upcoming": "Próximas Películas",
|
||||
"components.Discover.trending": "Tendencias",
|
||||
"components.Discover.recentrequests": "Peticiones Recientes",
|
||||
"components.Discover.recentlyAdded": "Agregado Recientemente",
|
||||
"components.Discover.populartv": "Series Populares",
|
||||
"components.Discover.popularmovies": "Películas Populares",
|
||||
"components.Discover.nopending": "Sin Peticiones Pendientes",
|
||||
"components.Discover.discovertv": "Series Populares",
|
||||
"components.Discover.discovermovies": "Películas Populares",
|
||||
"components.Layout.alphawarning": "Este software está en fase ALFA. Muchas cosas pueden ser inestables o fallar. ¡Por favor reporta estos problemas en el GitHub de Overseerr!",
|
||||
"components.Settings.addsonarr": "Agregar servidor Sonarr",
|
||||
"components.Settings.address": "Dirección",
|
||||
"components.Settings.addradarr": "Agregar servidor Radarr",
|
||||
"components.Settings.activeProfile": "Perfil activo",
|
||||
"components.Settings.SonarrModal.validationRootFolderRequired": "Debes seleccionar una carpeta raíz",
|
||||
"components.Settings.SonarrModal.validationProfileRequired": "Debes seleccionar un perfil",
|
||||
"components.Settings.SonarrModal.validationPortRequired": "Debes proporcionar un puerto",
|
||||
"components.Settings.SonarrModal.validationNameRequired": "Debes proporcionar un nombre de servidor",
|
||||
"components.Settings.SonarrModal.validationHostnameRequired": "Debes proporcionar un hostname/IP",
|
||||
"components.Settings.SonarrModal.validationApiKeyRequired": "Debes proporcionar la clave API",
|
||||
"components.Settings.SonarrModal.toastRadarrTestSuccess": "¡Conexión con Sonarr establecida!",
|
||||
"components.Settings.menuLogs": "Registro",
|
||||
"pages.somethingWentWrong": "{statusCode} - Algo salió mal",
|
||||
"pages.serviceUnavailable": "{statusCode}: Servicio no Disponible",
|
||||
"pages.returnHome": "Volver al Inicio",
|
||||
"pages.pageNotFound": "404 - Página no encontrada",
|
||||
"pages.oops": "Ups",
|
||||
"pages.internalServerError": "{statusCode}: Error interno del servidor",
|
||||
"i18n.unavailable": "No Disponible",
|
||||
"i18n.tvshows": "Series",
|
||||
"i18n.processing": "Procesando…",
|
||||
"i18n.pending": "Pendiente",
|
||||
"i18n.partiallyavailable": "Parcialmente Disponible",
|
||||
"i18n.movies": "Películas",
|
||||
"i18n.delete": "Eliminar",
|
||||
"i18n.declined": "Rechazado",
|
||||
"i18n.decline": "Rechazar",
|
||||
"i18n.cancel": "Cancelar",
|
||||
"i18n.available": "Disponible",
|
||||
"i18n.approved": "Aprobado",
|
||||
"i18n.approve": "Aprobar",
|
||||
"components.UserList.usertype": "Tipo de usuario",
|
||||
"components.UserList.username": "Nombre de usuario",
|
||||
"components.UserList.userlist": "Lista de usuarios",
|
||||
"components.UserList.user": "Usuario",
|
||||
"components.UserList.totalrequests": "Solicitudes totales",
|
||||
"components.UserList.role": "Rol",
|
||||
"components.UserList.plexuser": "Usuario de Plex",
|
||||
"components.UserList.lastupdated": "Última actualización",
|
||||
"components.UserList.edit": "Editar",
|
||||
"components.UserList.delete": "Eliminar",
|
||||
"components.UserList.created": "Creado",
|
||||
"components.UserList.admin": "Administrador",
|
||||
"components.UserEdit.voteDescription": "Otorga permiso para votar en las solicitudes (votación aún no implementada)",
|
||||
"components.UserEdit.vote": "Votar",
|
||||
"components.UserEdit.usersaved": "Usuario guardado",
|
||||
"components.UserEdit.usersDescription": "Otorga permiso para administrar usuarios de Overseerr. Los usuarios con este permiso no pueden modificar a los usuarios con privilegios de administrador, o concederlos.",
|
||||
"components.UserEdit.users": "Administrar usuarios",
|
||||
"components.UserEdit.username": "Nombre de usuario",
|
||||
"components.UserEdit.userfail": "Algo salió mal al guardar al usuario.",
|
||||
"components.UserEdit.settingsDescription": "Otorga permiso para modificar todas las configuraciones de Overseerr. El usuario debe tener este permiso para otorgarlo a otros.",
|
||||
"components.UserEdit.settings": "Administrar configuración",
|
||||
"components.UserEdit.saving": "Guardando...",
|
||||
"components.UserEdit.save": "Guardar",
|
||||
"components.UserEdit.requestDescription": "Otorga permiso para solicitar películas y series.",
|
||||
"components.UserEdit.request": "Solicitar",
|
||||
"components.UserEdit.permissions": "Permisos",
|
||||
"components.UserEdit.managerequestsDescription": "Otorga permiso para administrar las solicitudes de Overseerr. Esto incluye aprobar y rechazar solicitudes.",
|
||||
"components.UserEdit.managerequests": "Gestionar solicitudes",
|
||||
"components.UserEdit.email": "Correo electrónico",
|
||||
"components.UserEdit.edituser": "Editar Usuario",
|
||||
"components.UserEdit.avatar": "Avatar",
|
||||
"components.UserEdit.autoapproveDescription": "Otorga aprobación automática para cualquier solicitud de este usuario.",
|
||||
"components.UserEdit.autoapprove": "Aprobación automática",
|
||||
"components.UserEdit.adminDescription": "Acceso total de administrador . Ignoran todas las comprobaciones de permisos.",
|
||||
"components.UserEdit.admin": "Administrador",
|
||||
"components.TvDetails.userrating": "Puntuación de los Usuarios",
|
||||
"components.TvDetails.unavailable": "No Disponible",
|
||||
"components.TvDetails.status": "Estado",
|
||||
"components.TvDetails.similarsubtext": "Otras series similares a {title}",
|
||||
"components.TvDetails.similar": "Series similares",
|
||||
"components.TvDetails.requestmore": "Solicitar más",
|
||||
"components.TvDetails.request": "Solicitar",
|
||||
"components.TvDetails.recommendationssubtext": "Si te gustó {title}, también te puede gustar...",
|
||||
"components.TvDetails.recommendations": "Recomendaciones",
|
||||
"components.TvDetails.pending": "Pendiente",
|
||||
"components.TvDetails.overviewunavailable": "Resumen no disponible",
|
||||
"components.TvDetails.overview": "Resumen",
|
||||
"components.TvDetails.originallanguage": "Idioma original",
|
||||
"components.TvDetails.manageModalTitle": "Gestionar Series",
|
||||
"components.TvDetails.manageModalRequests": "Peticiones",
|
||||
"components.TvDetails.manageModalNoRequests": "Sin Peticiones",
|
||||
"components.TvDetails.manageModalClearMediaWarning": "Esto borrará todos los datos de los medios, incluyendo las peticiones. Si el elemento existe en tu librería de Plex, la información del elemento se recreará en la siguiente sincronización.",
|
||||
"components.TvDetails.manageModalClearMedia": "Borrar todos los datos de medios",
|
||||
"components.TvDetails.declinerequests": "Rechazar {requestCount} {requestCount, plural, one {Request} other {Requests}}",
|
||||
"components.TvDetails.decline": "Rechazar",
|
||||
"components.TvDetails.cast": "Reparto",
|
||||
"components.TvDetails.cancelrequest": "Cancelar Petición",
|
||||
"components.TvDetails.available": "Disponible",
|
||||
"components.TvDetails.approverequests": "Aprobar {requestCount} {requestCount, plural, one {Request} other {Requests}}",
|
||||
"components.TvDetails.approve": "Aprobar",
|
||||
"components.TvDetails.TvCast.fullseriescast": "Reparto completo de la serie",
|
||||
"components.TitleCard.tvshow": "Series",
|
||||
"components.Settings.jobname": "Nombre de Tarea",
|
||||
"components.TitleCard.movie": "Película",
|
||||
"components.Slider.noresults": "Sin resultados",
|
||||
"components.Setup.welcome": "Bienvenido a Overseerr",
|
||||
"components.Setup.signinMessage": "Comience iniciando sesión con su cuenta de Plex",
|
||||
"components.Setup.loginwithplex": "Iniciar sesión con Plex",
|
||||
"components.Setup.finishing": "Finalizando...",
|
||||
"components.Setup.finish": "Finalizar configuración",
|
||||
"components.Setup.continue": "Continuar",
|
||||
"components.Setup.configureplex": "Configurar Plex",
|
||||
"components.Setup.configureservices": "Configurar servicios",
|
||||
"components.Settings.validationPortRequired": "Debes proporcionar un puerto",
|
||||
"components.Settings.validationHostnameRequired": "Debe proporcionar un nombre de host / IP",
|
||||
"components.Settings.syncing": "Sincronizando…",
|
||||
"components.Settings.sync": "Sincronizar bibliotecas de Plex",
|
||||
"components.Settings.startscan": "Iniciar escaneo",
|
||||
"components.Settings.ssl": "SSL",
|
||||
"components.Settings.sonarrsettings": "Ajustes de Sonarr",
|
||||
"components.Settings.sonarrSettingsDescription": "Configure su conexión Sonarr a continuación. Puede tener varias instancias, pero sólo dos activas como predeterminadas en cualquier momento (una para HD estándar y otro para 4K). Los administradores pueden elegir qué servidor se utiliza para nuevas solicitudes.",
|
||||
"components.Settings.servernamePlaceholder": "Nombre del servidor Plex",
|
||||
"components.Settings.servername": "Nombre del servidor (Establecido automáticamente después de guardar)",
|
||||
"components.Settings.saving": "Guardando...",
|
||||
"components.Settings.save": "Guardar cambios",
|
||||
"components.Settings.runnow": "Ejecutar ahora",
|
||||
"components.Settings.radarrsettings": "Ajustes de Radarr",
|
||||
"components.Settings.radarrSettingsDescription": "Configure su conexión a Radarr a continuación. Puede tener varias instancias, pero sólo dos activas como predeterminadas en cualquier momento (una para HD estándar y otro para 4K). Los administradores pueden elegir qué servidor se utiliza para nuevas solicitudes.",
|
||||
"components.Settings.port": "Puerto",
|
||||
"components.Settings.plexsettingsDescription": "Configure los ajustes de su servidor Plex. Overseerr usa su servidor Plex para escanear su biblioteca en un intervalo y ver qué contenido está disponible.",
|
||||
"components.Settings.plexsettings": "Ajustes de Plex",
|
||||
"components.Settings.plexlibrariesDescription": "Las bibliotecas en las que Overseerr busca títulos. Configure y guarde la configuración de conexión Plex y haga clic en el botón de abajo si no aparece ninguna.",
|
||||
"components.Settings.plexlibraries": "Bibliotecas Plex",
|
||||
"components.Settings.notrunning": "Sin ejecutarse",
|
||||
"components.Settings.notificationsettingsDescription": "Aquí puedes elegir qué tipos de notificaciones enviar y a través de qué tipos de servicios.",
|
||||
"components.Settings.notificationsettings": "Configuración de notificaciones",
|
||||
"components.Settings.nextexecution": "Siguiente ejecución",
|
||||
"components.Settings.menuServices": "Servicios",
|
||||
"components.Settings.menuPlexSettings": "Plex",
|
||||
"components.Settings.menuNotifications": "Notificaciones",
|
||||
"components.Settings.menuJobs": "Tareas",
|
||||
"components.Settings.menuGeneralSettings": "Ajustes Generales",
|
||||
"components.Settings.menuAbout": "Acerca de",
|
||||
"components.Settings.manualscanDescription": "Normalmente, esto sólo se ejecutará una vez cada 6 horas. Overseerr comprobará de forma más agresiva los añadidos recientemente de su servidor Plex. ¡Si es la primera vez que configura Plex se recomienda un escaneo manual completo de la biblioteca!",
|
||||
"components.Settings.manualscan": "Escaneo manual de biblioteca",
|
||||
"components.Settings.librariesRemaining": "Bibliotecas restantes: {count}",
|
||||
"components.Settings.hostname": "Nombre de host / IP",
|
||||
"components.Settings.generalsettingsDescription": "Estos son ajustes relacionados con la configuración general de Overseerr.",
|
||||
"components.Settings.generalsettings": "Configuración general",
|
||||
"components.Settings.edit": "Editar",
|
||||
"components.Settings.deleteserverconfirm": "¿Está seguro de que desea eliminar este servidor?",
|
||||
"components.Settings.delete": "Eliminar",
|
||||
"components.Settings.default4k": "4K predeterminado",
|
||||
"components.Settings.default": "Predeterminado",
|
||||
"components.Settings.currentlibrary": "Biblioteca actual: {name}",
|
||||
"components.Settings.copied": "Clave API copiada en el portapapeles",
|
||||
"components.Settings.cancelscan": "Cancelar escaneo",
|
||||
"components.Settings.applicationurl": "URL de la aplicación",
|
||||
"components.Settings.apikey": "Clave API"
|
||||
}
|
||||
@@ -90,13 +90,13 @@
|
||||
"components.Settings.Notifications.agentenabled": "Agent activé",
|
||||
"components.Settings.Notifications.authPass": "Auth Pass",
|
||||
"components.Settings.Notifications.authUser": "Auth User",
|
||||
"components.Settings.Notifications.emailsender": "Adresse courriel de l'expéditeur",
|
||||
"components.Settings.Notifications.emailsender": "e-mail de l'expéditeur",
|
||||
"components.Settings.Notifications.enableSsl": "Activer le SSL",
|
||||
"components.Settings.Notifications.save": "Sauvegarder les changements",
|
||||
"components.Settings.Notifications.saving": "Sauvegarde en cours…",
|
||||
"components.Settings.Notifications.smtpHost": "Hôte SMTP",
|
||||
"components.Settings.Notifications.smtpPort": "Port SMTP",
|
||||
"components.Settings.Notifications.validationFromRequired": "Vous devez fournir une adresse courriel d'expéditeur",
|
||||
"components.Settings.Notifications.validationFromRequired": "Vous devez fournir une adresse e-mail d'expéditeur",
|
||||
"components.Settings.Notifications.validationSmtpHostRequired": "Vous devez fournir un hôte SSL",
|
||||
"components.Settings.Notifications.validationSmtpPortRequired": "Vous devez fournir un port SMTP",
|
||||
"components.Settings.Notifications.validationWebhookUrlRequired": "Vous devez fournir une URL de webhook",
|
||||
@@ -255,7 +255,7 @@
|
||||
"components.UserEdit.autoapproveDescription": "Active la validation automatique de toutes les demandes d'ajouts de cet utilisateur.",
|
||||
"components.UserEdit.avatar": "Avatar",
|
||||
"components.UserEdit.edituser": "Modifier l'utilisateur",
|
||||
"components.UserEdit.email": "Courriel",
|
||||
"components.UserEdit.email": "e-mail",
|
||||
"components.UserEdit.managerequests": "Gérer les demandes d'ajouts",
|
||||
"components.UserEdit.managerequestsDescription": "Donne la permission de gérer les demandes d'ajouts sur Overseerr. Ceci inclut la possibilité de valider ou refuser une demande.",
|
||||
"components.UserEdit.permissions": "Autorisations",
|
||||
@@ -304,5 +304,9 @@
|
||||
"pages.serviceUnavailable": "{statusCode} – Service indisponible",
|
||||
"pages.somethingWentWrong": "{statusCode} – Une erreur est survenue",
|
||||
"components.TvDetails.TvCast.fullseriescast": "Casting complet de la série",
|
||||
"components.MovieDetails.MovieCast.fullcast": "Casting complet"
|
||||
"components.MovieDetails.MovieCast.fullcast": "Casting complet",
|
||||
"components.Settings.Notifications.emailsettingssaved": "Paramètres de notification par courriel enregistrés !",
|
||||
"components.Settings.Notifications.emailsettingsfailed": "Les paramètres de notification par courriel n'ont pas pu être enregistrés.",
|
||||
"components.Settings.Notifications.discordsettingssaved": "Paramètres de notification Discord enregistrés !",
|
||||
"components.Settings.Notifications.discordsettingsfailed": "Les paramètres de notification Discord n'ont pas pu être enregistrés."
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
"components.Settings.runnow": "今すぐ実行",
|
||||
"components.Settings.save": "変更を保存",
|
||||
"components.Settings.saving": "保存中...",
|
||||
"components.Settings.servername": "サーバー名 (自動設定)",
|
||||
"components.Settings.servername": "サーバー名 (保存時に自動で設定されます)",
|
||||
"components.Settings.servernamePlaceholder": "Plexサーバー名",
|
||||
"components.Settings.sonarrSettingsDescription": "Sonarr接続設定。複数のサーバーを繋ぐことができますが、デフォルトで常にアクティブなのは2つのみ(1つは標準HD用、もう1つは4K用)。管理者は、新しいリクエストに使用するサーバーを選択することができます。",
|
||||
"components.Settings.sonarrsettings": "Sonarr設定",
|
||||
@@ -302,5 +302,22 @@
|
||||
"pages.pageNotFound": "404 - ページが見つかりません",
|
||||
"pages.returnHome": "ホームへ戻る",
|
||||
"pages.serviceUnavailable": "{statusCode} - サービスが利用できません",
|
||||
"pages.somethingWentWrong": "{statusCode} - 問題が発生しました"
|
||||
"pages.somethingWentWrong": "{statusCode} - 問題が発生しました",
|
||||
"components.TvDetails.TvCast.fullseriescast": "フルシリーズキャスト",
|
||||
"components.Settings.validationPortRequired": "ポートの入力が必要です",
|
||||
"components.Settings.validationHostnameRequired": "ホスト名/IPの入力が必要です",
|
||||
"components.Settings.SonarrModal.validationNameRequired": "サーバー名を指定してください",
|
||||
"components.Settings.SettingsAbout.version": "バージョン",
|
||||
"components.Settings.SettingsAbout.totalrequests": "総リクエスト数",
|
||||
"components.Settings.SettingsAbout.totalmedia": "総メディア数",
|
||||
"components.Settings.SettingsAbout.overseerrinformation": "Overseerr情報",
|
||||
"components.Settings.SettingsAbout.githubdiscussions": "GitHubディスカッション",
|
||||
"components.Settings.SettingsAbout.gettingsupport": "サポート",
|
||||
"components.Settings.SettingsAbout.clickheretojoindiscord": "Discordサーバーの参加はこちら。",
|
||||
"components.Settings.RadarrModal.validationNameRequired": "サーバー名を指定してください",
|
||||
"components.Settings.Notifications.emailsettingssaved": "メール通知設定が保存されました!",
|
||||
"components.Settings.Notifications.emailsettingsfailed": "メール通知設定の保存に失敗しました。",
|
||||
"components.Settings.Notifications.discordsettingsfailed": "ディスコード通知設定の保存に失敗しました。",
|
||||
"components.Settings.Notifications.discordsettingssaved": "ディスコードの通知設定が保存されました!",
|
||||
"components.MovieDetails.MovieCast.fullcast": "フルキャスト"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"components.Discover.nopending": "Geen Wachtende Verzoeken",
|
||||
"components.Discover.popularmovies": "Populaire Films",
|
||||
"components.Discover.populartv": "Populaire Series",
|
||||
"components.Discover.recentlyAdded": "RecentelijkToegevoegd",
|
||||
"components.Discover.recentlyAdded": "Recentelijk Toegevoegd",
|
||||
"components.Discover.recentrequests": "Recente Verzoeken",
|
||||
"components.Discover.trending": "Trending",
|
||||
"components.Discover.upcoming": "Verwachte Films",
|
||||
@@ -32,7 +32,7 @@
|
||||
"components.MovieDetails.originallanguage": "Originele Taal",
|
||||
"components.MovieDetails.overview": "Overzicht",
|
||||
"components.MovieDetails.overviewunavailable": "Overzicht onbeschikbaar",
|
||||
"components.MovieDetails.pending": "In afwachting",
|
||||
"components.MovieDetails.pending": "Wachtende",
|
||||
"components.MovieDetails.recommendations": "Aanbevelingen",
|
||||
"components.MovieDetails.recommendationssubtext": "Als je {title} leuk vond, vind je dit misschien ook leuk...",
|
||||
"components.MovieDetails.releasedate": "Release Datum",
|
||||
@@ -56,251 +56,261 @@
|
||||
"components.RequestCard.requestedby": "Verzocht door {username}",
|
||||
"components.RequestCard.seasons": "Seizoenen",
|
||||
"components.RequestList.RequestItem.notavailable": "Nvt",
|
||||
"components.RequestList.RequestItem.requestedby": "",
|
||||
"components.RequestList.RequestItem.seasons": "",
|
||||
"components.RequestList.mediaInfo": "",
|
||||
"components.RequestList.modifiedBy": "",
|
||||
"components.RequestList.next": "",
|
||||
"components.RequestList.previous": "",
|
||||
"components.RequestList.requestedAt": "",
|
||||
"components.RequestList.requests": "",
|
||||
"components.RequestList.showingresults": "",
|
||||
"components.RequestList.status": "",
|
||||
"components.RequestModal.cancel": "",
|
||||
"components.RequestModal.cancelling": "",
|
||||
"components.RequestModal.cancelrequest": "",
|
||||
"components.RequestModal.close": "",
|
||||
"components.RequestModal.extras": "",
|
||||
"components.RequestModal.notrequested": "",
|
||||
"components.RequestModal.numberofepisodes": "",
|
||||
"components.RequestModal.pendingrequest": "",
|
||||
"components.RequestModal.request": "",
|
||||
"components.RequestModal.requestCancel": "",
|
||||
"components.RequestModal.requestSuccess": "",
|
||||
"components.RequestModal.requestadmin": "",
|
||||
"components.RequestModal.requestfrom": "",
|
||||
"components.RequestModal.requesting": "",
|
||||
"components.RequestModal.requestseasons": "",
|
||||
"components.RequestModal.requesttitle": "",
|
||||
"components.RequestModal.season": "",
|
||||
"components.RequestModal.seasonnumber": "",
|
||||
"components.RequestModal.selectseason": "",
|
||||
"components.RequestModal.status": "",
|
||||
"components.Search.searchresults": "",
|
||||
"components.Settings.Notifications.agentenabled": "",
|
||||
"components.Settings.Notifications.authPass": "",
|
||||
"components.Settings.Notifications.authUser": "",
|
||||
"components.Settings.Notifications.emailsender": "",
|
||||
"components.Settings.Notifications.enableSsl": "",
|
||||
"components.Settings.Notifications.save": "",
|
||||
"components.Settings.Notifications.saving": "",
|
||||
"components.Settings.Notifications.smtpHost": "",
|
||||
"components.Settings.Notifications.smtpPort": "",
|
||||
"components.Settings.Notifications.validationFromRequired": "",
|
||||
"components.Settings.Notifications.validationSmtpHostRequired": "",
|
||||
"components.Settings.Notifications.validationSmtpPortRequired": "",
|
||||
"components.Settings.Notifications.validationWebhookUrlRequired": "",
|
||||
"components.Settings.Notifications.webhookUrl": "",
|
||||
"components.Settings.Notifications.webhookUrlPlaceholder": "",
|
||||
"components.Settings.RadarrModal.add": "",
|
||||
"components.Settings.RadarrModal.apiKey": "",
|
||||
"components.Settings.RadarrModal.apiKeyPlaceholder": "",
|
||||
"components.Settings.RadarrModal.baseUrl": "",
|
||||
"components.Settings.RadarrModal.baseUrlPlaceholder": "",
|
||||
"components.Settings.RadarrModal.createradarr": "",
|
||||
"components.Settings.RadarrModal.defaultserver": "",
|
||||
"components.Settings.RadarrModal.editradarr": "",
|
||||
"components.Settings.RadarrModal.hostname": "",
|
||||
"components.Settings.RadarrModal.minimumAvailability": "",
|
||||
"components.Settings.RadarrModal.port": "",
|
||||
"components.Settings.RadarrModal.qualityprofile": "",
|
||||
"components.Settings.RadarrModal.rootfolder": "",
|
||||
"components.Settings.RadarrModal.save": "",
|
||||
"components.Settings.RadarrModal.saving": "",
|
||||
"components.Settings.RadarrModal.selectMinimumAvailability": "",
|
||||
"components.Settings.RadarrModal.selectQualityProfile": "",
|
||||
"components.Settings.RadarrModal.selectRootFolder": "",
|
||||
"components.Settings.RadarrModal.server4k": "",
|
||||
"components.Settings.RadarrModal.servername": "",
|
||||
"components.Settings.RadarrModal.servernamePlaceholder": "",
|
||||
"components.Settings.RadarrModal.ssl": "",
|
||||
"components.Settings.RadarrModal.test": "",
|
||||
"components.Settings.RadarrModal.testing": "",
|
||||
"components.Settings.RadarrModal.toastRadarrTestFailure": "",
|
||||
"components.Settings.RadarrModal.toastRadarrTestSuccess": "",
|
||||
"components.Settings.RadarrModal.validationApiKeyRequired": "",
|
||||
"components.Settings.RadarrModal.validationHostnameRequired": "",
|
||||
"components.Settings.RadarrModal.validationPortRequired": "",
|
||||
"components.Settings.RadarrModal.validationProfileRequired": "",
|
||||
"components.Settings.RadarrModal.validationRootFolderRequired": "",
|
||||
"components.Settings.SonarrModal.add": "",
|
||||
"components.Settings.SonarrModal.apiKey": "",
|
||||
"components.Settings.SonarrModal.apiKeyPlaceholder": "",
|
||||
"components.Settings.SonarrModal.baseUrl": "",
|
||||
"components.Settings.SonarrModal.baseUrlPlaceholder": "",
|
||||
"components.Settings.SonarrModal.createsonarr": "",
|
||||
"components.Settings.SonarrModal.defaultserver": "",
|
||||
"components.Settings.SonarrModal.editsonarr": "",
|
||||
"components.Settings.SonarrModal.hostname": "",
|
||||
"components.Settings.SonarrModal.port": "",
|
||||
"components.Settings.SonarrModal.qualityprofile": "",
|
||||
"components.Settings.SonarrModal.rootfolder": "",
|
||||
"components.Settings.SonarrModal.save": "",
|
||||
"components.Settings.SonarrModal.saving": "",
|
||||
"components.Settings.SonarrModal.seasonfolders": "",
|
||||
"components.Settings.SonarrModal.selectQualityProfile": "",
|
||||
"components.Settings.SonarrModal.selectRootFolder": "",
|
||||
"components.Settings.SonarrModal.server4k": "",
|
||||
"components.Settings.SonarrModal.servername": "",
|
||||
"components.Settings.SonarrModal.servernamePlaceholder": "",
|
||||
"components.Settings.SonarrModal.ssl": "",
|
||||
"components.Settings.SonarrModal.test": "",
|
||||
"components.Settings.SonarrModal.testing": "",
|
||||
"components.Settings.SonarrModal.toastRadarrTestFailure": "",
|
||||
"components.Settings.SonarrModal.toastRadarrTestSuccess": "",
|
||||
"components.Settings.SonarrModal.validationApiKeyRequired": "",
|
||||
"components.Settings.SonarrModal.validationHostnameRequired": "",
|
||||
"components.Settings.SonarrModal.validationPortRequired": "",
|
||||
"components.Settings.SonarrModal.validationProfileRequired": "",
|
||||
"components.Settings.SonarrModal.validationRootFolderRequired": "",
|
||||
"components.Settings.activeProfile": "",
|
||||
"components.Settings.addradarr": "",
|
||||
"components.Settings.address": "",
|
||||
"components.Settings.addsonarr": "",
|
||||
"components.Settings.apikey": "",
|
||||
"components.Settings.applicationurl": "",
|
||||
"components.Settings.cancelscan": "",
|
||||
"components.Settings.copied": "",
|
||||
"components.Settings.currentlibrary": "",
|
||||
"components.Settings.default": "",
|
||||
"components.Settings.default4k": "",
|
||||
"components.Settings.delete": "",
|
||||
"components.Settings.deleteserverconfirm": "",
|
||||
"components.Settings.edit": "",
|
||||
"components.Settings.generalsettings": "",
|
||||
"components.Settings.generalsettingsDescription": "",
|
||||
"components.Settings.hostname": "",
|
||||
"components.Settings.jobname": "",
|
||||
"components.Settings.librariesRemaining": "",
|
||||
"components.Settings.manualscan": "",
|
||||
"components.Settings.manualscanDescription": "",
|
||||
"components.Settings.menuAbout": "",
|
||||
"components.Settings.menuGeneralSettings": "",
|
||||
"components.Settings.menuJobs": "",
|
||||
"components.Settings.menuLogs": "",
|
||||
"components.Settings.menuNotifications": "",
|
||||
"components.Settings.menuPlexSettings": "",
|
||||
"components.Settings.menuServices": "",
|
||||
"components.Settings.nextexecution": "",
|
||||
"components.Settings.notificationsettings": "",
|
||||
"components.Settings.notificationsettingsDescription": "",
|
||||
"components.Settings.notrunning": "",
|
||||
"components.Settings.plexlibraries": "",
|
||||
"components.Settings.plexlibrariesDescription": "",
|
||||
"components.Settings.plexsettings": "",
|
||||
"components.Settings.plexsettingsDescription": "",
|
||||
"components.Settings.port": "",
|
||||
"components.Settings.radarrSettingsDescription": "",
|
||||
"components.Settings.radarrsettings": "",
|
||||
"components.Settings.runnow": "",
|
||||
"components.Settings.save": "",
|
||||
"components.Settings.saving": "",
|
||||
"components.Settings.servername": "",
|
||||
"components.Settings.servernamePlaceholder": "",
|
||||
"components.Settings.sonarrSettingsDescription": "",
|
||||
"components.Settings.sonarrsettings": "",
|
||||
"components.Settings.ssl": "",
|
||||
"components.Settings.startscan": "",
|
||||
"components.Settings.sync": "",
|
||||
"components.Settings.syncing": "",
|
||||
"components.Setup.configureplex": "",
|
||||
"components.Setup.configureservices": "",
|
||||
"components.Setup.continue": "",
|
||||
"components.Setup.finish": "",
|
||||
"components.Setup.finishing": "",
|
||||
"components.Setup.loginwithplex": "",
|
||||
"components.Setup.signinMessage": "",
|
||||
"components.Setup.welcome": "",
|
||||
"components.Slider.noresults": "",
|
||||
"components.TitleCard.movie": "",
|
||||
"components.TitleCard.tvshow": "",
|
||||
"components.TvDetails.approve": "",
|
||||
"components.TvDetails.approverequests": "",
|
||||
"components.TvDetails.available": "",
|
||||
"components.TvDetails.cancelrequest": "",
|
||||
"components.TvDetails.cast": "",
|
||||
"components.TvDetails.decline": "",
|
||||
"components.TvDetails.declinerequests": "",
|
||||
"components.TvDetails.manageModalClearMedia": "",
|
||||
"components.TvDetails.manageModalClearMediaWarning": "",
|
||||
"components.TvDetails.manageModalNoRequests": "",
|
||||
"components.TvDetails.manageModalRequests": "",
|
||||
"components.TvDetails.manageModalTitle": "",
|
||||
"components.TvDetails.originallanguage": "",
|
||||
"components.TvDetails.overview": "",
|
||||
"components.TvDetails.overviewunavailable": "",
|
||||
"components.TvDetails.pending": "",
|
||||
"components.TvDetails.recommendations": "",
|
||||
"components.TvDetails.recommendationssubtext": "",
|
||||
"components.TvDetails.request": "",
|
||||
"components.TvDetails.requestmore": "",
|
||||
"components.TvDetails.similar": "",
|
||||
"components.TvDetails.similarsubtext": "",
|
||||
"components.TvDetails.status": "",
|
||||
"components.TvDetails.unavailable": "",
|
||||
"components.TvDetails.userrating": "",
|
||||
"components.UserEdit.admin": "",
|
||||
"components.UserEdit.adminDescription": "",
|
||||
"components.UserEdit.autoapprove": "",
|
||||
"components.UserEdit.autoapproveDescription": "",
|
||||
"components.UserEdit.avatar": "",
|
||||
"components.UserEdit.edituser": "",
|
||||
"components.UserEdit.email": "",
|
||||
"components.UserEdit.managerequests": "",
|
||||
"components.UserEdit.managerequestsDescription": "",
|
||||
"components.UserEdit.permissions": "",
|
||||
"components.UserEdit.request": "",
|
||||
"components.UserEdit.requestDescription": "",
|
||||
"components.UserEdit.save": "",
|
||||
"components.UserEdit.saving": "",
|
||||
"components.UserEdit.settings": "",
|
||||
"components.UserEdit.settingsDescription": "",
|
||||
"components.UserEdit.userfail": "",
|
||||
"components.UserEdit.username": "",
|
||||
"components.UserEdit.users": "",
|
||||
"components.UserEdit.usersDescription": "",
|
||||
"components.UserEdit.usersaved": "",
|
||||
"components.UserEdit.vote": "",
|
||||
"components.UserEdit.voteDescription": "",
|
||||
"components.UserList.admin": "",
|
||||
"components.UserList.created": "",
|
||||
"components.UserList.delete": "",
|
||||
"components.UserList.edit": "",
|
||||
"components.UserList.lastupdated": "",
|
||||
"components.UserList.plexuser": "",
|
||||
"components.UserList.role": "",
|
||||
"components.UserList.totalrequests": "",
|
||||
"components.UserList.user": "",
|
||||
"components.UserList.userlist": "",
|
||||
"components.UserList.username": "",
|
||||
"components.UserList.usertype": "",
|
||||
"i18n.approve": "",
|
||||
"i18n.approved": "",
|
||||
"i18n.available": "",
|
||||
"i18n.cancel": "",
|
||||
"i18n.decline": "",
|
||||
"i18n.declined": "",
|
||||
"i18n.delete": "",
|
||||
"i18n.movies": "",
|
||||
"i18n.partiallyavailable": "",
|
||||
"i18n.pending": "",
|
||||
"i18n.processing": "",
|
||||
"i18n.tvshows": "",
|
||||
"i18n.unavailable": "",
|
||||
"pages.internalServerError": "",
|
||||
"pages.oops": "",
|
||||
"pages.pageNotFound": "",
|
||||
"pages.returnHome": "",
|
||||
"pages.serviceUnavailable": "",
|
||||
"pages.somethingWentWrong": ""
|
||||
"components.RequestList.RequestItem.requestedby": "Verzocht door {username}",
|
||||
"components.RequestList.RequestItem.seasons": "Seizoenen",
|
||||
"components.RequestList.mediaInfo": "Media Info",
|
||||
"components.RequestList.modifiedBy": "Laatst Aangepast Door",
|
||||
"components.RequestList.next": "Volgende",
|
||||
"components.RequestList.previous": "Vorige",
|
||||
"components.RequestList.requestedAt": "Aangevraagd Om",
|
||||
"components.RequestList.requests": "Verzoeken",
|
||||
"components.RequestList.showingresults": "<strong>{from}</strong> tot <strong>{to}</strong> van de <strong>{total}</strong> resultaten worden weergegeven",
|
||||
"components.RequestList.status": "Status",
|
||||
"components.RequestModal.cancel": "Verzoek Annuleren",
|
||||
"components.RequestModal.cancelling": "Bezig met annuleren...",
|
||||
"components.RequestModal.cancelrequest": "Hiermee wordt je verzoek verwijderd. Weet je zeker dat je door wilt gaan?",
|
||||
"components.RequestModal.close": "Sluiten",
|
||||
"components.RequestModal.extras": "Extra",
|
||||
"components.RequestModal.notrequested": "Niet Verzocht",
|
||||
"components.RequestModal.numberofepisodes": "# Afleveringen",
|
||||
"components.RequestModal.pendingrequest": "Wachtend verzoek voor {title}",
|
||||
"components.RequestModal.request": "Verzoek",
|
||||
"components.RequestModal.requestCancel": "Verzoek voor <strong>{title}</strong> is geannuleerd",
|
||||
"components.RequestModal.requestSuccess": "<strong>{title}</strong> is verzocht.",
|
||||
"components.RequestModal.requestadmin": "Je verzoek zal onmiddelijk goedgekeurd worden.",
|
||||
"components.RequestModal.requestfrom": "Er is een afwachtend verzoek van {user}",
|
||||
"components.RequestModal.requesting": "Bezig met verzoeken…",
|
||||
"components.RequestModal.requestseasons": "Verzoek {seasonCount} {seasonCount, plural, één {Season} andere {Seasons}}",
|
||||
"components.RequestModal.requesttitle": "Verzoek {title}",
|
||||
"components.RequestModal.season": "Seizoen",
|
||||
"components.RequestModal.seasonnumber": "Seizoen {number}",
|
||||
"components.RequestModal.selectseason": "Selecteer seizoen(en)",
|
||||
"components.RequestModal.status": "Status",
|
||||
"components.Search.searchresults": "Zoekresultaten",
|
||||
"components.Settings.Notifications.agentenabled": "Agent Ingeschakeld",
|
||||
"components.Settings.Notifications.authPass": "Wachtwoord",
|
||||
"components.Settings.Notifications.authUser": "Gebruikersnaam",
|
||||
"components.Settings.Notifications.emailsender": "E-mailadres van afzender",
|
||||
"components.Settings.Notifications.enableSsl": "Schakel SSL in",
|
||||
"components.Settings.Notifications.save": "Wijzigingen Opslaan",
|
||||
"components.Settings.Notifications.saving": "Bezig met opslaan...",
|
||||
"components.Settings.Notifications.smtpHost": "SMTP Host",
|
||||
"components.Settings.Notifications.smtpPort": "SMTP Poort",
|
||||
"components.Settings.Notifications.validationFromRequired": "Je moet een afzenderadres opgeven",
|
||||
"components.Settings.Notifications.validationSmtpHostRequired": "Je moet een SMTP host opgeven",
|
||||
"components.Settings.Notifications.validationSmtpPortRequired": "Je moet een SMTP poort opgeven",
|
||||
"components.Settings.Notifications.validationWebhookUrlRequired": "Je moet een webhook URL opgeven",
|
||||
"components.Settings.Notifications.webhookUrl": "Webhook URL",
|
||||
"components.Settings.Notifications.webhookUrlPlaceholder": "Server instellingen -> Integraties -> Webhooks",
|
||||
"components.Settings.RadarrModal.add": "Server Toevoegen",
|
||||
"components.Settings.RadarrModal.apiKey": "API Key",
|
||||
"components.Settings.RadarrModal.apiKeyPlaceholder": "Je Radarr API Key",
|
||||
"components.Settings.RadarrModal.baseUrl": "Basis URL",
|
||||
"components.Settings.RadarrModal.baseUrlPlaceholder": "Voorbeeld: /radarr",
|
||||
"components.Settings.RadarrModal.createradarr": "Maak Een Nieuwe Radarr Server Aan",
|
||||
"components.Settings.RadarrModal.defaultserver": "Standaard Server",
|
||||
"components.Settings.RadarrModal.editradarr": "Wijzig Radarr Server",
|
||||
"components.Settings.RadarrModal.hostname": "Hostnaam",
|
||||
"components.Settings.RadarrModal.minimumAvailability": "Minimale Beschikbaarheid",
|
||||
"components.Settings.RadarrModal.port": "Poort",
|
||||
"components.Settings.RadarrModal.qualityprofile": "Kwaliteitsprofiel",
|
||||
"components.Settings.RadarrModal.rootfolder": "Hoofdmap",
|
||||
"components.Settings.RadarrModal.save": "Wijzigingen Opslaan",
|
||||
"components.Settings.RadarrModal.saving": "Bezig met opslaan...",
|
||||
"components.Settings.RadarrModal.selectMinimumAvailability": "Selecteer minimale beschikbaarheid",
|
||||
"components.Settings.RadarrModal.selectQualityProfile": "Selecteer een kwaliteitsprofiel",
|
||||
"components.Settings.RadarrModal.selectRootFolder": "Selecteer een Hoofdmap",
|
||||
"components.Settings.RadarrModal.server4k": "4K Server",
|
||||
"components.Settings.RadarrModal.servername": "Server Naam",
|
||||
"components.Settings.RadarrModal.servernamePlaceholder": "Een Radarr Server",
|
||||
"components.Settings.RadarrModal.ssl": "SSL",
|
||||
"components.Settings.RadarrModal.test": "Test",
|
||||
"components.Settings.RadarrModal.testing": "Bezig met testen...",
|
||||
"components.Settings.RadarrModal.toastRadarrTestFailure": "Kon niet verbinden met de Radarr Server",
|
||||
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Verbonden met de Radarr server!",
|
||||
"components.Settings.RadarrModal.validationApiKeyRequired": "Je moet een API key opgeven",
|
||||
"components.Settings.RadarrModal.validationHostnameRequired": "Je moet een hostnaam/IP opgeven",
|
||||
"components.Settings.RadarrModal.validationPortRequired": "Je moet een poort opgeven",
|
||||
"components.Settings.RadarrModal.validationProfileRequired": "Je moet een profiel selecteren",
|
||||
"components.Settings.RadarrModal.validationRootFolderRequired": "Je moet een hoofdmap selecteren",
|
||||
"components.Settings.SonarrModal.add": "Server Toevoegen",
|
||||
"components.Settings.SonarrModal.apiKey": "API Key",
|
||||
"components.Settings.SonarrModal.apiKeyPlaceholder": "Je Sonarr API Key",
|
||||
"components.Settings.SonarrModal.baseUrl": "Basis URL",
|
||||
"components.Settings.SonarrModal.baseUrlPlaceholder": "Voorbeeld: /sonarr",
|
||||
"components.Settings.SonarrModal.createsonarr": "Maak Nieuwe Sonar Server Aan",
|
||||
"components.Settings.SonarrModal.defaultserver": "Standaard Server",
|
||||
"components.Settings.SonarrModal.editsonarr": "Wijzig Sonarr Server",
|
||||
"components.Settings.SonarrModal.hostname": "Hostnaam",
|
||||
"components.Settings.SonarrModal.port": "Poort",
|
||||
"components.Settings.SonarrModal.qualityprofile": "Kwaliteitsprofiel",
|
||||
"components.Settings.SonarrModal.rootfolder": "Basisfolder",
|
||||
"components.Settings.SonarrModal.save": "Wijzigingen Opslaan",
|
||||
"components.Settings.SonarrModal.saving": "Bezig met opslaan...",
|
||||
"components.Settings.SonarrModal.seasonfolders": "Seizoen Folders",
|
||||
"components.Settings.SonarrModal.selectQualityProfile": "Selecteer een Kwaliteitsprofiel",
|
||||
"components.Settings.SonarrModal.selectRootFolder": "Selecteer een Basisfolder",
|
||||
"components.Settings.SonarrModal.server4k": "4K Server",
|
||||
"components.Settings.SonarrModal.servername": "Sever Naam",
|
||||
"components.Settings.SonarrModal.servernamePlaceholder": "Een Sonarr Server",
|
||||
"components.Settings.SonarrModal.ssl": "SSL",
|
||||
"components.Settings.SonarrModal.test": "Test",
|
||||
"components.Settings.SonarrModal.testing": "Bezig met testen...",
|
||||
"components.Settings.SonarrModal.toastRadarrTestFailure": "Kon niet verbinden met de Sonarr Server",
|
||||
"components.Settings.SonarrModal.toastRadarrTestSuccess": "Verbonden met de Sonarr server!",
|
||||
"components.Settings.SonarrModal.validationApiKeyRequired": "Je moet een API key opgeven",
|
||||
"components.Settings.SonarrModal.validationHostnameRequired": "Je moet een hostnaam/IP opgeven",
|
||||
"components.Settings.SonarrModal.validationPortRequired": "Je moet een poort opgeven",
|
||||
"components.Settings.SonarrModal.validationProfileRequired": "Je moet een profiel selecteren",
|
||||
"components.Settings.SonarrModal.validationRootFolderRequired": "Je moet een hoofdfolder selecteren",
|
||||
"components.Settings.activeProfile": "Actief profiel",
|
||||
"components.Settings.addradarr": "Radarr Server Toevoegen",
|
||||
"components.Settings.address": "Adres",
|
||||
"components.Settings.addsonarr": "Sonarr Server Toevoegen",
|
||||
"components.Settings.apikey": "API Key",
|
||||
"components.Settings.applicationurl": "Applicatie URL",
|
||||
"components.Settings.cancelscan": "Scan Annuleren",
|
||||
"components.Settings.copied": "API key gekopieerd naar het klembord",
|
||||
"components.Settings.currentlibrary": "Huidige Bibliotheek: {name}",
|
||||
"components.Settings.default": "Standaard",
|
||||
"components.Settings.default4k": "Standaard 4K",
|
||||
"components.Settings.delete": "Verwijderen",
|
||||
"components.Settings.deleteserverconfirm": "Weet je zeker dat je deze server wil verwijderen?",
|
||||
"components.Settings.edit": "Wijzig",
|
||||
"components.Settings.generalsettings": "Algemene Instellingen",
|
||||
"components.Settings.generalsettingsDescription": "Deze instellingen zijn gerelateerd aan de algemene Overseer configuratie.",
|
||||
"components.Settings.hostname": "Hostnaam/IP",
|
||||
"components.Settings.jobname": "Taaknaam",
|
||||
"components.Settings.librariesRemaining": "Resterende Bibliotheken: {count}",
|
||||
"components.Settings.manualscan": "Handmatige Bibliotheek Scan",
|
||||
"components.Settings.manualscanDescription": "Normaal gesproken wordt dit eens elke 6 uur gedraaid. Overseerr controleert de recentelijk toegoevoegde items van je Plex Server aggresiever. Als dit je eerst keer is om Plex te configuren raden wij aan om eenmaal een volledige bibliotheek scan handmatig te draaien!",
|
||||
"components.Settings.menuAbout": "Over",
|
||||
"components.Settings.menuGeneralSettings": "Algemene Instellingen",
|
||||
"components.Settings.menuJobs": "Taken",
|
||||
"components.Settings.menuLogs": "Logboeken",
|
||||
"components.Settings.menuNotifications": "Notificaties",
|
||||
"components.Settings.menuPlexSettings": "Plex",
|
||||
"components.Settings.menuServices": "Diensten",
|
||||
"components.Settings.nextexecution": "Volgende Uitvoering",
|
||||
"components.Settings.notificationsettings": "Notificatie Instellingen",
|
||||
"components.Settings.notificationsettingsDescription": "Hier kun je kiezen welke soort notificaties er worden verstuurd en door welke type diensten.",
|
||||
"components.Settings.notrunning": "Draait niet",
|
||||
"components.Settings.plexlibraries": "Plex Bibliotheken",
|
||||
"components.Settings.plexlibrariesDescription": "The bibliotheken die Overseerr scanned voor titels. Stel je Plex connectie instellingen in en sla ze op. Klik op de onderstaande knop als er geen connecties staan.",
|
||||
"components.Settings.plexsettings": "Plex Instellingen",
|
||||
"components.Settings.plexsettingsDescription": "Configureer de instellingen voor je Plex server. Overseerr gebruikt je Plex server om je bibliotheek met een interval te scannen om te zien welke content beschikbaar is.",
|
||||
"components.Settings.port": "Poort",
|
||||
"components.Settings.radarrSettingsDescription": "Stel hier onder je Radarr connectie in. Je kan er meerdere hebben, maar slechts twee actief hebben als standaard (één voor standaard HD, en één voor 4K). Beheerders kunnen bepalen welke server gebruikt wordt voor nieuwe verzoeken.",
|
||||
"components.Settings.radarrsettings": "Radarr Instellingen",
|
||||
"components.Settings.runnow": "Nu Starten",
|
||||
"components.Settings.save": "Wijzigingen Opslaan",
|
||||
"components.Settings.saving": "Bezig met opslaan...",
|
||||
"components.Settings.servername": "Server Naam (Automatisch Ingesteld na opslaan)",
|
||||
"components.Settings.servernamePlaceholder": "Plex Server Naam",
|
||||
"components.Settings.sonarrSettingsDescription": "Stel hier onder je Sonarr connectie in. Je kan er meerdere hebben, maar slechts twee actief hebben als standaard (één voor standaard HD, en één voor 4K). Beheerders kunnen bepalen welke server gebruikt wordt voor nieuwe verzoeken.",
|
||||
"components.Settings.sonarrsettings": "Sonarr Instellingen",
|
||||
"components.Settings.ssl": "SSL",
|
||||
"components.Settings.startscan": "Scan Starten",
|
||||
"components.Settings.sync": "Sync Plex Bibliotheken",
|
||||
"components.Settings.syncing": "Bezig met synchronizeren…",
|
||||
"components.Setup.configureplex": "Configureer Plex",
|
||||
"components.Setup.configureservices": "Configureer Diensten",
|
||||
"components.Setup.continue": "Doorgaan",
|
||||
"components.Setup.finish": "Installatie Voltooien",
|
||||
"components.Setup.finishing": "Bezig met voltooien...",
|
||||
"components.Setup.loginwithplex": "Login met Plex",
|
||||
"components.Setup.signinMessage": "Ga aan de slag door in te loggen met je Plex account",
|
||||
"components.Setup.welcome": "Welcome bij Overseerr",
|
||||
"components.Slider.noresults": "Geen Resultaten",
|
||||
"components.TitleCard.movie": "Film",
|
||||
"components.TitleCard.tvshow": "Series",
|
||||
"components.TvDetails.approve": "Goedkeuren",
|
||||
"components.TvDetails.approverequests": "Keur {requestCount} {requestCount, plural, één {Request} meerdere {Requests}} goed",
|
||||
"components.TvDetails.available": "Beschikbaar",
|
||||
"components.TvDetails.cancelrequest": "Verzoek Annuleren",
|
||||
"components.TvDetails.cast": "Cast",
|
||||
"components.TvDetails.decline": "Weigeren",
|
||||
"components.TvDetails.declinerequests": "Weiger {requestCount} {requestCount, plural, één {Request} meerdere {Requests}}",
|
||||
"components.TvDetails.manageModalClearMedia": "Wis Alle Media Data",
|
||||
"components.TvDetails.manageModalClearMediaWarning": "Dit wist alle media data inclusief alle verzoeken voor dit item zonder herstelmogelijkheden. Als dit item in je Plex bibliotheek bestaat zal alle media info bij de volgende sync hersteld worden.",
|
||||
"components.TvDetails.manageModalNoRequests": "Geen Verzoeken",
|
||||
"components.TvDetails.manageModalRequests": "Verzoeken",
|
||||
"components.TvDetails.manageModalTitle": "Beheer Series",
|
||||
"components.TvDetails.originallanguage": "Originele Taal",
|
||||
"components.TvDetails.overview": "Overzicht",
|
||||
"components.TvDetails.overviewunavailable": "Overzicht onbeschikbaar",
|
||||
"components.TvDetails.pending": "Wachtende",
|
||||
"components.TvDetails.recommendations": "Aanbevelingen",
|
||||
"components.TvDetails.recommendationssubtext": "Als je {title} leuk vond, vind je dit misschien ook leuk...",
|
||||
"components.TvDetails.request": "Verzoek",
|
||||
"components.TvDetails.requestmore": "Verzoek Meer",
|
||||
"components.TvDetails.similar": "Soortgelijke Series",
|
||||
"components.TvDetails.similarsubtext": "Andere series gelijk aan {title}",
|
||||
"components.TvDetails.status": "Status",
|
||||
"components.TvDetails.unavailable": "Onbeschikbaar",
|
||||
"components.TvDetails.userrating": "Gebruikersbeoordeling",
|
||||
"components.UserEdit.admin": "Beheerder",
|
||||
"components.UserEdit.adminDescription": "Volledige beheerderstoegang. Omzeilt alle toestemmingscontroles.",
|
||||
"components.UserEdit.autoapprove": "Automatisch Goedkeuren",
|
||||
"components.UserEdit.autoapproveDescription": "Geeft automatische goedkeuring voor alle verzoeken van deze gebruiker.",
|
||||
"components.UserEdit.avatar": "Avatar",
|
||||
"components.UserEdit.edituser": "Wijzig Gebruiker",
|
||||
"components.UserEdit.email": "E-mail",
|
||||
"components.UserEdit.managerequests": "Beheer Verzoeken",
|
||||
"components.UserEdit.managerequestsDescription": "Geeft toestemming om Overseerr-verzoeken te beheren. Inclusief het goedkeuren en weigeren van verzoeken.",
|
||||
"components.UserEdit.permissions": "Rechten",
|
||||
"components.UserEdit.request": "Verzoek",
|
||||
"components.UserEdit.requestDescription": "Geeft toestemming om films en series aan te vragen.",
|
||||
"components.UserEdit.save": "Sla op",
|
||||
"components.UserEdit.saving": "Bezig met opslaan...",
|
||||
"components.UserEdit.settings": "Instellingen Beheren",
|
||||
"components.UserEdit.settingsDescription": "Geeft toestemming om alle Overseerr-instellingen te wijzigen. Een gebruiker moet deze toestemming hebben om deze aan anderen te verlenen.",
|
||||
"components.UserEdit.userfail": "Er ging iets verkeerd bij het opslaan van de gebruiker.",
|
||||
"components.UserEdit.username": "Gebruikersnaam",
|
||||
"components.UserEdit.users": "Beheer Gebruikers",
|
||||
"components.UserEdit.usersDescription": "Geeft toestemming om Overseerr-gebruikers te beheren. Gebruikers met deze toestemming kunnen gebruikers met beheerdersrechten niet wijzigen of deze toestemming geven.",
|
||||
"components.UserEdit.usersaved": "Gebruiker opgeslagen",
|
||||
"components.UserEdit.vote": "Stem",
|
||||
"components.UserEdit.voteDescription": "Geeft toestemming om te stemmen op verzoeken (stemmen is nog niet geïmplementeerd)",
|
||||
"components.UserList.admin": "Beheerder",
|
||||
"components.UserList.created": "Gemaakt",
|
||||
"components.UserList.delete": "Verwijderen",
|
||||
"components.UserList.edit": "Wijzig",
|
||||
"components.UserList.lastupdated": "Laatst Aangepast",
|
||||
"components.UserList.plexuser": "Plex Gebruiker",
|
||||
"components.UserList.role": "Rol",
|
||||
"components.UserList.totalrequests": "Totale Verzoeken",
|
||||
"components.UserList.user": "Gebruiker",
|
||||
"components.UserList.userlist": "Gebruikerslijst",
|
||||
"components.UserList.username": "Gebruikersnaam",
|
||||
"components.UserList.usertype": "Gebruikerstype",
|
||||
"i18n.approve": "Goedkeuren",
|
||||
"i18n.approved": "Goedgekeurd",
|
||||
"i18n.available": "Beschikbaar",
|
||||
"i18n.cancel": "Annuleer",
|
||||
"i18n.decline": "Weigeren",
|
||||
"i18n.declined": "Geweigerd",
|
||||
"i18n.delete": "Verwijder",
|
||||
"i18n.movies": "Films",
|
||||
"i18n.partiallyavailable": "Gedeeltelijk Beschikbaar",
|
||||
"i18n.pending": "Afwachtende",
|
||||
"i18n.processing": "Bezig met verwerken…",
|
||||
"i18n.tvshows": "Series",
|
||||
"i18n.unavailable": "Onbeschikbaar",
|
||||
"pages.internalServerError": "{statusCode} - Interne Server Error",
|
||||
"pages.oops": "Oeps",
|
||||
"pages.pageNotFound": "404 - Pagina Niet Gevonden",
|
||||
"pages.returnHome": "Terug naar de Home pagina",
|
||||
"pages.serviceUnavailable": "{statusCode} - Service Onbeschikbaar",
|
||||
"pages.somethingWentWrong": "{statusCode} - Er is iets verkeerd gegaan",
|
||||
"components.MovieDetails.MovieCast.fullcast": "Volledige Cast",
|
||||
"components.TvDetails.TvCast.fullseriescast": "Volledige Cast van de Series",
|
||||
"components.Settings.Notifications.emailsettingssaved": "Instellingen voor e-mailmeldingen opgeslagen!",
|
||||
"components.Settings.Notifications.emailsettingsfailed": "Instellingen voor e-mailmeldingen zijn niet opgeslagen.",
|
||||
"components.Settings.Notifications.discordsettingssaved": "Discord notificatie instellingen zijn opgeslagen!",
|
||||
"components.Settings.Notifications.discordsettingsfailed": "Discord notificatie instellingen konden niet opgeslagen worden.",
|
||||
"components.Settings.validationPortRequired": "Je moet een poort opgeven",
|
||||
"components.Settings.validationHostnameRequired": "Je moet een hostnaam/IP opgeven",
|
||||
"components.Settings.SonarrModal.validationNameRequired": "Je moet een servernaam opgeven",
|
||||
"components.Settings.RadarrModal.validationNameRequired": "Je moet een servernaam opgeven"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IntlProvider } from 'react-intl';
|
||||
import { LanguageContext, AvailableLocales } from '../context/LanguageContext';
|
||||
import Head from 'next/head';
|
||||
import Toast from '../components/Toast';
|
||||
import { InteractionProvider } from '../context/InteractionContext';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const loadLocaleData = (locale: string): Promise<any> => {
|
||||
@@ -28,6 +29,8 @@ const loadLocaleData = (locale: string): Promise<any> => {
|
||||
return import('../i18n/locale/ru.json');
|
||||
case 'nl':
|
||||
return import('../i18n/locale/nl.json');
|
||||
case 'es':
|
||||
return import('../i18n/locale/es.json');
|
||||
default:
|
||||
return import('../i18n/locale/en.json');
|
||||
}
|
||||
@@ -88,12 +91,14 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
|
||||
defaultLocale="en"
|
||||
messages={loadedMessages}
|
||||
>
|
||||
<ToastProvider components={{ Toast }}>
|
||||
<Head>
|
||||
<title>Overseerr</title>
|
||||
</Head>
|
||||
<UserContext initialUser={user}>{component}</UserContext>
|
||||
</ToastProvider>
|
||||
<InteractionProvider>
|
||||
<ToastProvider components={{ Toast }}>
|
||||
<Head>
|
||||
<title>Overseerr</title>
|
||||
</Head>
|
||||
<UserContext initialUser={user}>{component}</UserContext>
|
||||
</ToastProvider>
|
||||
</InteractionProvider>
|
||||
</IntlProvider>
|
||||
</LanguageContext.Provider>
|
||||
</SWRConfig>
|
||||
|
||||
17
src/pages/settings/about.tsx
Normal file
17
src/pages/settings/about.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextPage } from 'next';
|
||||
import React from 'react';
|
||||
import SettingsAbout from '../../components/Settings/SettingsAbout';
|
||||
import SettingsLayout from '../../components/Settings/SettingsLayout';
|
||||
import useRouteGuard from '../../hooks/useRouteGuard';
|
||||
import { Permission } from '../../hooks/useUser';
|
||||
|
||||
const SettingsAboutPage: NextPage = () => {
|
||||
useRouteGuard(Permission.MANAGE_SETTINGS);
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<SettingsAbout />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAboutPage;
|
||||
14
src/pages/settings/logs.tsx
Normal file
14
src/pages/settings/logs.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextPage } from 'next';
|
||||
import React from 'react';
|
||||
import SettingsLayout from '../../components/Settings/SettingsLayout';
|
||||
import SettingsLogs from '../../components/Settings/SettingsLogs';
|
||||
|
||||
const SettingsLogsPage: NextPage = () => {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<SettingsLogs />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsLogsPage;
|
||||
@@ -7,7 +7,7 @@ body {
|
||||
}
|
||||
|
||||
.plex-button {
|
||||
@apply w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 transition ease-in-out duration-150 text-center;
|
||||
@apply w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 transition ease-in-out duration-150 text-center disabled:opacity-50;
|
||||
background-color: #cc7b19;
|
||||
}
|
||||
|
||||
@@ -47,3 +47,7 @@ body {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
code {
|
||||
@apply bg-gray-800 py-1 px-2 rounded-md;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user