Compare commits

...

273 Commits

Author SHA1 Message Date
semantic-release-bot
24151d27f7 chore(release): 1.5.0 2023-04-20 02:05:25 +00:00
Fallenbagel
f3cc8cba0a Merge pull request #368 from Fallenbagel/develop
Merge 'develop' into main
2023-04-20 07:02:36 +05:00
Fallenbagel
53f6a890b9 Merge pull request #356 from GeoffreyCoulaud/geoffrey-fix
Fixed french locale: using "de " instead of "d'" before Jellyseerr
2023-03-25 16:43:51 +05:00
GeoffreyCoulaud
7dbe6f61d0 Fixed d' before Jellyseerr 2023-03-25 00:49:59 +01:00
Fallenbagel
fd460df243 Merge pull request #348 from Fallenbagel/temp-disable-media-availability
fix: disable availability sync temporarily
2023-03-18 04:26:14 +05:00
Fallenbagel
2e5cf22626 fix: disable availability sync temporarily
This PR disables availability sync temporarily as the current one does not have jellyfin/emby sync
ported into it. This would ensure that jellyseerr does not bug out and start removing availability
from media despite it being available on jellyfin/emby/*arr as well as their requests.
2023-03-18 04:08:54 +05:00
Fallenbagel
092d639dd9 Merge pull request #347 from Fallenbagel/temp-fix-display-specials-within-season
fix(jellyfin sync): temporary workaround fix for jellyfin scan when display specials within season
2023-03-18 03:43:06 +05:00
Fallenbagel
fc1f3202e8 Merge pull request #338 from Fallenbagel/fix-4k-detection-series
fixes 4k detection of series
2023-03-18 03:42:48 +05:00
Fallenbagel
3bf04f2abd refactor: refactored 4k detection fix to be more consistent with how movie logic works 2023-03-17 04:32:27 +05:00
Fallenbagel
38fb66d31e fix(jellyfin scan): temporary workaround fix for jellyfin scan when display specials within season
Currently when display specials within season is enabled, it increases the indexed episode count of
the season. This is a problem due to the way our jellyfin sync works as it requires total standard
episodes to be equal to season episode count for the `AVAILABLE` badge for that season. However,
when the display specials within season is enabled, the scan sets that season as `PARTIALLY
AVAILABLE`. This workaround fixes this behaviour. In addition, this fix **might** also fix the
recurring availability notifications (recurring notifications might be occurring due to the scan
initially setting the season as available, thus the series is set as available and sends the
notification, but then it sets the season as partially available due to the aforementioned sync flow
until next scan and repeats.)

fix #215 #176 #246
2023-03-17 04:19:16 +05:00
Fallenbagel
8b3801539e Merge pull request #321 from andrey4korop/Lang_changes
Update Ukrainian language
2023-03-09 03:43:13 +05:00
Fallenbagel
101ffae641 style: ran prettier on ua locale 2023-03-04 01:17:26 +05:00
Fallenbagel
bc9017f54d fix: add better checks on 4k detection of series 2023-03-04 00:54:21 +05:00
Fallenbagel
b90dedfafc fix merge conflict lines which were present during last merge 2023-03-02 19:32:34 +05:00
Fallenbagel
ee23de6d2f Merge remote-tracking branch 'upstream/develop' into develop 2023-02-28 03:28:36 +05:00
allcontributors[bot]
04980f93ab docs: add jariz as a contributor for code (#3357) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-02-24 19:47:36 +09:00
allcontributors[bot]
2a3213d706 docs: add Nimelrian as a contributor for code (#3356) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-02-24 19:46:46 +09:00
Brandon Cohen
c36a4ba2b8 fix: logger was set to info for the wrong logs (#3354) 2023-02-24 07:29:42 +00:00
Brandon Cohen
ae3818304b feat: availability sync rework (#3219)
* feat: add availability synchronization job

fix #377

* fix: feedback on PR

* perf: use pagination for Media Availability Synchronization job

The original approach loaded all media items from the database at once. With large libraries, this
could lead to performance issues. We're now using a paginated approach with a page size of 50.

* feat: updated the availability sync to work with 4k

* fix: corrected detection of media in plex

* refactor: code cleanup and minimized unnecessary calls

* fix: if media is not found, media check will continue

* fix: if non-4k or 4k show media is updated, seasons and request is now properly updated

* refactor: consolidated media updater into one function

* fix: season requests are now removed if season has been deleted

* refactor: removed joincolumn

* fix: makes sure we will always check radarr/sonarr to see if media exists

* fix: media will now only be updated to unavailable and deletion will be prevented

* fix: changed types in Media entity

* fix: prevent season deletion in preference of setting season to unknown

---------

Co-authored-by: Jari Zwarts <jari@oberon.nl>
Co-authored-by: Sebastian Kappen <sebastian@kappen.dev>
2023-02-24 05:28:22 +00:00
Danshil Kokil Mungur
b3882de893 fix(ui): hide search bar behind slideover when opened (#3348) 2023-02-24 02:03:01 +00:00
Danshil Kokil Mungur
af880a6c83 fix(watchlist): correctly load more than 20 watchlist items (#3351)
* fix(discover): correctly load additional watchlist items pages

* chore(discover): remove unused params types
2023-02-24 00:40:01 +00:00
Danshil Kokil Mungur
eb5502a16f fix(ui): prevent title cards from flickering when quickly hovering across them (#3349)
* fix(ui): remove opacity classes from transition enter and leave props

The flickering was caused by the opacity classes in the `leave` prop to take effect as the
transition ends; when the `leaveTo` prop classes are no longer applied, but the `leave` prop classes
are still applied.

* fix(ui): resolve transition issues for all components

1. Remove opacity classes from `enter` and `leave` props
2. Fix some class name typos
3. Remove transform classes since those are automatically applied as from TailwindCSS v3.0
4. Narrow down `transition` classes to only the properties being transitioned in Transition components
2023-02-24 00:27:26 +09:00
Danshil Kokil Mungur
50f06dabbf fix(ui): hide mini status badge if non-4K media status is unknown (#3346) 2023-02-20 10:54:06 +09:00
Fallenbagel
ddbc377d79 Merge pull request #182 from dd060606/features/deleteMediaFile
feat: button to remove a movie from Radarr/Sonarr
2023-02-19 02:59:28 +05:00
Brandon Cohen
1e2c6f46ab fix: added a refresh interval if download status is in progress (#3275)
* fix: added a refresh interval if download status is in progress

* refactor: switched to a function instead of useEffect

* feat: added editable download sync schedule
2023-02-15 19:16:13 +04:00
Owen Voke
dd1378cef5 chore(api): update descriptions for API endpoints (#3341) 2023-02-15 00:40:51 +04:00
allcontributors[bot]
e684456bba docs: add owenvoke as a contributor for code (#3340) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-02-14 14:53:35 +09:00
Owen Voke
6bd3f015d6 fix: remove unnecessary parenthesis from api key generation (#3336) 2023-02-14 14:52:26 +09:00
dd060606
7bd4c4d1d4 Merge branch 'develop' into features/deleteMediaFile 2023-02-13 08:58:53 +01:00
dd060606
3005e577d7 style(src/components/manageslideover/index.tsx): fix code style issues 2023-02-13 08:33:23 +01:00
allcontributors[bot]
2d97be0d6c docs: add lunks as a contributor for code (#3334) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-02-12 20:05:59 +09:00
Pedro Nascimento
966639df43 perf(imageproxy): do not set cookies to image proxy so CDNs can cache images (#3332)
CDNs such as Cloudflare bypass their cache if cookies are set in the response.
clearCookies
middleware removes the header before imageproxy serves the image.
2023-02-12 20:05:29 +09:00
Brandon Cohen
33e7691b94 feat: full title of download item on hover with tooltip (#3296)
Co-authored-by: Ryan Cohen <ryan@sct.dev>
2023-02-11 10:30:53 +00:00
Danshil Kokil Mungur
d7b83d22ce fix(build): increase threshold for amount of data to be fetched when SSR'ing (#3320)
Co-authored-by: Ryan Cohen <ryan@sct.dev>
2023-02-11 09:36:31 +00:00
Ryan Cohen
b6eac0f364 test: change custom keyword for slider creation (#3333) 2023-02-11 17:18:30 +09:00
Fallenbagel
572a7db4aa Merge pull request #297 from DimitriDR/develop
French I18n update
2023-02-04 03:27:06 +05:00
andrey
862cd2d6ac Update Ukrainian language 2023-02-02 19:04:29 +02:00
Fallenbagel
6f23abaa6d Merge pull request #298 from andrey4korop/develop
Add Ukrainian language
2023-02-01 21:15:23 +05:00
andrey
81518df89a Merge remote-tracking branch 'o/develop' into develop 2023-02-01 16:20:04 +02:00
Fallenbagel
604335a16d ci(release): fix previous merge that changed discord notification run-on to self-hosted [skip ci] 2023-02-01 08:52:28 +05:00
semantic-release-bot
57e7d68092 chore(release): 1.4.1 2023-01-31 00:20:50 +00:00
Fallenbagel
d3622f7bb3 Merge pull request #316 from Fallenbagel/develop
Merge develop into main
2023-01-31 05:15:41 +05:00
Fallenbagel
78ccea94bd Merge pull request #318 from Fallenbagel/CHANGELOG-ignore-prettier
chore(prettier): ignore CHANGELOG by prettier
2023-01-31 03:50:53 +05:00
Fallenbagel
a487ab4506 chore(prettier): ignore CHANGELOG by prettier
When changelog is generated by semantic bot, it keeps failing the prettier format check. So this
ignores it
2023-01-31 03:40:47 +05:00
Ryan Cohen
c93467b3ac fix(snapcraft): use the correct config folder for image cache (#3302) 2023-01-31 01:31:39 +09:00
Fallenbagel
c709e8596a Merge pull request #314 from Fallenbagel/upstream-fix
Merge upstream hotfix
2023-01-30 04:41:37 +05:00
Fallenbagel
26e49e73a5 Merge remote-tracking branch 'upstream/develop' into develop 2023-01-30 04:27:34 +05:00
semantic-release-bot
20c821e2eb chore(release): 1.4.0 2023-01-29 20:33:10 +00:00
Fallenbagel
7b82ced5e6 Merge pull request #312 from Fallenbagel/develop
Merge 'origin/develop' into main
2023-01-30 01:31:00 +05:00
Ryan Cohen
d954328911 fix(ui): correct range slider styling in chrome (#3299) 2023-01-29 20:32:25 +09:00
Brandon Cohen
3e43586acc fix(ui): air date will use UTC for timezone (#3297) 2023-01-29 16:43:54 +09:00
Ryan Cohen
7040da1334 fix(ui): show 5 icons when possible on mobile menu (#3298) 2023-01-29 16:27:09 +09:00
Ryan Cohen
9d10e6a88c fix(ui): style range thumbs correctly for firefox (#3294) 2023-01-29 13:27:33 +09:00
Fallenbagel
4c22a71cdf Merge pull request #311 from Fallenbagel/upstream
Merge 'upstream/develop' into develop
2023-01-28 05:22:42 +05:00
Fallenbagel
b9e7933e09 docs(readme): revert to original README 2023-01-28 05:04:33 +05:00
Fallenbagel
2508b4340f refactor: updated the heroicons used in jellyfin settings/email warning 2023-01-28 04:44:46 +05:00
Fallenbagel
e1f67ad8ba Merge 'overseerr/develop' into develop 2023-01-28 03:40:12 +05:00
Fallenbagel
91abdb2ba5 Remove Zone.Identfier [skip ci] 2023-01-28 03:33:22 +05:00
Brandon Cohen
8942eb8b7c fix: pass in library type when scanning recently added items (#3287) 2023-01-27 21:53:46 +09:00
notfakie
ad3d922440 Merge remote-tracking branch 'overseerr/develop' into develop 2023-01-27 17:55:55 +13:00
Danshil Kokil Mungur
51b05cd8fb fix(build): update usage of publish snap action (#3272)
* fix(build): use env variable to login with snapcraft 7

* refactor(build): replace deprecated set-output command in GHA

* fix(build): use correct environment variable for output

* style(build): run prettier
2023-01-27 09:48:40 +09:00
Brandon Cohen
374c78c989 fix(ui): series first air date sorting (#3283) 2023-01-26 22:22:38 +00:00
Brandon Cohen
507693881b fix: multiple genre filtering now works (#3282) 2023-01-26 14:10:02 -08:00
Ryan Cohen
f4a22dc437 fix: correctly check mobile menu permissions (#3271) 2023-01-25 18:49:01 +09:00
Ryan Cohen
5d1c6f7065 fix: create shared class to add bottom spacing (#3269) 2023-01-24 19:30:38 +09:00
Ryan Cohen
3db010b9ea fix: correct issue detail bottom padding on mobile displays (#3268) 2023-01-24 14:54:58 +09:00
TheCatLady
fd219717c0 fix: issues with issues (#3267)
* fix: issues with issues

* fix: don't notify on user closing/reopening own issue

* fix: only show close/reopen buttons for OP and admins
2023-01-24 02:58:56 +00:00
Brandon Cohen
d328485161 fix: arrow icons were misplaced on mobile in slider edit (#3260) 2023-01-20 14:46:29 +00:00
Brandon Cohen
da00d454e1 feat: discover slider edit arrow buttons for reordering (#3259) 2023-01-20 06:20:05 +00:00
Danshil Kokil Mungur
d7bfc73727 docs(installation): add PORT env variable to example commands (#3254) [skip ci]
* docs(installation): add PORT env variable to Docker CLI & Docker Compose examples

* docs(installation): fix typo

* docs(installation): clarify hint about named volumes for windows installation example
2023-01-20 02:39:24 +09:00
Weblate (bot)
5940ff7f5f feat: translations update from Hosted Weblate (#3218)
* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.5% (1217 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.4% (1215 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.4% (1215 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.2% (1213 of 1222 strings)

feat(lang): translated using Weblate (French)

Currently translated at 98.6% (1203 of 1219 strings)

feat(lang): translated using Weblate (French)

Currently translated at 92.6% (1130 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (French)

Currently translated at 96.0% (1132 of 1179 strings)

feat(lang): translated using Weblate (French)

Currently translated at 95.9% (1131 of 1179 strings)

Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Uruk <uruknarb20@gmail.com>
Co-authored-by: slundi <slundi@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Spanish)

Currently translated at 85.5% (1045 of 1222 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Angel <adelpozoman@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Polish)

Currently translated at 100.0% (1222 of 1222 strings)

feat(lang): translated using Weblate (Polish)

Currently translated at 93.6% (1144 of 1222 strings)

feat(lang): translated using Weblate (Polish)

Currently translated at 90.6% (1105 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Patryk <byakurau1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pl/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1219 of 1219 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 96.9% (1182 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1179 of 1179 strings)

Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/da/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 94.0% (1105 of 1175 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hans/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1219 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Dutch)

Currently translated at 95.5% (1126 of 1179 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 93.8% (1107 of 1179 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (German)

Currently translated at 94.6% (1154 of 1219 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1175 of 1175 strings)

Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Norwegian Bokmål)

Currently translated at 94.7% (1113 of 1175 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: exentler <gurandsrud@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nb_NO/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Hindi)

Currently translated at 11.8% (145 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 9.4% (115 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 9.1% (112 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 9.1% (112 of 1222 strings)

feat(lang): translated using Weblate (Hindi)

Currently translated at 2.7% (33 of 1222 strings)

Co-authored-by: Gaurang Goel <gaurang7goel+hosted@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Ishan Jindal <jindal25ishan@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hi/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Albanian)

Currently translated at 97.1% (1142 of 1175 strings)

Co-authored-by: Denis Çerri <deniscerri3@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sq/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Mathieu <math_du_88@yahoo.fr>
Co-authored-by: Sulli <susu.leduc@gmail.com>
Co-authored-by: Uruk <uruknarb20@gmail.com>
Co-authored-by: slundi <slundi@gmail.com>
Co-authored-by: Angel <adelpozoman@gmail.com>
Co-authored-by: Patryk <byakurau1@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Ben <ben.david.wallner@gmail.com>
Co-authored-by: exentler <gurandsrud@gmail.com>
Co-authored-by: Gaurang Goel <gaurang7goel+hosted@gmail.com>
Co-authored-by: Ishan Jindal <jindal25ishan@gmail.com>
Co-authored-by: Denis Çerri <deniscerri3@gmail.com>
2023-01-19 21:53:44 +09:00
Ryan Cohen
fcbca1722f feat: new mobile menu (#3251) 2023-01-17 18:00:15 +09:00
Ryan Cohen
19370f856c chore: add holopin config (#3250) [skip ci] 2023-01-17 10:36:00 +09:00
Ryan Cohen
154f3e72ef fix: correctly restore selected streaming service filters (#3249) 2023-01-16 21:34:47 +09:00
Ryan Cohen
6fd11cf425 fix: correct grid sizing for webkit on streaming services (#3248) 2023-01-16 21:04:55 +09:00
Ryan Cohen
1154156459 feat: add streaming services filter (#3247)
* feat: add streaming services filter

* fix: count watch region/provider as one filter
2023-01-16 17:05:21 +09:00
Danshil Kokil Mungur
cb650745f6 fix(request): mark request as approved if media is already available when retrying failed request (#3244) 2023-01-15 11:32:38 +04:00
Brandon Cohen
3aefddd488 fix: screen would zoom on mobile if date picker input was selected (#3241) 2023-01-14 11:40:39 +09:00
allcontributors[bot]
1bf0103422 docs: add aedelbro as a contributor for code (#3240) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-01-14 10:11:30 +09:00
aedelbro
a672b324ec feat(ui): add episode number to front of episode name in season details (#3086) 2023-01-14 01:10:38 +00:00
Brandon Cohen
a343f8ad91 fix: prevent double encode if we are on /search endpoint (#3238) 2023-01-13 23:21:54 +00:00
Fallenbagel
7a69cb35f8 Merge pull request #296 from CheChu10/patch-2
Update Spanish translations
2023-01-14 00:01:31 +05:00
Ryan Cohen
c2a1a20a3b fix: include new package calendar css in build (#3235) 2023-01-13 20:42:03 +09:00
Ryan Cohen
dd00e48f59 feat: discover overhaul (filters!) (#3232) 2023-01-13 16:54:35 +09:00
Danshil Kokil Mungur
b5157010c4 fix(request): approve request when retrying request (#3234)
chore(request): clarify comment
2023-01-12 16:34:53 +09:00
andrey
812fb2f087 Add Ukrainian language 2023-01-11 04:09:48 +02:00
renovate[bot]
7b6db50ae5 fix(deps): update dependency swr to v2 (#3212)
* fix(deps): update dependency swr to v2

* fix: correct type import for swr

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2023-01-09 16:28:15 +09:00
Ryan Cohen
3ba6df1a41 fix: correct checkbox position (again) for slider edits (#3227) 2023-01-09 15:15:26 +09:00
Ryan Cohen
2eebb7fd39 fix: restore border to ghost button and fix discover slider visibility toggle position (#3226) 2023-01-08 09:18:08 +09:00
Dimitri
c60667ba63 Readding file because Windows didn't want to. 2023-01-08 00:13:27 +01:00
Dimitri
7d6831483a Are you ready for a miracle? 2023-01-08 00:10:48 +01:00
Dimitri
58c5c27929 Delete preview.jpg:Zone.Identifier 2023-01-07 23:33:21 +01:00
Fallenbagel
f5c7a4fa97 Merge pull request #295 from undone37/patch-1
Updated German Translations
2023-01-08 00:34:27 +05:00
Chechu
50e85c975a Prettier fixed 2023-01-07 19:31:50 +01:00
Ryan Cohen
62e2de70bf fix: correct spacing between sliders (#3225) 2023-01-08 00:35:08 +09:00
Ryan Cohen
0683f4f000 refactor: redesign discover customization buttons (#3224) 2023-01-08 00:12:31 +09:00
Chechu García
b1bd569335 Update es.json
Added some missing Spanish translations and fixed a spelling mistake.
Ordered by JSON Keys.
2023-01-07 09:15:28 +00:00
undone37
a49ea92692 style(i18n german translation): fixed Prettier formatting issue of German translation file 2023-01-06 20:02:56 +01:00
Ryan Cohen
d23b2132de fix: improve small screen layout for discover editing (#3221) 2023-01-07 01:12:19 +09:00
Ryan Cohen
8bd10b5bf3 feat: discover inline customization (#3220) 2023-01-06 21:03:09 +09:00
undone37
08c9085f0d Updated German Translations
Added missing German translations and fixed some spelling mistakes.
Changed some German translations to match informal address of user.
Unification of wordings like "Episoden" and "Folgen".
2023-01-06 11:10:13 +01:00
Weblate (bot)
0d8b390b67 feat(lang): translations update from Hosted Weblate (#3030)
* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Spanish)

Currently translated at 84.8% (955 of 1125 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Joseph Valderrama Palacios <jvalderrama.es@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Romanian)

Currently translated at 23.3% (263 of 1125 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 23.3% (263 of 1125 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 21.6% (243 of 1125 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 17.6% (199 of 1125 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 10.9% (123 of 1125 strings)

Co-authored-by: DragoPrime <emperordrago@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sergiu Pahontu <pahontusergiu@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ro/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Polish)

Currently translated at 100.0% (1129 of 1129 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Patryk <byakurau1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pl/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Russian)

Currently translated at 87.7% (987 of 1125 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sergey Moiseev <ty4ko@bk.ru>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ru/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Portuguese (Portugal))

Currently translated at 98.0% (1103 of 1125 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_PT/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1125 of 1125 strings)

Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/da/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.7% (1126 of 1129 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.5% (1120 of 1125 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1120 of 1120 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1120 of 1120 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1120 of 1120 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (1118 of 1120 strings)

Co-authored-by: Eric <hamburger1024@mailbox.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lkw123 <2020393267@qq.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hans/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Hungarian)

Currently translated at 96.7% (1083 of 1119 strings)

feat(lang): translated using Weblate (Hungarian)

Currently translated at 94.3% (1056 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1129 of 1129 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 99.9% (1124 of 1125 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1120 of 1120 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Czech)

Currently translated at 100.0% (1119 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/cs/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (1120 of 1120 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TheCatLady <o40yoym9@anonaddy.me>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (German)

Currently translated at 98.0% (1152 of 1175 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1129 of 1129 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Squizzy911 <tobias.mueller1210@gmail.com>
Co-authored-by: inkarnation <94744834+inkarnation@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (1120 of 1120 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: exentler <gurandsrud@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nb_NO/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 95.0% (1069 of 1125 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 94.0% (1058 of 1125 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 93.8% (1050 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Micke Nilsson <mikni@proton.me>
Co-authored-by: Mikael Nilsson <mikni@proton.me>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Hebrew)

Currently translated at 12.1% (137 of 1125 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Izik Avinoam <izik.avi@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/he/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1120 of 1120 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: dtalens <databio@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ca/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Croatian)

Currently translated at 100.0% (1129 of 1129 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.9% (1124 of 1125 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 100.0% (1120 of 1120 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 100.0% (1119 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 98.3% (1100 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 96.9% (1085 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 96.0% (1075 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.3% (1067 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.3% (1067 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: lpispek <lpispek@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hr/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Albanian)

Currently translated at 100.0% (1125 of 1125 strings)

feat(lang): translated using Weblate (Albanian)

Currently translated at 99.5% (1120 of 1125 strings)

feat(lang): translated using Weblate (Albanian)

Currently translated at 100.0% (1119 of 1119 strings)

Co-authored-by: Denis Çerri <deniscerri3@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sq/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Joseph Valderrama Palacios <jvalderrama.es@gmail.com>
Co-authored-by: DragoPrime <emperordrago@gmail.com>
Co-authored-by: Sergiu Pahontu <pahontusergiu@gmail.com>
Co-authored-by: Patryk <byakurau1@gmail.com>
Co-authored-by: Sergey Moiseev <ty4ko@bk.ru>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Eric <hamburger1024@mailbox.org>
Co-authored-by: lkw123 <2020393267@qq.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Co-authored-by: TheCatLady <o40yoym9@anonaddy.me>
Co-authored-by: Squizzy911 <tobias.mueller1210@gmail.com>
Co-authored-by: inkarnation <94744834+inkarnation@users.noreply.github.com>
Co-authored-by: exentler <gurandsrud@gmail.com>
Co-authored-by: Micke Nilsson <mikni@proton.me>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: Izik Avinoam <izik.avi@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: lpispek <lpispek@gmail.com>
Co-authored-by: Denis Çerri <deniscerri3@gmail.com>
2023-01-05 13:57:43 +09:00
renovate[bot]
19b4dc424f chore(deps): update all non-major dependencies (#3195) [skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-05 13:35:13 +09:00
renovate[bot]
7fef48df63 chore(deps): update dependency lint-staged to v13 (#2931) [skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-05 13:25:24 +09:00
allcontributors[bot]
8220ea55ae docs: add ceptonit as a contributor for doc (#3211) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-01-05 13:19:25 +09:00
ceptonit
4de4a1a52c docs: removed 'available' from /request/{requestId}/{status} endpoint (#3098) [skip ci] 2023-01-05 04:18:05 +00:00
Ryan Cohen
042a1a950f fix: update StatusBadgeMini to shrink on title cards (and remove ring) (#3210) 2023-01-05 12:09:08 +09:00
renovate[bot]
421029ebab fix(deps): update dependency axios to v1 (#3202)
* fix(deps): update dependency axios to v1

* fix: deal with possibly undefined headers from axios

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2023-01-05 11:05:46 +09:00
Ryan Cohen
4e9be7a3f7 fix: correct link to correct keyword results for series (#3208) 2023-01-05 10:30:05 +09:00
Fallenbagel
af4a3b4279 Merge remote-tracking branch 'notfakie/develop' into develop 2023-01-05 03:47:35 +05:00
Fallenbagel
5ce59cc2ee Revert to develop version [skip ci] 2023-01-05 03:45:57 +05:00
Fallenbagel
ff2fa66002 Merge pull request #291 from Fallenbagel/main
Merge origin/main to develop
2023-01-05 03:38:47 +05:00
Fallenbagel
9388a1e61c docs: fix CHANGELOG formatting issue 2023-01-05 02:51:45 +05:00
Fallenbagel
b44a1b4a99 Remove CHANGELOG from origin/develop [skip ci] 2023-01-05 02:46:59 +05:00
Fallenbagel
6f73dbc36a Fix snap stable builds 2023-01-05 02:26:23 +05:00
Ryan Cohen
9d3446d370 fix: restore status badges on titles on actors page when hide available media enabled (#3206) 2023-01-04 17:29:48 +09:00
Ryan Cohen
2f680b4cec refactor: update titlecard to use StatusBadgeMini (#3205) 2023-01-04 16:40:21 +09:00
Brandon Cohen
2179637d43 fix: series displayed an empty season with series list/request modal (#3147)
* fix: series would show an empty season on season list or tv request modal

* fix: request more would show even if all requestable seasons are already requested

* fix: will check if request or season length is longer
2023-01-04 05:48:21 +00:00
Ryan Cohen
e084649878 feat: add keywords to movie/series detail pages (#3204) 2023-01-04 14:19:51 +09:00
renovate[bot]
edf5010659 chore(deps): update dependency cypress to v12 (#3197) [skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-04 10:43:02 +09:00
Ryan Cohen
93afead92e fix: convert genre/studio to string in create slider (#3201)
* fix: convert genre/studio to string in create slider

* fix: fix typo in variable name for i18n message
2023-01-04 10:42:30 +09:00
renovate[bot]
dd48d59b20 fix(deps): update dependency @heroicons/react to v2 (#2970)
* fix(deps): update dependency @heroicons/react to v2

* fix: update imports and fix icon name changes for heroicons

* fix: also update MiniStatusBadge to use new check icon

* fix: update last place with old import

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2023-01-04 01:06:02 +00:00
renovate[bot]
c4b16abc62 fix(deps): pin dependency @headlessui/react to 1.7.7 (#3194) [skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-03 22:08:29 +09:00
renovate[bot]
1a95d423f2 chore(deps): update all non-major dependencies (#2926)
* chore(deps): update all non-major dependencies

* fix: correct breaking changes

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: sct <ryan@sct.dev>
2023-01-03 22:06:07 +09:00
Ryan Cohen
cd3574851a feat: add discover customization (#3182) 2023-01-03 16:04:28 +09:00
semantic-release-bot
299f65c597 chore(release): 1.3.0 2023-01-02 00:14:11 +00:00
Fallenbagel
6021d1e336 Merge pull request #285 from Fallenbagel/prepare-for-next-version
Merge origin/develop to prepare for next release
2023-01-02 05:11:37 +05:00
Fallenbagel
3ce1ef350e docs: merge CHANGELOG.md of develop with main to fix format issue 2023-01-02 05:01:13 +05:00
Fallenbagel
06c91744f3 Merge branch 'develop' 2023-01-02 04:44:05 +05:00
Ryan Cohen
f14d9407d8 chore: update to use github codeql (#3191) [skip ci] 2022-12-30 11:38:08 +09:00
Brandon Cohen
68223f4b1e fix: add bg-opacity to in-progress status badges (#3190) 2022-12-30 11:36:47 +09:00
Ryan Cohen
76335ec8d3 refactor: update mini status icons on titlecard to match badge colors (#3188) 2022-12-29 23:10:23 +09:00
notfakie
2714cbcefd Merge remote-tracking branch 'overseerr/develop' into develop 2022-12-29 13:37:07 +13:00
Brandon Cohen
3309f77aa4 fix: added download status and title to request card/item error components (#3186) 2022-12-28 05:57:11 +00:00
Brandon Cohen
6face8cc45 fix: tooltip shows properly if not in progress (#3185) 2022-12-27 15:17:32 +00:00
Brandon Cohen
27feeea691 fix: changed overflow scroll to only if necessary (#3184) 2022-12-27 13:54:17 +09:00
Brandon Cohen
03853a1b91 feat(ui): request card progress bar (#3123) 2022-12-27 02:13:57 +00:00
Ryan Cohen
357cab87ac fix(experimental): use new RT API (sorta) (#3179) 2022-12-26 16:42:08 +09:00
Fallenbagel
d18e3d185f Merge pull request #277 from Fallenbagel/updatereadme
docs: update current features and add emphasis on the pre-requisites
2022-12-17 17:40:31 +05:00
Fallenbagel
e222463a63 docs: update current features and add emphasis on the pre-requisites [skip ci] 2022-12-17 06:02:06 +05:00
Fallenbagel
03b9bda287 Merge pull request #276 from Fallenbagel/fix-issue-#254
fix(ui): adds mediaServerName to statusBadge and manageSlideOver
2022-12-17 05:38:21 +05:00
Fallenbagel
7e20c7cb78 fix(locale): fix the duplicated wording in the Clear Media Warning message
Fixes the duplicated wording in the clear media warning message of manageSlideOver that was
introduced in previous commit
2022-12-17 05:10:11 +05:00
Fallenbagel
d0cdce9e90 fix(ui): adds mediaServerName to statusBadge and manageSlideOver
Adds mediaServerName to statusBadge and manageSlideOver to indicate the type of mediaServer that is
connected to jellyseerr

fix #254
2022-12-17 05:02:47 +05:00
Fallenbagel
113b09bf2b Merge pull request #275 from Fallenbagel/support-mixed-libraries
feat(api): adds support for Mixed Libraries
2022-12-17 04:27:46 +05:00
Fallenbagel
b16f192b92 Merge pull request #274 from Fallenbagel/pr269
Merge upstream "origin/develop"
2022-12-16 22:26:21 +05:00
Fallenbagel
d9ca3c6e52 fix(api): ignore Music,Books,Photos,MusicVideo libraries
Ignores libraries other than tvshows,movies,others
2022-12-16 20:19:03 +05:00
Fallenbagel
ba82ecec5c feat(api): adds support for Mixed Libraries
Adds support for mixed libraries with movies and show types

fix #95
2022-12-16 16:23:32 +05:00
Fallenbagel
c052a2455c Merge remote-tracking branch 'origin/develop' into pr269 2022-12-16 13:01:07 +05:00
Fallenbagel
2d99a8b03c fix character length of summary for snaps 2022-12-16 12:34:59 +05:00
Fallenbagel
7434c0cf2f fix formatting snapcraft 2022-12-16 12:22:22 +05:00
notfakie
afcb096f49 Merge remote-tracking branch 'overseerr/develop' into develop 2022-12-16 19:58:33 +13:00
Fallenbagel
9dc11cedbf Merge pull request #272 from Fallenbagel/prepare-snap-builds [skip ci]
Prepare snap builds [skip ci]
2022-12-16 03:01:31 +05:00
Fallenbagel
22aab783d4 Prepare snap builds [skip ci] 2022-12-16 02:41:05 +05:00
Fallenbagel
a2babb83ad Merge pull request #263 from darmiel/fix/combined-episodes
fix: count combined episodes
2022-12-06 17:35:02 +05:00
allcontributors[bot]
76a7ceb758 docs: add s0up4200 as a contributor for doc (#3153) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-12-06 12:49:19 +04:00
soup
9688acaa87 chore(docs): fix typo in fail2ban article (#3139) [skip ci] 2022-12-06 12:35:09 +04:00
darmiel
64339e5f03 fix: count combined episodes
Jellyfin allows combined episodes, like `S01E01-E02`,
but seasons containing such episodes are only recognized
as `Partially Available`. This commit should fix that.
2022-12-04 00:36:01 +01:00
Fallenbagel
1ceea3dcca Merge pull request #262 from Fallenbagel/change-cypress-projectid
test(cypress): change cypress projectId
2022-12-03 17:10:42 +05:00
Fallenbagel
e3c3283603 test(cypress): change cypress projectId
Change cypress projectId to jellyseerr's project id
2022-12-03 17:07:36 +05:00
Fallenbagel
4ac02d3aac docs(readme): fixed the formatting of README.md
Fixed the formatting of README.md that was causing issues with the formatting check workflow
2022-12-03 16:15:14 +05:00
Fallenbagel
8eacfe045f Add information about unsupported types
Added information about unsupported libraries and automatic grouping
2022-11-24 06:06:35 +05:00
Ryan Cohen
15e246929b fix(api): handle auth for accounts where the plex id may have been set to null (#3125)
also made some changes to hopefully alleviate this issue from happening at all in the future
2022-11-20 19:07:32 +09:00
Fallenbagel
c1424634fb fix the git clone url 2022-11-02 03:31:47 +05:00
Brandon Cohen
07ec3efbca fix: improved PTR scrolling performance (#3095) 2022-11-01 14:24:10 +09:00
Fallenbagel
9b07b10901 style(readme): fix formatting of README.md 2022-10-26 08:43:08 +05:00
Fallenbagel
b1e9cdbea2 add in minimum version needed for nodejs 2022-10-25 03:53:50 +05:00
Fallenbagel
9aee630392 update instructions to include steps for stable version 2022-10-25 03:47:08 +05:00
Fallenbagel
6b50f77624 update windows instructions
Replaced `yarn` with `npm` in the installation of `win-node-env`
2022-10-25 03:45:07 +05:00
Fallenbagel
16f1c286c4 Add detailed native installation instructions 2022-10-25 03:42:46 +05:00
TheCatLady
64aab6dd82 feat(lang): add Croatian display language (#3041) 2022-10-19 00:40:03 +00:00
allcontributors[bot]
144bb84bdc docs: add Eclipseop as a contributor for code (#3087) [skip ci]
* docs: update README.md

* docs: update .all-contributorsrc

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2022-10-19 09:29:07 +09:00
Mackenzie
76260f9b22 build: update semantic-release to use proper arg for git sha (#3075) 2022-10-18 23:18:48 +00:00
Ryan Cohen
500cd1f872 feat: custom image proxy (#3056) 2022-10-18 14:40:24 +09:00
semantic-release-bot
9252817b58 chore(release): 1.2.1 2022-10-18 02:24:18 +00:00
Fallenbagel
a66925067d Merge pull request #242 from Fallenbagel/develop
Merge origin/develop
2022-10-18 07:21:19 +05:00
Fallenbagel
d037d178aa Merge pull request #240 from sambartik/revert-230-fix-jellyfin-emby-links
Fix jellyfin external url basepath being ignored
2022-10-16 04:24:25 +05:00
Fallenbagel
ab09664d41 fix(backend): fix jellyfinHost to not be undefined
Fix jellyfinHost so its not being treated as null or undefined

fix #237
2022-10-16 03:50:22 +05:00
Brandon Cohen
bfe56c3470 fix: added deep links to issues and status badges (#3065) 2022-10-15 05:39:33 +00:00
TheCatLady
1dfa9431a9 fix: update API docs to allow 'all' seasons value (#3073) 2022-10-15 08:31:50 +09:00
Samuel Bartík
0faae20bac Fix jellyfin external url basepath being ignored 2022-10-13 23:24:09 +02:00
Samuel Bartík
5b10da4073 Revert "fix(backend): fixes Jellyfin/Emby links if server is initially setup with a trailing /" 2022-10-13 23:08:20 +02:00
semantic-release-bot
6049edffca chore(release): 1.2.0 2022-10-12 13:34:54 +00:00
Fallenbagel
f27200c8c1 Merge pull request #235 from Fallenbagel/prepare-for-next-version
Merge origin/develop
2022-10-12 18:27:14 +05:00
Fallenbagel
613ebb95d2 Merge origin/develop 2022-10-12 00:15:50 +05:00
Fallenbagel
15c79e03a5 Merge pull request #234 from Fallenbagel/merge-upstream
Merge upstream develop
2022-10-12 00:07:57 +05:00
Fallenbagel
ed95b0af25 Merge upstream develop 2022-10-11 23:13:31 +05:00
Danshil Kokil Mungur
f5c2fc1c20 fix(ui): minor fixes (#3036)
* fix(ui): hide available media on person page

* fix(ui): set correct label for image cache settings

* fix(ui): disable status badge tooltip for collections

* fix(ui): replace empty space when no episodes in season

* fix: suggested changes

* fix(jobs): set watchlist sync to short interval

* chore: run i18n:extract

* fix: suggested changes
2022-10-04 12:03:24 +09:00
Fallenbagel
3ba69f9a74 Merge pull request #230 from Fallenbagel/fix-jellyfin-emby-links
fix(backend): fixes Jellyfin/Emby links if server is initially setup with a trailing /
2022-09-29 08:34:26 +05:00
Fallenbagel
66357019f0 fix(backend): fixes Jellyfin/Emby links if server is initially setup with a trailing /
Fixes #168 and #220
2022-09-26 10:17:18 +05:00
Brandon Cohen
21d20fdfd6 fix: sidebar close button placement when using PWA (#3045) 2022-09-23 17:54:17 +09:00
JonnyWong16
cf96db90ad docs(proxy): update sub_filter for subfolder (#3046) [skip ci]
The updated `next.js` dependency has a new regex match in the code for `/^\/_next\/data\//`.

This `sub_filter` creates invalid regex `/^\/overseerr/_next\/data\//`
```nginx
    sub_filter '/_next' '/$app/_next';
```
It needs to be updated to substitute the correct the regex `/^\/overseerr\/_next\/data\//`
```nginx
    sub_filter '\/_next' '\/$app\/_next';
    sub_filter '/_next' '/$app/_next';
```
2022-09-22 18:16:08 +00:00
Ryan Cohen
430b1ab871 fix: remove backdrop-blur class from warning buttons (#3037) 2022-09-20 18:18:25 +09:00
Danshil Kokil Mungur
7404d68143 fix(ui): hide null dates in episodes list (#3035) 2022-09-19 16:11:16 +00:00
Weblate (bot)
16cb53f703 feat(lang): translations update from Hosted Weblate (#3026)
* feat(lang): translated using Weblate (Swedish)

Currently translated at 91.8% (1028 of 1119 strings)

Co-authored-by: Johan Ruda <johan.ruda@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Hungarian)

Currently translated at 89.7% (1004 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1119 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Croatian)

Currently translated at 23.3% (261 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hr/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Johan Ruda <johan.ruda@gmail.com>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
2022-09-18 21:07:58 -07:00
Brandon Cohen
407af32d32 fix: new status indicators added to series list on mobile (#3024)
* fix: new status indicators added to series list

* refactor: component will render icons and has updated props
2022-09-17 14:52:51 +09:00
aedelbro
5c01313cc4 fix(ui): remove 'all' badge from request cards (#2992)
if all seasons are requested for a TV show, show each indivdual season badge. This prevents the
admin from needing to open a second tab / navigate to see how many seasons / what seasons have been
requested.
2022-09-16 21:27:45 +00:00
Ryan Cohen
d8da5cbe9d fix(plex): add container-size header to recently added api call (#3023) 2022-09-16 02:01:29 +00:00
Weblate (bot)
3d458dd2fd feat(lang): translations update from Hosted Weblate (#3014)
* feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1119 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Maite Guix <maite.guix@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ca/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 95.6% (1070 of 1119 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 95.6% (1070 of 1119 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 92.3% (1033 of 1119 strings)

Co-authored-by: Eric <hamburger1024@mailbox.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Linyue-GitHub <592746995@qq.com>
Co-authored-by: TheCatLady <o40yoym9@anonaddy.me>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hans/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (1119 of 1119 strings)

feat(lang): translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (1119 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: TheCatLady <o40yoym9@anonaddy.me>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (1119 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: exentler <gurandsrud@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nb_NO/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Croatian)

Currently translated at 23.3% (261 of 1119 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 18.1% (203 of 1119 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lpispek <lpispek@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hr/
Translation: Overseerr/Overseerr Frontend

Co-authored-by: Maite Guix <maite.guix@gmail.com>
Co-authored-by: Eric <hamburger1024@mailbox.org>
Co-authored-by: Linyue-GitHub <592746995@qq.com>
Co-authored-by: TheCatLady <o40yoym9@anonaddy.me>
Co-authored-by: exentler <gurandsrud@gmail.com>
Co-authored-by: lpispek <lpispek@gmail.com>
2022-09-13 23:27:24 +00:00
Brandon Cohen
e486623310 fix: compatibility issue with safari (#3019) 2022-09-14 08:16:55 +09:00
Fallenbagel
8feb20ff52 Merge pull request #157 from Fallenbagel/staging-for-1.1.1
chore(release): 1.1.1
2022-06-20 20:15:44 +05:00
Fallenbagel
f2c659c6f3 chore(release): 1.1.1 2022-06-20 20:11:05 +05:00
semantic-release-bot
99f1a4e4f3 chore(release): 1.2.0 2022-06-20 14:29:16 +00:00
Fallenbagel
fea9457dad Merge pull request #156 from Fallenbagel/develop
Merge branch 'develop' to fix the release workflow
2022-06-20 19:27:42 +05:00
Fallenbagel
23c9595933 Merge pull request #153 from Fallenbagel/develop
Merge branch 'develop'
2022-06-20 18:30:20 +05:00
semantic-release-bot
eceedbbaad chore(release): 1.1.0 2022-05-21 01:58:03 +00:00
Fallenbagel
29f06a965c Merge branch 'develop' 2022-05-21 06:43:52 +05:00
Fallenbagel
9ec05d3ba4 Fixed the link for the jellyseerr logo 2022-04-24 13:22:47 +05:00
semantic-release-bot
ee14ff5a51 chore(release): 1.0.2 2022-04-20 00:06:57 +00:00
Fallenbagel
6b62d4b862 Merge pull request #82 from Fallenbagel/workFlowfix
ci: adds GITHUB_TOKEN as an env
2022-04-20 05:02:17 +05:00
Fallenbagel
706fea0e97 ci: adds GITHUB_TOKEN as an env
adds GITHUB_TOKEN as an env to fix the github_token missing error during release workflow
2022-04-20 05:00:29 +05:00
Fallenbagel
80956d1a83 Merge pull request #81 from Fallenbagel/fixMediaServerType
fix: fix usertype from local user to mediaServerType
2022-04-20 04:58:20 +05:00
Fallenbagel
6d530d9028 fix: fix usertype from local user to mediaServerType
Fixes usertype from appearing as local user even if the mediaServerType is jellyfin
2022-04-20 04:52:39 +05:00
Fallenbagel
f12237565f Merge pull request #80 from Fallenbagel/packagejsonChanges
update tags and the branch to jellyseerr
2022-04-20 03:49:16 +05:00
Fallenbagel
11f5594ed4 update tags and the branch to jellyseerr 2022-04-20 03:47:46 +05:00
Fallenbagel
e4e58bee05 Merge pull request #79 from Fallenbagel/githubChanges
update workflows and discord locations for jellyseerr
2022-04-20 03:32:46 +05:00
Fallenbagel
13ee3a836c update workflows and discord locations for jellyseerr 2022-04-20 03:29:19 +05:00
Fallenbagel
3f16a353f5 Merge pull request #78 from Fallenbagel/urlValidationFix
fix: relax jellyfin url validation to allow local domains
2022-04-20 03:25:41 +05:00
Fallenbagel
9c43ba95e6 fix: relax jellyfin url validation to allow local domains
Relaxes jellyfin url validation so that http://localhost:8096 and http://jellyfin:8096 urls are
accepted in addition to full urls like https://example.com

fix #123
2022-04-20 03:12:01 +05:00
Fallenbagel
13fb6fd1a7 Updated the docker tags
Updated the docker tags to point to fallenbagel docker repo
2022-04-18 07:27:21 +05:00
Fallenbagel
16e8e3a38e update workflow to test for jellyseerr
update workflow and discord locations to test the docker pipeline
2022-04-18 07:17:16 +05:00
Fallenbagel
6fecdf094d Merge pull request #76 from Fallenbagel/updatePackagejson
Update package.json to reflect the jellyseerr version. This helps fix the version issue.
2022-04-15 14:57:27 +05:00
Fallenbagel
69b271b018 Chore(release):v1.0.1 2022-04-15 14:55:53 +05:00
Fallenbagel
d6ebd9a9b9 Chore(release):v1.0.1 2022-04-15 14:54:30 +05:00
Fallenbagel
70dad332fc Merge pull request #74 from Fallenbagel/versionStatusFix
fix: fix for the jellyseerr out of date even though it is up-to-date
2022-04-15 14:30:41 +05:00
Fallenbagel
a65e430c60 fix: fix for the jellyseerr out of date even though it is up-to-date
Reverting back the changes for the quick jellyseerr version fix for a better implementation
2022-04-15 14:03:03 +05:00
Fallenbagel
18f4b67b72 Merge pull request #73 from Fallenbagel/avatarfix
fix: fix default avatar missing
2022-04-15 12:12:13 +05:00
Fallenbagel
506c31562a fix: fix default avatar missing
Fix the default avatar missing because one of the os_logo_square.png file was missing
2022-04-15 12:07:12 +05:00
Fallenbagel
7a9d7a4834 Merge pull request #71 from jsl9208/feat-emby-mediaurl
feat: add emby detail url support
2022-04-15 11:21:49 +05:00
Fallenbagel
902a033b8a Merge pull request #72 from Fallenbagel/unknownjobfix
fix: replaced Unkown job with jellyfin in jobsandcache
2022-04-15 11:18:46 +05:00
Fallenbagel
00eb20aa5e fix: replaced Unkown job with jellyfin in jobsandcache
Replaced unknown job with jellyfin in jobsandcache and fixed the translations to reflect it as well
2022-04-15 10:46:09 +05:00
Shilong Jiang
a2c27cfa95 feat: add emby detail url support 2022-04-14 20:10:57 +08:00
Fallenbagel
7122b4d08b Replaced arm tags with latest
Replaced `:arm` and `:armv7` tags with `latest` as they are now deprecated.
2022-04-14 00:03:57 +05:00
Fallenbagel
b03b9b1dbb fix: fixed request card not displaying the requested season and episodes
When requested, the request card shows as {seasonCount, plural, one {Season}} and does not display
which season or episode was requested because it was still using the alpha request cards. This fixed
that issue
2022-04-13 17:24:47 +05:00
Fallenbagel
73672e29f8 fix: fixed jellyseerr out of date on stable version
When jellyseerr latest version or the stable version was deployed, the version was shown as out of
date with a message to up date to the latest version even though it was the latest version. This
fixed that issue
2022-04-13 17:21:03 +05:00
Fallenbagel
cc5192209f fixed logo_full.svg render 2022-04-13 13:17:54 +05:00
Fallenbagel
278dcf4b44 Update .all-contributorsrc 2022-04-13 13:17:54 +05:00
Fallenbagel
36e092f225 Update .all-contributorsrc 2022-04-13 13:17:54 +05:00
Fallenbagel
46d5c737a2 chore: github update 2022-04-13 13:17:54 +05:00
Fallenbagel
cba4878db3 feat: update zh_Hans.json
Update zh_Hans.json
2022-04-13 13:17:53 +05:00
Fallenbagel
57cc48a699 style: replaced Overseerr with jellyseerr 2022-04-13 13:17:53 +05:00
Fallenbagel
84f488be06 fix: database migration fix
Fixed the database migration issue fixing the error "SQLITE+ERROR: no such column:
User.jellyfinUsername
2022-04-13 13:17:53 +05:00
Fallenbagel
f885f2a0f3 ci: remove DEPENDABOT 2022-04-13 13:17:53 +05:00
Fallenbagel
eef3e5ea4c docs: added preview 2022-04-13 13:17:53 +05:00
Fallenbagel
8db821c1c1 docs: added new logo
Added new jellyseerr logo
2022-04-13 13:17:53 +05:00
Fallenbagel
a39b882f09 docs: added new logo
Added new jellyseerr logo
2022-04-13 13:17:53 +05:00
Fallenbagel
754dccc4bf first commit 2022-04-13 13:17:53 +05:00
Juan D. Jara
f97ee11430 fix(jellyfin): get jellyfin integration working with the last develop version
re #288
2021-09-27 02:56:02 +02:00
Juan D. Jara
54868fd486 style: fix linter and add types 2021-09-27 02:35:10 +02:00
Juan D. Jara
eea389879f Merge branch 'develop' of https://github.com/sct/overseerr into jellyfin-support 2021-09-27 02:24:30 +02:00
Aiden Vigue
5c917f95b4 fix(backend): use different device ids for jellyfin users 2021-06-17 13:42:08 -04:00
Aiden Vigue
dd4d42fd31 fix(backend): force same device id 2021-06-14 16:33:17 -04:00
Aiden Vigue
e5c6b9cd74 fix(backend): update jellyfin.ts for 10.8.0 2021-06-14 12:27:07 -04:00
Aiden Vigue
508fccae4e fix(build): fix build errors 2021-02-27 22:41:35 +00:00
Aiden Vigue
f77573c838 fix(frontend): revert mpaa change 2021-02-27 22:17:51 +00:00
Aiden Vigue
7dfe38001e fix(backend): fix Jellyfin scan for recently added items 2021-02-27 22:15:32 +00:00
Aiden Vigue
48f55da43e style(frontend): fix padding on MPAA rating 2021-02-27 22:15:32 +00:00
Aiden Vigue
1e97503802 fix(db): add migration 2021-02-27 22:13:53 +00:00
Aiden Vigue
42ff34bb3d fix(backend): remove console statement 2021-02-27 22:13:53 +00:00
Aiden Vigue
107b766c44 fix(frontend): add Jellyfin logo to ExternalLinkBlock 2021-02-27 22:13:23 +00:00
Aiden Vigue
fb51ce5570 feat(rebase): rebase 2021-02-27 22:12:55 +00:00
Aiden Vigue
3357343d98 feat(rebase): rebase 2021-02-27 22:12:54 +00:00
Aiden Vigue
9d61092f37 feat(rebase): rebase 2021-02-27 22:12:54 +00:00
Aiden Vigue
29274614c3 feat(rebase): rebase 2021-02-27 22:12:54 +00:00
Aiden Vigue
19b51592ea feat(rebase): rebase 2021-02-27 22:11:47 +00:00
Aiden Vigue
757c0fc29e feat(rebase): rebase 2021-02-27 22:11:27 +00:00
Aiden Vigue
3eb48abc14 feat(rebase): rebase 2021-02-27 22:11:27 +00:00
Aiden Vigue
01cd9d3872 feat(rebase): rebasse 2021-02-27 22:10:25 +00:00
Aiden Vigue
9582196e1f feat: rebase 2021-02-27 22:09:43 +00:00
Aiden Vigue
3743edab8d feat(rebase): rebase 2021-02-27 22:09:02 +00:00
Aiden Vigue
d81e7cdbab feat(rebase): rebase 2021-02-27 22:09:02 +00:00
Aiden Vigue
6e1d7f7075 feat(rebase): rebase 2021-02-27 22:09:02 +00:00
Aiden Vigue
91cf2de33a feat(rebase): rebase 2021-02-27 22:09:02 +00:00
Aiden Vigue
a6ec2d5220 feat(all): add initial Jellyfin/Emby support 2021-02-27 22:09:02 +00:00
230 changed files with 17995 additions and 7960 deletions

View File

@@ -737,6 +737,78 @@
"contributions": [
"translation"
]
},
{
"login": "Eclipseop",
"name": "Mackenzie",
"avatar_url": "https://avatars.githubusercontent.com/u/5846213?v=4",
"profile": "https://github.com/Eclipseop",
"contributions": [
"code"
]
},
{
"login": "s0up4200",
"name": "soup",
"avatar_url": "https://avatars.githubusercontent.com/u/18177310?v=4",
"profile": "https://github.com/s0up4200",
"contributions": [
"doc"
]
},
{
"login": "ceptonit",
"name": "ceptonit",
"avatar_url": "https://avatars.githubusercontent.com/u/12678743?v=4",
"profile": "https://github.com/ceptonit",
"contributions": [
"doc"
]
},
{
"login": "aedelbro",
"name": "aedelbro",
"avatar_url": "https://avatars.githubusercontent.com/u/36162221?v=4",
"profile": "https://github.com/aedelbro",
"contributions": [
"code"
]
},
{
"login": "lunks",
"name": "Pedro Nascimento",
"avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4",
"profile": "http://twitter.com/lunks/",
"contributions": [
"code"
]
},
{
"login": "owenvoke",
"name": "Owen Voke",
"avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4",
"profile": "https://voke.dev",
"contributions": [
"code"
]
},
{
"login": "Nimelrian",
"name": "Sebastian K",
"avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4",
"profile": "https://github.com/Nimelrian",
"contributions": [
"code"
]
},
{
"login": "jariz",
"name": "jariz",
"avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4",
"profile": "https://github.com/jariz",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
@@ -745,5 +817,6 @@
"projectOwner": "sct",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": false
"skipCi": false,
"commitConvention": "angular"
}

7
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,7 @@
# Global code ownership
- @Fallenbagel
# i18n locale files
src/i18n/locale/ @Fallenbagel

5
.github/holopin.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
organization: overseerr
defaultSticker: clcyagj1j329008l468ya8pu2
stickers:
- id: clcyagj1j329008l468ya8pu2
alias: overseerr-contributor

View File

@@ -91,9 +91,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
echo "status=failure" >> $GITHUB_OUTPUT
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

41
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: 'CodeQL'
on:
push:
branches: ['develop']
pull_request:
branches: ['develop']
schedule:
- cron: '50 7 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [javascript]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: '/language:${{ matrix.language }}'

View File

@@ -14,7 +14,7 @@ jobs:
uses: actions/checkout@v3
- name: Get the version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx

View File

@@ -51,8 +51,8 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Switch to master branch
run: git checkout master
- name: Switch to main branch
run: git checkout main
- name: Pull latest changes
run: git pull
- name: Prepare
@@ -60,9 +60,9 @@ jobs:
run: |
git fetch --prune --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo ::set-output name=RELEASE::stable
echo "RELEASE=stable" >> $GITHUB_OUTPUT
else
echo ::set-output name=RELEASE::edge
echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v1
@@ -76,7 +76,7 @@ jobs:
- name: Upload Snap Package
uses: actions/upload-artifact@v2
with:
name: overseerr-snap-package-${{ matrix.architecture }}
name: jellyseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1
@@ -84,8 +84,9 @@ jobs:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
@@ -93,7 +94,7 @@ jobs:
name: Send Discord Notification
needs: semantic-release
if: always()
runs-on: self-hosted
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
@@ -102,9 +103,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
echo "status=failure" >> $GITHUB_OUTPUT
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

View File

@@ -35,9 +35,9 @@ jobs:
run: |
git fetch --prune --unshallow --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo ::set-output name=RELEASE::stable
echo "RELEASE=stable" >> $GITHUB_OUTPUT
else
echo ::set-output name=RELEASE::edge
echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v2
@@ -49,7 +49,7 @@ jobs:
- name: Upload Snap Package
uses: actions/upload-artifact@v3
with:
name: overseerr-snap-package-${{ matrix.architecture }}
name: jellyseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1
@@ -57,8 +57,9 @@ jobs:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
@@ -75,9 +76,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo ::set-output name=status::failure
echo "status=failure" >> $GITHUB_OUTPUT
else
echo ::set-output name=status::$WORKFLOW_CONCLUSION
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

3
.gitignore vendored
View File

@@ -67,3 +67,6 @@ tsconfig.tsbuildinfo
# Webstorm
.idea
# Config Cache Directory
config/cache

View File

@@ -2,6 +2,7 @@
.next/
dist/
config/
CHANGELOG.md
# assets
src/assets/

View File

@@ -16,5 +16,8 @@
}
],
"editor.formatOnSave": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
"typescript.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"globals.css": "tailwindcss"
}
}

View File

@@ -1,3 +1,119 @@
# [1.5.0](https://github.com/fallenbagel/jellyseerr/compare/v1.4.1...v1.5.0) (2023-04-20)
### Bug Fixes
* add better checks on 4k detection of series ([bc9017f](https://github.com/fallenbagel/jellyseerr/commit/bc9017f54d84ec24c4d74d38e1b4e24219425d41))
* added a refresh interval if download status is in progress ([#3275](https://github.com/fallenbagel/jellyseerr/issues/3275)) ([1e2c6f4](https://github.com/fallenbagel/jellyseerr/commit/1e2c6f46ab66c836f321b5d8e34f1e8124c0b542))
* **build:** increase threshold for amount of data to be fetched when SSR'ing ([#3320](https://github.com/fallenbagel/jellyseerr/issues/3320)) ([d7b83d2](https://github.com/fallenbagel/jellyseerr/commit/d7b83d22cee3d20db564cc0564d42802b02327e3))
* disable availability sync temporarily ([2e5cf22](https://github.com/fallenbagel/jellyseerr/commit/2e5cf226265686012329248e7f729fec324c3deb))
* hide remove button when default service is not configured ([7d4455b](https://github.com/fallenbagel/jellyseerr/commit/7d4455ba6bfd12e2730f7085cbb87df246f01d22))
* **jellyfin scan:** temporary workaround fix for jellyfin scan when display specials within season ([38fb66d](https://github.com/fallenbagel/jellyseerr/commit/38fb66d31e41232c01898d0d362af8338eb7b960)), closes [#215](https://github.com/fallenbagel/jellyseerr/issues/215) [#176](https://github.com/fallenbagel/jellyseerr/issues/176) [#246](https://github.com/fallenbagel/jellyseerr/issues/246)
* lint issues ([bcd2bb7](https://github.com/fallenbagel/jellyseerr/commit/bcd2bb7c96810f5a6932f42468a628d2db1bc771))
* logger was set to info for the wrong logs ([#3354](https://github.com/fallenbagel/jellyseerr/issues/3354)) ([c36a4ba](https://github.com/fallenbagel/jellyseerr/commit/c36a4ba2b8df05873f5dfd0946a9bc3dc4ecfd1d))
* remove unnecessary parenthesis from api key generation ([#3336](https://github.com/fallenbagel/jellyseerr/issues/3336)) ([6bd3f01](https://github.com/fallenbagel/jellyseerr/commit/6bd3f015d65507efca60279007bd2b86ee860643))
* **snapcraft:** use the correct config folder for image cache ([#3302](https://github.com/fallenbagel/jellyseerr/issues/3302)) ([c93467b](https://github.com/fallenbagel/jellyseerr/commit/c93467b3acf2c256324297e7e8f21e9944005dd4))
* **ui:** hide mini status badge if non-4K media status is unknown ([#3346](https://github.com/fallenbagel/jellyseerr/issues/3346)) ([50f06da](https://github.com/fallenbagel/jellyseerr/commit/50f06dabbffc693f0843584a64d1d96e77982820))
* **ui:** hide search bar behind slideover when opened ([#3348](https://github.com/fallenbagel/jellyseerr/issues/3348)) ([b3882de](https://github.com/fallenbagel/jellyseerr/commit/b3882de8930a70adb2f93a27be6370bfa1826587))
* **ui:** prevent title cards from flickering when quickly hovering across them ([#3349](https://github.com/fallenbagel/jellyseerr/issues/3349)) ([eb5502a](https://github.com/fallenbagel/jellyseerr/commit/eb5502a16f86e37a933f6beca0678c2d228e77d5))
* **watchlist:** correctly load more than 20 watchlist items ([#3351](https://github.com/fallenbagel/jellyseerr/issues/3351)) ([af880a6](https://github.com/fallenbagel/jellyseerr/commit/af880a6c839794b34bddcd7e0fe56353aa48ba36))
### Features
* add a button in ManageSlideOver to remove the movie and the file from Radarr/Sonarr ([2e74584](https://github.com/fallenbagel/jellyseerr/commit/2e7458457e995dd3ec6dd96035fe997646cdd446))
* availability sync rework ([#3219](https://github.com/fallenbagel/jellyseerr/issues/3219)) ([ae38183](https://github.com/fallenbagel/jellyseerr/commit/ae3818304b2f75222d1bd223ece94f829a3b42d0)), closes [#377](https://github.com/fallenbagel/jellyseerr/issues/377)
* full title of download item on hover with tooltip ([#3296](https://github.com/fallenbagel/jellyseerr/issues/3296)) ([33e7691](https://github.com/fallenbagel/jellyseerr/commit/33e7691b94d7d369a0a1410e434850bc51e5572e))
### Performance Improvements
* **imageproxy:** do not set cookies to image proxy so CDNs can cache images ([#3332](https://github.com/fallenbagel/jellyseerr/issues/3332)) ([966639d](https://github.com/fallenbagel/jellyseerr/commit/966639df430d32f6bfebdb16314dc4590d21caf8))
## [1.4.1](https://github.com/fallenbagel/jellyseerr/compare/v1.4.0...v1.4.1) (2023-01-31)
### Bug Fixes
* pass in library type when scanning recently added items ([#3287](https://github.com/fallenbagel/jellyseerr/issues/3287)) ([8942eb8](https://github.com/fallenbagel/jellyseerr/commit/8942eb8b7c4fa1d16aa2e72e8ba7120a653c9aa2))
* **ui:** air date will use UTC for timezone ([#3297](https://github.com/fallenbagel/jellyseerr/issues/3297)) ([3e43586](https://github.com/fallenbagel/jellyseerr/commit/3e43586acc0804c3fff524509caa890a104e132b))
* **ui:** correct range slider styling in chrome ([#3299](https://github.com/fallenbagel/jellyseerr/issues/3299)) ([d954328](https://github.com/fallenbagel/jellyseerr/commit/d9543289111d72245564d25d300a71b0ea3954ba))
* **ui:** show 5 icons when possible on mobile menu ([#3298](https://github.com/fallenbagel/jellyseerr/issues/3298)) ([7040da1](https://github.com/fallenbagel/jellyseerr/commit/7040da1334f6d18e19a494c73caa17f7df552dfe))
* **ui:** style range thumbs correctly for firefox ([#3294](https://github.com/fallenbagel/jellyseerr/issues/3294)) ([9d10e6a](https://github.com/fallenbagel/jellyseerr/commit/9d10e6a88c0996671f1d9d20792e1930dbc82329))
# [1.4.0](https://github.com/fallenbagel/jellyseerr/compare/v1.3.0...v1.4.0) (2023-01-29)
### Bug Fixes
* add bg-opacity to in-progress status badges ([#3190](https://github.com/fallenbagel/jellyseerr/issues/3190)) ([68223f4](https://github.com/fallenbagel/jellyseerr/commit/68223f4b1e98b01825516dcba39cbb2d3df31a70))
* added download status and title to request card/item error components ([#3186](https://github.com/fallenbagel/jellyseerr/issues/3186)) ([3309f77](https://github.com/fallenbagel/jellyseerr/commit/3309f77aa4be1d70b27693531c119a8e26822518))
* arrow icons were misplaced on mobile in slider edit ([#3260](https://github.com/fallenbagel/jellyseerr/issues/3260)) ([d328485](https://github.com/fallenbagel/jellyseerr/commit/d328485161b9cae6a70ef0713b4878207bc6015e))
* **build:** update usage of publish snap action ([#3272](https://github.com/fallenbagel/jellyseerr/issues/3272)) ([51b05cd](https://github.com/fallenbagel/jellyseerr/commit/51b05cd8fbb5d332807d8c00b2ffb7b10c3d0179))
* changed overflow scroll to only if necessary ([#3184](https://github.com/fallenbagel/jellyseerr/issues/3184)) ([27feeea](https://github.com/fallenbagel/jellyseerr/commit/27feeea69121336557deda1f32b65a5daa146f82))
* convert genre/studio to string in create slider ([#3201](https://github.com/fallenbagel/jellyseerr/issues/3201)) ([93afead](https://github.com/fallenbagel/jellyseerr/commit/93afead92e497f2e5bce67a34fffdaa08d20c7f2))
* correct checkbox position (again) for slider edits ([#3227](https://github.com/fallenbagel/jellyseerr/issues/3227)) ([3ba6df1](https://github.com/fallenbagel/jellyseerr/commit/3ba6df1a41c084c4a6a90354338047623abef521))
* correct grid sizing for webkit on streaming services ([#3248](https://github.com/fallenbagel/jellyseerr/issues/3248)) ([6fd11cf](https://github.com/fallenbagel/jellyseerr/commit/6fd11cf4254e1a19310592bec78a6de52bc073a8))
* correct issue detail bottom padding on mobile displays ([#3268](https://github.com/fallenbagel/jellyseerr/issues/3268)) ([3db010b](https://github.com/fallenbagel/jellyseerr/commit/3db010b9eaec62aa08d973a61caf1801471bbf3e))
* correct link to correct keyword results for series ([#3208](https://github.com/fallenbagel/jellyseerr/issues/3208)) ([4e9be7a](https://github.com/fallenbagel/jellyseerr/commit/4e9be7a3f7304ee7be5ee6fd34b1ea8f6c0cf399))
* correct spacing between sliders ([#3225](https://github.com/fallenbagel/jellyseerr/issues/3225)) ([62e2de7](https://github.com/fallenbagel/jellyseerr/commit/62e2de70bf37b72d5f63370b662d4103a642775b))
* correctly check mobile menu permissions ([#3271](https://github.com/fallenbagel/jellyseerr/issues/3271)) ([f4a22dc](https://github.com/fallenbagel/jellyseerr/commit/f4a22dc437404558f301ccfc195cf0a300dd1ff2))
* correctly restore selected streaming service filters ([#3249](https://github.com/fallenbagel/jellyseerr/issues/3249)) ([154f3e7](https://github.com/fallenbagel/jellyseerr/commit/154f3e72efbf0b663358b3029156f54516f01a2f))
* create shared class to add bottom spacing ([#3269](https://github.com/fallenbagel/jellyseerr/issues/3269)) ([5d1c6f7](https://github.com/fallenbagel/jellyseerr/commit/5d1c6f706555613d97ed9e61d8b665543c2f239b))
* **deps:** pin dependency @headlessui/react to 1.7.7 ([#3194](https://github.com/fallenbagel/jellyseerr/issues/3194)) [skip ci] ([c4b16ab](https://github.com/fallenbagel/jellyseerr/commit/c4b16abc62647c74215155942a4230a31a238677))
* **deps:** update dependency @heroicons/react to v2 ([#2970](https://github.com/fallenbagel/jellyseerr/issues/2970)) ([dd48d59](https://github.com/fallenbagel/jellyseerr/commit/dd48d59b20e2d1800ea30912116f4a4f1bb7928f))
* **deps:** update dependency axios to v1 ([#3202](https://github.com/fallenbagel/jellyseerr/issues/3202)) ([421029e](https://github.com/fallenbagel/jellyseerr/commit/421029ebab66c9a6622ba47e56d7f6473524cce4))
* **deps:** update dependency swr to v2 ([#3212](https://github.com/fallenbagel/jellyseerr/issues/3212)) ([7b6db50](https://github.com/fallenbagel/jellyseerr/commit/7b6db50ae55b1fc60d19a5cff62dd46bb989fa51))
* **experimental:** use new RT API (sorta) ([#3179](https://github.com/fallenbagel/jellyseerr/issues/3179)) ([357cab8](https://github.com/fallenbagel/jellyseerr/commit/357cab87ac7752b8e119b51c938b343c661d83c2))
* improve small screen layout for discover editing ([#3221](https://github.com/fallenbagel/jellyseerr/issues/3221)) ([d23b213](https://github.com/fallenbagel/jellyseerr/commit/d23b2132de05f072f7f9daad83d81421d747cf99))
* include new package calendar css in build ([#3235](https://github.com/fallenbagel/jellyseerr/issues/3235)) ([c2a1a20](https://github.com/fallenbagel/jellyseerr/commit/c2a1a20a3bb20039a1936c7fe0ecb9e8311a0aea))
* issues with issues ([#3267](https://github.com/fallenbagel/jellyseerr/issues/3267)) ([fd21971](https://github.com/fallenbagel/jellyseerr/commit/fd219717c01c558814d7a80de6304272b5a7944e))
* multiple genre filtering now works ([#3282](https://github.com/fallenbagel/jellyseerr/issues/3282)) ([5076938](https://github.com/fallenbagel/jellyseerr/commit/507693881b939819413f0959df5ef6b7a357eb5c))
* prevent double encode if we are on /search endpoint ([#3238](https://github.com/fallenbagel/jellyseerr/issues/3238)) ([a343f8a](https://github.com/fallenbagel/jellyseerr/commit/a343f8ad915491a9c81512c7e541a1dac8906025))
* **request:** approve request when retrying request ([#3234](https://github.com/fallenbagel/jellyseerr/issues/3234)) ([b515701](https://github.com/fallenbagel/jellyseerr/commit/b5157010c46cd9083993d5ee0172007b83d631da))
* **request:** mark request as approved if media is already available when retrying failed request ([#3244](https://github.com/fallenbagel/jellyseerr/issues/3244)) ([cb65074](https://github.com/fallenbagel/jellyseerr/commit/cb650745f6a33e69391a633e6d272831f314e098))
* restore border to ghost button and fix discover slider visibility toggle position ([#3226](https://github.com/fallenbagel/jellyseerr/issues/3226)) ([2eebb7f](https://github.com/fallenbagel/jellyseerr/commit/2eebb7fd3941b34fe9472aaf9d28265df8cce311))
* restore status badges on titles on actors page when hide available media enabled ([#3206](https://github.com/fallenbagel/jellyseerr/issues/3206)) ([9d3446d](https://github.com/fallenbagel/jellyseerr/commit/9d3446d370499c3251159393e5c791b01225e05c))
* screen would zoom on mobile if date picker input was selected ([#3241](https://github.com/fallenbagel/jellyseerr/issues/3241)) ([3aefddd](https://github.com/fallenbagel/jellyseerr/commit/3aefddd48834d86150d5f5cceb2d08af3a78847b))
* series displayed an empty season with series list/request modal ([#3147](https://github.com/fallenbagel/jellyseerr/issues/3147)) ([2179637](https://github.com/fallenbagel/jellyseerr/commit/2179637d437999290eaa4152f6f37c71fc3d8ba3))
* tooltip shows properly if not in progress ([#3185](https://github.com/fallenbagel/jellyseerr/issues/3185)) ([6face8c](https://github.com/fallenbagel/jellyseerr/commit/6face8cc4564b978fb98af32659b326d8c5cede8))
* **ui:** series first air date sorting ([#3283](https://github.com/fallenbagel/jellyseerr/issues/3283)) ([374c78c](https://github.com/fallenbagel/jellyseerr/commit/374c78c989cc86bb144a954a91d5d183c4b591c0))
* update StatusBadgeMini to shrink on title cards (and remove ring) ([#3210](https://github.com/fallenbagel/jellyseerr/issues/3210)) ([042a1a9](https://github.com/fallenbagel/jellyseerr/commit/042a1a950fdd4d4a61edf4bc19657f9b7a526da8))
### Features
* add discover customization ([#3182](https://github.com/fallenbagel/jellyseerr/issues/3182)) ([cd35748](https://github.com/fallenbagel/jellyseerr/commit/cd3574851a12517cbfadc109e6412a7a9e44c114))
* add keywords to movie/series detail pages ([#3204](https://github.com/fallenbagel/jellyseerr/issues/3204)) ([e084649](https://github.com/fallenbagel/jellyseerr/commit/e084649878a58c296786141d12dd69a69a27ee85))
* add streaming services filter ([#3247](https://github.com/fallenbagel/jellyseerr/issues/3247)) ([1154156](https://github.com/fallenbagel/jellyseerr/commit/1154156459403494e8daf0c89a3ba356aeea1d97))
* discover inline customization ([#3220](https://github.com/fallenbagel/jellyseerr/issues/3220)) ([8bd10b5](https://github.com/fallenbagel/jellyseerr/commit/8bd10b5bf3d1b8069872b616c7c8596caeb4937e))
* discover overhaul (filters!) ([#3232](https://github.com/fallenbagel/jellyseerr/issues/3232)) ([dd00e48](https://github.com/fallenbagel/jellyseerr/commit/dd00e48f59054b44bef6b32a2c169e59f6175051))
* discover slider edit arrow buttons for reordering ([#3259](https://github.com/fallenbagel/jellyseerr/issues/3259)) ([da00d45](https://github.com/fallenbagel/jellyseerr/commit/da00d454e17e8b00d04f6e26f6dd5153ed6ced81))
* **lang:** translations update from Hosted Weblate ([#3030](https://github.com/fallenbagel/jellyseerr/issues/3030)) ([0d8b390](https://github.com/fallenbagel/jellyseerr/commit/0d8b390b678731e76bd1f0f8a0a4952c11e77f4d))
* new mobile menu ([#3251](https://github.com/fallenbagel/jellyseerr/issues/3251)) ([fcbca17](https://github.com/fallenbagel/jellyseerr/commit/fcbca1722f31f32633a57bc5048f46c9da057d87))
* translations update from Hosted Weblate ([#3218](https://github.com/fallenbagel/jellyseerr/issues/3218)) ([5940ff7](https://github.com/fallenbagel/jellyseerr/commit/5940ff7f5f62eed9ac5aa6f02803418aaa09813a))
* **ui:** add episode number to front of episode name in season details ([#3086](https://github.com/fallenbagel/jellyseerr/issues/3086)) ([a672b32](https://github.com/fallenbagel/jellyseerr/commit/a672b324ec391a20f6f3a1daed82a8d276a52c2c))
* **ui:** request card progress bar ([#3123](https://github.com/fallenbagel/jellyseerr/issues/3123)) ([03853a1](https://github.com/fallenbagel/jellyseerr/commit/03853a1b9155c8a2153c8885022a74619af1bc15))
# [1.3.0](https://github.com/fallenbagel/jellyseerr/compare/v1.2.1...v1.3.0) (2023-01-02)
### Bug Fixes
- added deep links to issues and status badges ([#3065](https://github.com/fallenbagel/jellyseerr/issues/3065)) ([bfe56c3](https://github.com/fallenbagel/jellyseerr/commit/bfe56c347073001795b1c3e917eb7a5afcc4462c))
- **api:** handle auth for accounts where the plex id may have been set to null ([#3125](https://github.com/fallenbagel/jellyseerr/issues/3125)) ([15e2469](https://github.com/fallenbagel/jellyseerr/commit/15e246929bdbc2b7b5bdab7a84bd7882b79d5cb1))
- **api:** ignore Music,Books,Photos,MusicVideo libraries ([d9ca3c6](https://github.com/fallenbagel/jellyseerr/commit/d9ca3c6e52c118698ca71021217f6ca409e71974))
- count combined episodes ([64339e5](https://github.com/fallenbagel/jellyseerr/commit/64339e5f0374f8490e685e5c086e088bb7fd737e))
- improved PTR scrolling performance ([#3095](https://github.com/fallenbagel/jellyseerr/issues/3095)) ([07ec3ef](https://github.com/fallenbagel/jellyseerr/commit/07ec3efbcaf669de7ccde4421c1112bfd23675d6))
- **locale:** fix the duplicated wording in the Clear Media Warning message ([7e20c7c](https://github.com/fallenbagel/jellyseerr/commit/7e20c7cb78a44c32ab8a5f21203e285f23f402ab))
- **ui:** adds mediaServerName to statusBadge and manageSlideOver ([d0cdce9](https://github.com/fallenbagel/jellyseerr/commit/d0cdce9e90fba642d2bf934a4266e1421424bc73)), closes [#254](https://github.com/fallenbagel/jellyseerr/issues/254)
- update API docs to allow 'all' seasons value ([#3073](https://github.com/fallenbagel/jellyseerr/issues/3073)) ([1dfa943](https://github.com/fallenbagel/jellyseerr/commit/1dfa9431a95e7e2a1843746c2473d8a06f03e184))
### Features
- **api:** adds support for Mixed Libraries ([ba82ece](https://github.com/fallenbagel/jellyseerr/commit/ba82ecec5c994e79d7c9b658372041522b58a120)), closes [#95](https://github.com/fallenbagel/jellyseerr/issues/95)
- custom image proxy ([#3056](https://github.com/fallenbagel/jellyseerr/issues/3056)) ([500cd1f](https://github.com/fallenbagel/jellyseerr/commit/500cd1f872942923d2b9c3b835e6329e335d4a3f))
- **lang:** add Croatian display language ([#3041](https://github.com/fallenbagel/jellyseerr/issues/3041)) ([64aab6d](https://github.com/fallenbagel/jellyseerr/commit/64aab6dd8240e191026512733b34cc046b6e508a))
## [1.29.1](https://github.com/sct/overseerr/compare/v1.29.0...v1.29.1) (2022-04-06)
### Bug Fixes

View File

@@ -5,7 +5,6 @@
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
</p>
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
@@ -13,37 +12,105 @@ _The original Overseerr team have been busy and Jellyfin/Emby support aren't on
## Current Features
- Jellyfin Support
- Emby Support
(Upcoming Features include: Multiple Server Instances, Music Support, Ability to change email address and much more!)
Along with all the existing Overseerr features:
- Full Plex integration. Authenticate and manage user access with Plex!
- Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex!
- Supports Movies, Shows, Mixed Libraries!
- Ability to change email addresses for smtp purposes
- Ability to import all jellyfin/emby users
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
- Plex library scan, to keep track of the titles which are already available.
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
- Granular permission system.
- Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go!
(Upcoming Features include: Multiple Server Instances, Music Support, and much more!)
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
## Getting Started
#### Pre-requisite (Important)
_*On Jellyfin/Emby, ensure the `settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_
### Launching Jellyseerr using Docker
Check out our dockerhub for instructions on how to install and run Jellyseerr:
https://hub.docker.com/r/fallenbagel/jellyseerr
### Launching Jellyseerr manually:
#### Windows
Pre-requisites:
- Nodejs (atleast LTS version)
- Yarn
- Download the source code from the github (Either develop branch or main for stable)
```bash
npm i -g win-node-env
yarn install
yarn run build
yarn start
```
#### Linux
Pre-requisites:
- Nodejs (atleast LTS version)
- Yarn
- Git
```bash
git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr
git checkout main #if you want to run stable instead of develop
yarn install
yarn run build
yarn start
```
_Systemd-service:_
- assuming jellyseerr was cloned to `/opt/`
and the environmentfile is located at `/etc/jellyseerr`
service:
```
[Unit]
Description=Jellyseerr Service
Wants=network-online.target
After=network-online.target
[Service]
EnvironmentFile=/etc/jellyseerr/jellyseerr.conf
Environment=NODE_ENV=production
Type=exec
Restart=on-failure
WorkingDirectory=/opt/jellyseerr
ExecStart=/root/.nvm/versions/node/v18.7.0/bin/node dist/index.js
[Install]
WantedBy=multi-user.target
```
Environmentfile:
```
# Jellyseerr's default port is 5055, if you want to use both, change this.
# specify on which port to listen
PORT=5055
# specify on which interface to listen, by default jellyseerr listens on all interfaces
#HOST=127.0.0.1
# Uncomment if your media server is emby instead of jellyfin.
# JELLYFIN_TYPE=emby
```
### Packages:
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
@@ -73,3 +140,7 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD
## Contributing
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
## Contributors ✨
Thanks goes to all wonderful people who contributed directly to Jellyseerr and Overseerr.

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'cypress';
export default defineConfig({
projectId: 'onnqy3',
projectId: 'xkm1b4',
e2e: {
baseUrl: 'http://localhost:5055',
experimentalSessionAndOrigin: true,

View File

@@ -36,7 +36,9 @@ describe('Discover', () => {
});
it('loads upcoming movies', () => {
cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies');
cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as(
'getUpcomingMovies'
);
cy.visit('/');
cy.wait('@getUpcomingMovies');
clickFirstTitleCardInSlider('Upcoming Movies');
@@ -50,7 +52,9 @@ describe('Discover', () => {
});
it('loads upcoming series', () => {
cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries');
cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as(
'getUpcomingSeries'
);
cy.visit('/');
cy.wait('@getUpcomingSeries');
clickFirstTitleCardInSlider('Upcoming Series');

View File

@@ -0,0 +1,163 @@
describe('Discover Customization', () => {
beforeEach(() => {
cy.loginAsAdmin();
cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders');
});
it('show the discover customization settings', () => {
cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=create-slider-header')
.should('contain', 'Create New Slider')
.scrollIntoView();
// There should be some built in options
cy.get('[data-testid=discover-slider-edit-mode]').should(
'contain',
'Recently Added'
);
cy.get('[data-testid=discover-slider-edit-mode]').should(
'contain',
'Recent Requests'
);
});
it('can drag to re-order elements and save to persist the changes', () => {
let dataTransfer = new DataTransfer();
cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.trigger('dragstart', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('drop', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('dragend', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recently Added');
cy.get('[data-testid=discover-customize-submit').click();
cy.wait('@getDiscoverSliders');
cy.reload();
cy.get('[data-testid=discover-start-editing]').click();
dataTransfer = new DataTransfer();
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recently Added');
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.trigger('dragstart', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('drop', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('dragend', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recent Requests');
cy.get('[data-testid=discover-customize-submit').click();
cy.wait('@getDiscoverSliders');
});
it('can create a new discover option and remove it', () => {
cy.visit('/');
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
cy.get('[data-testid=discover-start-editing]').click();
const sliderTitle = 'Custom Keyword Slider';
cy.get('#sliderType').select('TMDB Movie Keyword');
cy.get('#title').type(sliderTitle);
// First confirm that an invalid keyword doesn't allow us to submit anything
cy.get('#data').type('invalidkeyword{enter}', { delay: 100 });
cy.wait('@searchKeyword');
cy.get('[data-testid=create-discover-option-form]')
.find('button')
.should('be.disabled');
cy.get('#data').clear();
cy.get('#data').type('christmas{enter}', { delay: 100 });
// Confirming we have some results
cy.contains('.slider-header', sliderTitle)
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]');
cy.get('[data-testid=create-discover-option-form]').submit();
cy.wait('@discoverSlider');
cy.wait('@getDiscoverSliders');
cy.wait(1000);
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('contain', sliderTitle);
// Make sure its still there even if we reload
cy.reload();
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('contain', sliderTitle);
// Verify it's not rendering on our discover page (its still disabled!)
cy.visit('/');
cy.get('.slider-header').should('not.contain', sliderTitle);
cy.get('[data-testid=discover-start-editing]').click();
// Enable it, and check again
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.find('[role="checkbox"]')
.click();
cy.get('[data-testid=discover-customize-submit').click();
cy.wait('@getDiscoverSliders');
cy.visit('/');
cy.contains('.slider-header', sliderTitle)
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]');
cy.get('[data-testid=discover-start-editing]').click();
// let's delete it and confirm its deleted.
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.find('[data-testid=discover-slider-remove-button]')
.click();
cy.wait('@discoverSlider');
cy.wait('@getDiscoverSliders');
cy.wait(1000);
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('not.contain', sliderTitle);
});
});

View File

@@ -16,7 +16,7 @@ describe('General Settings', () => {
cy.visit('/settings');
cy.get('#trustProxy').click();
cy.get('form').submit();
cy.get('[data-testid=settings-main-form]').submit();
cy.get('[data-testid=modal-title]').should(
'contain',
'Server Restart Required'
@@ -26,7 +26,7 @@ describe('General Settings', () => {
cy.get('[data-testid=modal-title]').should('not.exist');
cy.get('[type=checkbox]#trustProxy').click();
cy.get('form').submit();
cy.get('[data-testid=settings-main-form]').submit();
cy.get('[data-testid=modal-title]').should('not.exist');
});
});

View File

@@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
```
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.

View File

@@ -138,6 +138,7 @@ location ^~ /overseerr {
sub_filter 'href="/"' 'href="/$app"';
sub_filter 'href="/login"' 'href="/$app/login"';
sub_filter 'href:"/"' 'href:"/$app"';
sub_filter '\/_next' '\/$app\/_next';
sub_filter '/_next' '/$app/_next';
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';

View File

@@ -28,6 +28,7 @@ docker run -d \
--name overseerr \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tokyo \
-e PORT=5055 `#optional` \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
@@ -81,6 +82,7 @@ services:
environment:
- LOG_LEVEL=debug
- TZ=Asia/Tokyo
- PORT=5055 #optional
ports:
- 5055:5055
volumes:
@@ -88,7 +90,7 @@ services:
restart: unless-stopped
```
Then, start all services defined in the your Compose file:
Then, start all services defined in the Compose file:
```bash
docker-compose up -d
@@ -146,8 +148,6 @@ Then, create and start the Overseerr container:
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
```
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
{% hint style="info" %}
@@ -155,7 +155,7 @@ Docker on Windows works differently than it does on Linux; it runs Docker inside
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
Named volumes, like in the example commands above, are automatically mounted inside the VM.
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
{% endhint %}
## Linux

View File

@@ -40,6 +40,14 @@ If you enable this setting and find yourself unable to access Overseerr, you can
This setting is **disabled** by default.
### Enable Image Caching
When enabled, Overseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space.
Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours.
You should enable this if you are having issues with loading images directly from TMDB in your browser.
### Display Language
Set the default display language for Overseerr. Users can override this setting in their user settings.

View File

@@ -23,5 +23,6 @@ module.exports = {
},
experimental: {
scrollRestoration: true,
largePageDataBytes: 256000,
},
};

View File

@@ -26,6 +26,8 @@ tags:
description: Endpoints related to retrieving movies and their details.
- name: tv
description: Endpoints related to retrieving TV series and their details.
- name: other
description: Endpoints related to other TMDB data
- name: person
description: Endpoints related to retrieving person details.
- name: media
@@ -648,6 +650,17 @@ components:
name:
type: string
example: Adventure
Company:
type: object
properties:
id:
type: number
example: 1
logo_path:
type: string
nullable: true
name:
type: string
ProductionCompany:
type: object
properties:
@@ -1087,6 +1100,8 @@ components:
nullable: true
status:
type: number
example: 0
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
requests:
type: array
readOnly: true
@@ -1828,6 +1843,40 @@ components:
message:
type: string
example: A comment
DiscoverSlider:
type: object
properties:
id:
type: number
example: 1
type:
type: number
example: 1
title:
type: string
nullable: true
isBuiltIn:
type: boolean
enabled:
type: boolean
data:
type: string
example: '1234'
nullable: true
required:
- type
- enabled
- title
- data
WatchProviderRegion:
type: object
properties:
iso_3166_1:
type: string
english_name:
type: string
native_name:
type: string
securitySchemes:
cookieAuth:
type: apiKey
@@ -2667,29 +2716,44 @@ paths:
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: string
example: cache-id
name:
type: string
example: cache name
stats:
type: object
properties:
imageCache:
type: object
properties:
tmdb:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches:
type: array
items:
type: object
properties:
hits:
type: number
misses:
type: number
keys:
type: number
ksize:
type: number
vsize:
type: number
id:
type: string
example: cache-id
name:
type: string
example: cache name
stats:
type: object
properties:
hits:
type: number
misses:
type: number
keys:
type: number
ksize:
type: number
vsize:
type: number
/settings/cache/{cacheId}/flush:
post:
summary: Flush a specific cache
@@ -3219,6 +3283,133 @@ paths:
responses:
'204':
description: Test notification attempted
/settings/discover:
get:
summary: Get all discover sliders
description: Returns all discovery sliders. Built-in and custom made.
tags:
- settings
responses:
'200':
description: Returned all discovery sliders
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
post:
summary: Batch update all sliders.
description: |
Batch update all sliders at once. Should also be used for creation. Will only update sliders provided
and will not delete any sliders not present in the request. If a slider is missing a required field,
it will be ignored. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
responses:
'200':
description: Returned all newly updated discovery sliders
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
/settings/discover/{sliderId}:
put:
summary: Update a single slider
description: |
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: 'Slider Title'
type:
type: number
example: 1
data:
type: string
example: '1'
responses:
'200':
description: Returns newly added discovery slider
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
delete:
summary: Delete slider by ID
description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission.
tags:
- settings
parameters:
- in: path
name: sliderId
required: true
schema:
type: number
responses:
'200':
description: Slider successfully deleted
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
/settings/discover/add:
post:
summary: Add a new slider
description: |
Add a single slider and return the newly created slider. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: 'New Slider'
type:
type: number
example: 1
data:
type: string
example: '1'
responses:
'200':
description: Returns newly added discovery slider
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
/settings/discover/reset:
get:
summary: Reset all discover sliders
description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission.
tags:
- settings
responses:
'204':
description: All sliders reset to defaults
/settings/about:
get:
summary: Get server stats
@@ -3677,7 +3868,7 @@ paths:
$ref: '#/components/schemas/User'
/user/{userId}/requests:
get:
summary: Get user by ID
summary: Get requests for a specific user
description: |
Retrieves a user's requests in a JSON object.
tags:
@@ -3773,7 +3964,7 @@ paths:
example: false
/user/{userId}/watchlist:
get:
summary: Get user by ID
summary: Get the Plex watchlist for a specific user
description: |
Retrieves a user's Plex Watchlist in a JSON object.
tags:
@@ -4100,6 +4291,86 @@ paths:
- $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult'
- $ref: '#/components/schemas/PersonResult'
/search/keyword:
get:
summary: Search for keywords
description: Returns a list of TMDB keywords matching the search query
tags:
- search
parameters:
- in: query
name: query
required: true
schema:
type: string
example: 'christmas'
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Results
content:
application/json:
schema:
type: object
properties:
page:
type: number
example: 1
totalPages:
type: number
example: 20
totalResults:
type: number
example: 200
results:
type: array
items:
$ref: '#/components/schemas/Keyword'
/search/company:
get:
summary: Search for companies
description: Returns a list of TMDB companies matching the search query. (Will not return origin country)
tags:
- search
parameters:
- in: query
name: query
required: true
schema:
type: string
example: 'Disney'
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Results
content:
application/json:
schema:
type: object
properties:
page:
type: number
example: 1
totalPages:
type: number
example: 20
totalResults:
type: number
example: 200
results:
type: array
items:
$ref: '#/components/schemas/Company'
/discover/movies:
get:
summary: Discover movies
@@ -4121,13 +4392,63 @@ paths:
- in: query
name: genre
schema:
type: number
type: string
example: 18
- in: query
name: studio
schema:
type: number
example: 1
- in: query
name: keywords
schema:
type: string
example: 1,2
- in: query
name: sortBy
schema:
type: string
example: popularity.desc
- in: query
name: primaryReleaseDateGte
schema:
type: string
example: 2022-01-01
- in: query
name: primaryReleaseDateLte
schema:
type: string
example: 2023-01-01
- in: query
name: withRuntimeGte
schema:
type: number
example: 60
- in: query
name: withRuntimeLte
schema:
type: number
example: 120
- in: query
name: voteAverageGte
schema:
type: number
example: 7
- in: query
name: voteAverageLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:
type: string
example: US
- in: query
name: watchProviders
schema:
type: string
example: 8|9
responses:
'200':
description: Results
@@ -4350,13 +4671,63 @@ paths:
- in: query
name: genre
schema:
type: number
type: string
example: 18
- in: query
name: network
schema:
type: number
example: 1
- in: query
name: keywords
schema:
type: string
example: 1,2
- in: query
name: sortBy
schema:
type: string
example: popularity.desc
- in: query
name: firstAirDateGte
schema:
type: string
example: 2022-01-01
- in: query
name: firstAirDateLte
schema:
type: string
example: 2023-01-01
- in: query
name: withRuntimeGte
schema:
type: number
example: 60
- in: query
name: withRuntimeLte
schema:
type: number
example: 120
- in: query
name: voteAverageGte
schema:
type: number
example: 7
- in: query
name: voteAverageLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:
type: string
example: US
- in: query
name: watchProviders
schema:
type: string
example: 8|9
responses:
'200':
description: Results
@@ -4838,9 +5209,13 @@ paths:
type: number
example: 123
seasons:
type: array
items:
type: number
oneOf:
- type: array
items:
type: number
minimum: 1
- type: string
enum: [all]
is4k:
type: boolean
example: false
@@ -4919,7 +5294,7 @@ paths:
$ref: '#/components/schemas/MediaRequest'
put:
summary: Update MediaRequest
description: Updates a specific media request and returns the request in a JSON object.. Requires the `MANAGE_REQUESTS` permission.
description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission.
tags:
- request
parameters:
@@ -4930,6 +5305,37 @@ paths:
example: '1'
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
mediaType:
type: string
enum: [movie, tv]
seasons:
type: array
items:
type: number
minimum: 1
is4k:
type: boolean
example: false
serverId:
type: number
profileId:
type: number
rootFolder:
type: string
languageProfileId:
type: number
userId:
type: number
nullable: true
required:
- mediaType
responses:
'200':
description: Succesfully updated request
@@ -5000,7 +5406,7 @@ paths:
required: true
schema:
type: string
enum: [pending, approve, decline, available]
enum: [approve, decline]
responses:
'200':
description: Request status changed
@@ -6137,6 +6543,89 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Issue'
/keyword/{keywordId}:
get:
summary: Get keyword
description: |
Returns a single keyword in JSON format.
tags:
- other
parameters:
- in: path
name: keywordId
required: true
schema:
type: number
example: 1
responses:
'200':
description: Keyword returned
content:
application/json:
schema:
$ref: '#/components/schemas/Keyword'
/watchproviders/regions:
get:
summary: Get watch provider regions
description: |
Returns a list of all available watch provider regions.
tags:
- other
responses:
'200':
description: Watch provider regions returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderRegion'
/watchproviders/movies:
get:
summary: Get watch provider movies
description: |
Returns a list of all available watch providers for movies.
tags:
- other
parameters:
- in: query
name: watchRegion
required: true
schema:
type: string
example: US
responses:
'200':
description: Watch providers for movies returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
/watchproviders/tv:
get:
summary: Get watch provider series
description: |
Returns a list of all available watch providers for series.
tags:
- other
parameters:
- in: query
name: watchRegion
required: true
schema:
type: string
example: US
responses:
'200':
description: Watch providers for series returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
security:
- cookieAuth: []
- apiKey: []

View File

@@ -1,6 +1,6 @@
{
"name": "jellyseerr",
"version": "0.1.0",
"version": "1.5.0",
"private": true,
"scripts": {
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
@@ -29,145 +29,149 @@
},
"license": "MIT",
"dependencies": {
"@formatjs/intl-displaynames": "6.0.3",
"@formatjs/intl-locale": "3.0.3",
"@formatjs/intl-pluralrules": "5.0.3",
"@formatjs/intl-displaynames": "6.2.3",
"@formatjs/intl-locale": "3.0.11",
"@formatjs/intl-pluralrules": "5.1.8",
"@formatjs/intl-utils": "3.8.4",
"@headlessui/react": "0.0.0-insiders.b301f04",
"@heroicons/react": "1.0.6",
"@headlessui/react": "1.7.7",
"@heroicons/react": "2.0.13",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.3.1",
"@tanem/react-nprogress": "5.0.11",
"ace-builds": "1.9.6",
"axios": "0.27.2",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.22",
"ace-builds": "1.14.0",
"axios": "1.2.2",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.0.1",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
"connect-typeorm": "1.1.4",
"cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.2",
"copy-to-clipboard": "3.3.3",
"country-flag-icons": "1.5.5",
"cronstrue": "2.11.0",
"cronstrue": "2.21.0",
"csurf": "1.11.0",
"date-fns": "2.29.1",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"email-templates": "9.0.0",
"email-validator": "2.0.4",
"express": "4.18.1",
"express": "4.18.2",
"express-openapi-validator": "4.13.8",
"express-rate-limit": "6.5.1",
"express-rate-limit": "6.7.0",
"express-session": "1.17.3",
"formik": "2.2.9",
"gravatar-url": "3.1.0",
"intl": "1.2.5",
"lodash": "4.17.21",
"next": "12.2.5",
"next": "12.3.4",
"node-cache": "5.1.2",
"node-gyp": "9.1.0",
"node-gyp": "9.3.1",
"node-schedule": "2.1.0",
"nodemailer": "6.7.8",
"openpgp": "5.4.0",
"nodemailer": "6.8.0",
"openpgp": "5.5.0",
"plex-api": "5.3.2",
"pug": "3.0.2",
"pulltorefreshjs": "0.1.22",
"react": "18.2.0",
"react-ace": "10.1.0",
"react-animate-height": "2.1.2",
"react-aria": "3.22.0",
"react-dom": "18.2.0",
"react-intersection-observer": "9.4.0",
"react-intl": "6.0.5",
"react-markdown": "8.0.3",
"react-intersection-observer": "9.4.1",
"react-intl": "6.2.5",
"react-markdown": "8.0.4",
"react-popper-tooltip": "4.4.2",
"react-select": "5.4.0",
"react-spring": "9.5.2",
"react-select": "5.7.0",
"react-spring": "9.6.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1",
"react-truncate-markup": "5.1.2",
"react-use-clipboard": "1.0.8",
"react-use-clipboard": "1.0.9",
"reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3",
"semver": "7.3.7",
"sqlite3": "5.0.11",
"swagger-ui-express": "4.5.0",
"swr": "1.3.0",
"typeorm": "0.3.7",
"semver": "7.3.8",
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.0",
"swr": "2.0.0",
"typeorm": "0.3.11",
"web-push": "3.5.0",
"winston": "3.8.1",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23",
"yamljs": "0.3.0",
"yup": "0.32.11"
"yup": "0.32.11",
"zod": "3.20.2"
},
"devDependencies": {
"@babel/cli": "7.18.10",
"@commitlint/cli": "17.0.3",
"@commitlint/config-conventional": "17.0.3",
"@semantic-release/changelog": "6.0.1",
"@babel/cli": "7.20.7",
"@commitlint/cli": "17.4.0",
"@commitlint/config-conventional": "17.4.0",
"@semantic-release/changelog": "6.0.2",
"@semantic-release/commit-analyzer": "9.0.2",
"@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1",
"@tailwindcss/aspect-ratio": "0.4.0",
"@tailwindcss/forms": "0.5.2",
"@tailwindcss/typography": "0.5.4",
"@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.5.3",
"@tailwindcss/typography": "0.5.8",
"@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.3",
"@types/country-flag-icons": "1.2.0",
"@types/csurf": "1.11.2",
"@types/email-templates": "8.0.4",
"@types/express": "4.17.13",
"@types/express-session": "1.17.4",
"@types/lodash": "4.14.183",
"@types/express": "4.17.15",
"@types/express-session": "1.17.5",
"@types/lodash": "4.14.191",
"@types/node": "17.0.36",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.5",
"@types/nodemailer": "6.4.7",
"@types/pulltorefreshjs": "0.1.5",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.12",
"@types/semver": "7.3.13",
"@types/swagger-ui-express": "4.1.3",
"@types/web-push": "3.3.2",
"@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31",
"@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "5.33.1",
"@typescript-eslint/parser": "5.33.1",
"autoprefixer": "10.4.8",
"@typescript-eslint/eslint-plugin": "5.48.0",
"@typescript-eslint/parser": "5.48.0",
"autoprefixer": "10.4.13",
"babel-plugin-react-intl": "8.2.25",
"babel-plugin-react-intl-auto": "3.3.0",
"commitizen": "4.2.5",
"commitizen": "4.2.6",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "10.6.0",
"cypress": "12.3.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.22.0",
"eslint-config-next": "12.2.5",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-formatjs": "4.1.0",
"eslint": "8.31.0",
"eslint-config-next": "12.3.4",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.3.9",
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-no-relative-import-paths": "1.4.0",
"eslint-plugin-no-relative-import-paths": "1.5.2",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.30.1",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1",
"husky": "8.0.1",
"lint-staged": "12.4.3",
"nodemon": "2.0.19",
"postcss": "8.4.16",
"prettier": "2.7.1",
"prettier-plugin-organize-imports": "3.1.0",
"prettier-plugin-tailwindcss": "0.1.13",
"semantic-release": "19.0.3",
"husky": "8.0.3",
"lint-staged": "13.1.0",
"nodemon": "2.0.20",
"postcss": "8.4.20",
"prettier": "2.8.1",
"prettier-plugin-organize-imports": "3.2.1",
"prettier-plugin-tailwindcss": "0.2.1",
"semantic-release": "19.0.5",
"semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.1.8",
"tailwindcss": "3.2.4",
"ts-node": "10.9.1",
"tsc-alias": "1.7.0",
"tsconfig-paths": "4.1.0",
"typescript": "4.7.4"
"tsc-alias": "1.8.2",
"tsconfig-paths": "4.1.2",
"typescript": "4.9.4"
},
"resolutions": {
"sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6"
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10"
},
"config": {
"commitizen": {
@@ -225,7 +229,7 @@
{
"path": "semantic-release-docker-buildx",
"buildArgs": {
"COMMIT_TAG": "$GITHUB_SHA"
"COMMIT_TAG": "$GIT_SHA"
},
"imageNames": [
"fallenbagel/jellyseerr"

View File

@@ -69,6 +69,30 @@ class ExternalAPI {
return response.data;
}
protected async post<T>(
endpoint: string,
data: Record<string, unknown>,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params,
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.post<T>(endpoint, data, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,

View File

@@ -38,6 +38,7 @@ export interface JellyfinLibraryItem {
SeasonId?: string;
SeasonName?: string;
IndexNumber?: number;
IndexNumberEnd?: number;
ParentIndexNumber?: number;
MediaType: string;
}
@@ -178,8 +179,10 @@ class JellyfinAPI {
(Item: any) => {
return (
Item.Type === 'CollectionFolder' &&
(Item.CollectionType === 'tvshows' ||
Item.CollectionType === 'movies')
Item.CollectionType !== 'music' &&
Item.CollectionType !== 'books' &&
Item.CollectionType !== 'musicvideos' &&
Item.CollectionType !== 'homevideos'
);
}
).map((Item: any) => {
@@ -204,7 +207,7 @@ class JellyfinAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try {
const contents = await this.axios.get<any>(
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie&Recursive=true&StartIndex=0&ParentId=${id}`
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}`
);
return contents.data.Items.filter(

View File

@@ -226,12 +226,17 @@ class PlexAPI {
id: string,
options: { addedAt: number } = {
addedAt: Date.now() - 1000 * 60 * 60,
}
},
mediaType: 'movie' | 'show'
): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
options.addedAt / 1000
)}`,
uri: `/library/sections/${id}/all?type=${
mediaType === 'show' ? '4' : '1'
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
extraHeaders: {
'X-Plex-Container-Start': `0`,
'X-Plex-Container-Size': `500`,
},
});
return response.MediaContainer.Metadata;

View File

@@ -1,28 +1,40 @@
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import ExternalAPI from './externalapi';
interface RTSearchResult {
meterClass: 'certified_fresh' | 'fresh' | 'rotten';
meterScore: number;
url: string;
interface RTAlgoliaSearchResponse {
results: {
hits: RTAlgoliaHit[];
index: 'content_rt' | 'people_rt';
}[];
}
interface RTTvSearchResult extends RTSearchResult {
interface RTAlgoliaHit {
emsId: string;
emsVersionId: string;
tmsId: string;
type: string;
title: string;
startYear: number;
endYear: number;
}
interface RTMovieSearchResult extends RTSearchResult {
name: string;
url: string;
year: number;
}
interface RTMultiSearchResponse {
tvCount: number;
tvSeries: RTTvSearchResult[];
movieCount: number;
movies: RTMovieSearchResult[];
titles: string[];
description: string;
releaseYear: string;
rating: string;
genres: string[];
updateDate: string;
isEmsSearchable: boolean;
rtId: number;
vanity: string;
aka: string[];
posterImageUrl: string;
rottenTomatoes: {
audienceScore: number;
criticsIconUrl: string;
wantToSeeCount: number;
audienceIconUrl: string;
scoreSentiment: string;
certifiedFresh: boolean;
criticsScore: number;
};
}
export interface RTRating {
@@ -47,13 +59,20 @@ export interface RTRating {
*/
class RottenTomatoes extends ExternalAPI {
constructor() {
const settings = getSettings();
super(
'https://www.rottentomatoes.com/api/private',
{},
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
{
'x-algolia-agent':
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
'x-algolia-application-id': '79FRDP12PN',
},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-algolia-usertoken': settings.clientId,
},
nodeCache: cacheManager.getCache('rt').data,
}
@@ -61,14 +80,11 @@ class RottenTomatoes extends ExternalAPI {
}
/**
* Search the 1.0 api for the movie title
* Search the RT algolia api for the movie title
*
* We compare the release date to make sure its the correct
* match. But it's not guaranteed to have results.
*
* We use the 1.0 API here because the 2.0 search api does
* not return audience ratings.
*
* @param name Movie name
* @param year Release Year
*/
@@ -77,30 +93,45 @@ class RottenTomatoes extends ExternalAPI {
year: number
): Promise<RTRating | null> {
try {
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
params: { q: name, limit: 10 },
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
if (!contentResults) {
return null;
}
// First, attempt to match exact name and year
let movie = data.movies.find(
(movie) => movie.year === year && movie.name === name
let movie = contentResults.hits.find(
(movie) => movie.releaseYear === year.toString() && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = data.movies.find(
(movie) => movie.year === year && movie.name.includes(name)
movie = contentResults.hits.find(
(movie) =>
movie.releaseYear === year.toString() && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = data.movies.find((movie) => movie.year === year);
movie = contentResults.hits.find(
(movie) => movie.releaseYear === year.toString()
);
}
// One last try, try exact name match only
if (!movie) {
movie = data.movies.find((movie) => movie.name === name);
movie = contentResults.hits.find((movie) => movie.title === name);
}
if (!movie) {
@@ -108,16 +139,15 @@ class RottenTomatoes extends ExternalAPI {
}
return {
title: movie.name,
url: `https://www.rottentomatoes.com${movie.url}`,
criticsRating:
movie.meterClass === 'certified_fresh'
? 'Certified Fresh'
: movie.meterClass === 'fresh'
? 'Fresh'
: 'Rotten',
criticsScore: movie.meterScore,
year: movie.year,
title: movie.title,
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
criticsRating: movie.rottenTomatoes.certifiedFresh
? 'Certified Fresh'
: movie.rottenTomatoes.criticsScore >= 60
? 'Fresh'
: 'Rotten',
criticsScore: movie.rottenTomatoes.criticsScore,
year: Number(movie.releaseYear),
};
} catch (e) {
throw new Error(
@@ -131,14 +161,28 @@ class RottenTomatoes extends ExternalAPI {
year?: number
): Promise<RTRating | null> {
try {
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
params: { q: name, limit: 10 },
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
const contentResults = data.results.find((r) => r.index === 'content_rt');
if (!contentResults) {
return null;
}
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
if (year) {
tvshow = data.tvSeries.find((series) => series.startYear === year);
tvshow = contentResults.hits.find(
(series) => series.releaseYear === year.toString()
);
}
if (!tvshow) {
@@ -147,10 +191,11 @@ class RottenTomatoes extends ExternalAPI {
return {
title: tvshow.title,
url: `https://www.rottentomatoes.com${tvshow.url}`,
criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten',
criticsScore: tvshow.meterScore,
year: tvshow.startYear,
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
criticsRating:
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
criticsScore: tvshow.rottenTomatoes.criticsScore,
year: Number(tvshow.releaseYear),
};
} catch (e) {
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);

View File

@@ -158,7 +158,12 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
`/queue`
`/queue`,
{
params: {
includeEpisode: true,
},
}
);
return response.data.records;

View File

@@ -1,7 +1,7 @@
import logger from '@server/logger';
import ServarrBase from './base';
interface SonarrSeason {
export interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {
@@ -13,6 +13,21 @@ interface SonarrSeason {
percentOfEpisodes: number;
};
}
interface EpisodeResult {
seriesId: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
title: string;
airDate: string;
airDateUtc: string;
overview: string;
hasFile: boolean;
monitored: boolean;
absoluteEpisodeNumber: number;
unverifiedSceneNumbering: boolean;
id: number;
}
export interface SonarrSeries {
title: string;
@@ -82,7 +97,11 @@ export interface LanguageProfile {
name: string;
}
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
class SonarrAPI extends ServarrBase<{
seriesId: number;
episodeId: number;
episode: EpisodeResult;
}> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
}

View File

@@ -3,9 +3,12 @@ import cacheManager from '@server/lib/cache';
import { sortBy } from 'lodash';
import type {
TmdbCollection,
TmdbCompanySearchResponse,
TmdbExternalIdResponse,
TmdbGenre,
TmdbGenresResult,
TmdbKeyword,
TmdbKeywordSearchResponse,
TmdbLanguage,
TmdbMovieDetails,
TmdbNetwork,
@@ -19,6 +22,8 @@ import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
TmdbUpcomingMoviesResponse,
TmdbWatchProviderDetails,
TmdbWatchProviderRegion,
} from './interfaces';
interface SearchOptions {
@@ -32,30 +37,41 @@ interface SingleSearchOptions extends SearchOptions {
year?: number;
}
export type SortOptions =
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
primaryReleaseDateGte?: string;
primaryReleaseDateLte?: string;
withRuntimeGte?: string;
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
originalLanguage?: string;
genre?: number;
studio?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc';
genre?: string;
studio?: string;
keywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
}
interface DiscoverTvOptions {
@@ -63,19 +79,18 @@ interface DiscoverTvOptions {
language?: string;
firstAirDateGte?: string;
firstAirDateLte?: string;
withRuntimeGte?: string;
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
includeEmptyReleaseDate?: boolean;
originalLanguage?: string;
genre?: number;
genre?: string;
network?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
keywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
}
class TheMovieDb extends ExternalAPI {
@@ -237,7 +252,7 @@ class TheMovieDb extends ExternalAPI {
params: {
language,
append_to_response:
'credits,external_ids,videos,release_dates,watch/providers',
'credits,external_ids,videos,keywords,release_dates,watch/providers',
},
},
43200
@@ -440,8 +455,25 @@ class TheMovieDb extends ExternalAPI {
originalLanguage,
genre,
studio,
keywords,
withRuntimeGte,
withRuntimeLte,
voteAverageGte,
voteAverageLte,
watchProviders,
watchRegion,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const defaultFutureDate = new Date(
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
)
.toISOString()
.split('T')[0];
const defaultPastDate = new Date('1900-01-01')
.toISOString()
.split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: {
sort_by: sortBy,
@@ -449,11 +481,31 @@ class TheMovieDb extends ExternalAPI {
include_adult: includeAdult,
language,
region: this.region,
with_original_language: originalLanguage ?? this.originalLanguage,
'primary_release_date.gte': primaryReleaseDateGte,
'primary_release_date.lte': primaryReleaseDateLte,
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'primary_release_date.gte':
!primaryReleaseDateGte && primaryReleaseDateLte
? defaultPastDate
: primaryReleaseDateGte,
'primary_release_date.lte':
!primaryReleaseDateLte && primaryReleaseDateGte
? defaultFutureDate
: primaryReleaseDateLte,
with_genres: genre,
with_companies: studio,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
watch_region: watchRegion,
with_watch_providers: watchProviders,
},
});
@@ -473,20 +525,57 @@ class TheMovieDb extends ExternalAPI {
originalLanguage,
genre,
network,
keywords,
withRuntimeGte,
withRuntimeLte,
voteAverageGte,
voteAverageLte,
watchProviders,
watchRegion,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const defaultFutureDate = new Date(
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
)
.toISOString()
.split('T')[0];
const defaultPastDate = new Date('1900-01-01')
.toISOString()
.split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: {
sort_by: sortBy,
page,
language,
region: this.region,
'first_air_date.gte': firstAirDateGte,
'first_air_date.lte': firstAirDateLte,
with_original_language: originalLanguage ?? this.originalLanguage,
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'first_air_date.gte':
!firstAirDateGte && firstAirDateLte
? defaultPastDate
: firstAirDateGte,
'first_air_date.lte':
!firstAirDateLte && firstAirDateGte
? defaultFutureDate
: firstAirDateLte,
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
include_null_first_air_dates: includeEmptyReleaseDate,
with_genres: genre,
with_networks: network,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
with_watch_providers: watchProviders,
watch_region: watchRegion,
},
});
@@ -874,6 +963,152 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
}
}
public async getKeywordDetails({
keywordId,
}: {
keywordId: number;
}): Promise<TmdbKeyword> {
try {
const data = await this.get<TmdbKeyword>(
`/keyword/${keywordId}`,
undefined,
604800 // 7 days
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
}
}
public async searchKeyword({
query,
page = 1,
}: {
query: string;
page?: number;
}): Promise<TmdbKeywordSearchResponse> {
try {
const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword',
{
params: {
query,
page,
},
},
86400 // 24 hours
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`);
}
}
public async searchCompany({
query,
page = 1,
}: {
query: string;
page?: number;
}): Promise<TmdbCompanySearchResponse> {
try {
const data = await this.get<TmdbCompanySearchResponse>(
'/search/company',
{
params: {
query,
page,
},
},
86400 // 24 hours
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
}
}
public async getAvailableWatchProviderRegions({
language,
}: {
language?: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions',
{
params: {
language: language ?? this.originalLanguage,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch available watch regions: ${e.message}`
);
}
}
public async getMovieWatchProviders({
language,
watchRegion,
}: {
language?: string;
watchRegion: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
);
}
}
public async getTvWatchProviders({
language,
watchRegion,
}: {
language?: string;
watchRegion: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
);
}
}
}
export default TheMovieDb;

View File

@@ -171,6 +171,9 @@ export interface TmdbMovieDetails {
id: number;
results?: { [iso_3166_1: string]: TmdbWatchProviders };
};
keywords: {
keywords: TmdbKeyword[];
};
}
export interface TmdbVideo {
@@ -191,7 +194,7 @@ export interface TmdbVideo {
export interface TmdbTvEpisodeResult {
id: number;
air_date: string;
air_date: string | null;
episode_number: number;
name: string;
overview: string;
@@ -372,7 +375,8 @@ export interface TmdbPersonCombinedCredits {
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
export interface TmdbSeasonWithEpisodes
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}
@@ -427,3 +431,24 @@ export interface TmdbWatchProviderDetails {
provider_id: number;
provider_name: string;
}
export interface TmdbKeywordSearchResponse extends TmdbPaginatedResponse {
results: TmdbKeyword[];
}
// We have production companies, but the company search results return less data
export interface TmdbCompany {
id: number;
logo_path?: string;
name: string;
}
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
results: TmdbCompany[];
}
export interface TmdbWatchProviderRegion {
iso_3166_1: string;
english_name: string;
native_name: string;
}

View File

@@ -0,0 +1,98 @@
import type DiscoverSlider from '@server/entity/DiscoverSlider';
export enum DiscoverSliderType {
RECENTLY_ADDED = 1,
RECENT_REQUESTS,
PLEX_WATCHLIST,
TRENDING,
POPULAR_MOVIES,
MOVIE_GENRES,
UPCOMING_MOVIES,
STUDIOS,
POPULAR_TV,
TV_GENRES,
UPCOMING_TV,
NETWORKS,
TMDB_MOVIE_KEYWORD,
TMDB_MOVIE_GENRE,
TMDB_TV_KEYWORD,
TMDB_TV_GENRE,
TMDB_SEARCH,
TMDB_STUDIO,
TMDB_NETWORK,
}
export const defaultSliders: Partial<DiscoverSlider>[] = [
{
type: DiscoverSliderType.RECENTLY_ADDED,
enabled: true,
isBuiltIn: true,
order: 0,
},
{
type: DiscoverSliderType.RECENT_REQUESTS,
enabled: true,
isBuiltIn: true,
order: 1,
},
{
type: DiscoverSliderType.PLEX_WATCHLIST,
enabled: true,
isBuiltIn: true,
order: 2,
},
{
type: DiscoverSliderType.TRENDING,
enabled: true,
isBuiltIn: true,
order: 3,
},
{
type: DiscoverSliderType.POPULAR_MOVIES,
enabled: true,
isBuiltIn: true,
order: 4,
},
{
type: DiscoverSliderType.MOVIE_GENRES,
enabled: true,
isBuiltIn: true,
order: 5,
},
{
type: DiscoverSliderType.UPCOMING_MOVIES,
enabled: true,
isBuiltIn: true,
order: 6,
},
{
type: DiscoverSliderType.STUDIOS,
enabled: true,
isBuiltIn: true,
order: 7,
},
{
type: DiscoverSliderType.POPULAR_TV,
enabled: true,
isBuiltIn: true,
order: 8,
},
{
type: DiscoverSliderType.TV_GENRES,
enabled: true,
isBuiltIn: true,
order: 9,
},
{
type: DiscoverSliderType.UPCOMING_TV,
enabled: true,
isBuiltIn: true,
order: 10,
},
{
type: DiscoverSliderType.NETWORKS,
enabled: true,
isBuiltIn: true,
order: 11,
},
];

View File

@@ -34,7 +34,7 @@ const dataSource = new DataSource(
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
);
export const getRepository = <Entity>(
export const getRepository = <Entity extends object>(
target: EntityTarget<Entity>
): Repository<Entity> => {
return dataSource.getRepository(target);

View File

@@ -0,0 +1,69 @@
import type { DiscoverSliderType } from '@server/constants/discover';
import { defaultSliders } from '@server/constants/discover';
import { getRepository } from '@server/datasource';
import logger from '@server/logger';
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
class DiscoverSlider {
public static async bootstrapSliders(): Promise<void> {
const sliderRepository = getRepository(DiscoverSlider);
for (const slider of defaultSliders) {
const existingSlider = await sliderRepository.findOne({
where: {
type: slider.type,
},
});
if (!existingSlider) {
logger.info('Creating built-in discovery slider', {
label: 'Discover Slider',
slider,
});
await sliderRepository.save(new DiscoverSlider(slider));
}
}
}
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'int' })
public type: DiscoverSliderType;
@Column({ type: 'int' })
public order: number;
@Column({ default: false })
public isBuiltIn: boolean;
@Column({ default: true })
public enabled: boolean;
@Column({ nullable: true })
// Title is not required for built in sliders because we will
// use translations for them.
public title?: string;
@Column({ nullable: true })
public data?: string;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<DiscoverSlider>) {
Object.assign(this, init);
}
}
export default DiscoverSlider;

View File

@@ -115,29 +115,29 @@ class Media {
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true })
public serviceId?: number;
@Column({ nullable: true, type: 'int' })
public serviceId?: number | null;
@Column({ nullable: true })
public serviceId4k?: number;
@Column({ nullable: true, type: 'int' })
public serviceId4k?: number | null;
@Column({ nullable: true })
public externalServiceId?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId?: number | null;
@Column({ nullable: true })
public externalServiceId4k?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId4k?: number | null;
@Column({ nullable: true })
public externalServiceSlug?: string;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug?: string | null;
@Column({ nullable: true })
public externalServiceSlug4k?: string;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug4k?: string | null;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey?: string | null;
@Column({ nullable: true })
public ratingKey4k?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string | null;
@Column({ nullable: true })
public jellyfinMediaId?: string;
@@ -200,15 +200,20 @@ class Media {
const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
}
if (this.jellyfinMediaId4k) {
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
}
}
}
@@ -283,7 +288,9 @@ class Media {
if (this.mediaType === MediaType.MOVIE) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getMovieProgress(
this.serviceId,
@@ -293,7 +300,9 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) {
this.downloadStatus4k = downloadTracker.getMovieProgress(
this.serviceId4k,
@@ -305,7 +314,9 @@ class Media {
if (this.mediaType === MediaType.TV) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getSeriesProgress(
this.serviceId,
@@ -315,7 +326,9 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) {
this.downloadStatus4k = downloadTracker.getSeriesProgress(
this.serviceId4k,

View File

@@ -767,7 +767,16 @@ export class MediaRequest {
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
throw new Error('Media already available');
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
}
const radarrMovieOptions: RadarrMovieOptions = {
@@ -908,7 +917,16 @@ export class MediaRequest {
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
throw new Error('Media already available');
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
}
const tmdb = new TheMovieDb();
@@ -1169,3 +1187,5 @@ export class MediaRequest {
}
}
}
export default MediaRequest;

View File

@@ -1,5 +1,7 @@
import { MediaRequestStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import {
AfterRemove,
Column,
CreateDateColumn,
Entity,
@@ -34,6 +36,18 @@ class SeasonRequest {
constructor(init?: Partial<SeasonRequest>) {
Object.assign(this, init);
}
@AfterRemove()
public async handleRemoveParent(): Promise<void> {
const mediaRequestRepository = getRepository(MediaRequest);
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
where: { id: this.request.id },
});
if (requestToBeDeleted.seasons.length === 0) {
await mediaRequestRepository.delete({ id: this.request.id });
}
}
}
export default SeasonRequest;

View File

@@ -39,7 +39,7 @@ export class User {
return users.map((u) => u.filter(showFiltered));
}
static readonly filteredFields: string[] = ['email'];
static readonly filteredFields: string[] = ['email', 'plexId'];
public displayName: string;
@@ -76,7 +76,7 @@ export class User {
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@Column({ nullable: true })
@Column({ nullable: true, select: true })
public plexId?: number;
@Column({ nullable: true })

View File

@@ -1,5 +1,6 @@
import PlexAPI from '@server/api/plexapi';
import dataSource, { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
@@ -16,7 +17,9 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
@@ -104,6 +107,9 @@ app
);
}
// Bootstrap Discovery Sliders
await DiscoverSlider.bootstrapSliders();
const server = express();
if (settings.main.trustProxy) {
server.enable('trust proxy');
@@ -186,6 +192,10 @@ app
next();
});
server.use('/api/v1', routes);
// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.get('*', (req, res) => handle(req, res));
server.use(
(

View File

@@ -54,6 +54,11 @@ export interface CacheItem {
};
}
export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
}
export interface StatusResponse {
version: string;
commitTag: string;

View File

@@ -257,8 +257,19 @@ class JobJellyfinSync {
//use for loop to make sure this loop _completes_ in full
//before the next section
for (const episode of episodes) {
let episodeCount = 1;
// count number of combined episodes
if (
episode.IndexNumber !== undefined &&
episode.IndexNumberEnd !== undefined
) {
episodeCount =
episode.IndexNumberEnd - episode.IndexNumber + 1;
}
if (!this.enable4kShow) {
totalStandard++;
totalStandard += episodeCount;
} else {
const ExtendedEpisodeData = await this.jfClient.getItemData(
episode.Id
@@ -267,11 +278,11 @@ class JobJellyfinSync {
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => {
if (MediaStream.Type === 'Video') {
if (MediaStream.Width ?? 0 < 2000) {
totalStandard++;
if ((MediaStream.Width ?? 0) >= 2000) {
total4k += episodeCount;
} else {
totalStandard += episodeCount;
}
} else {
total4k++;
}
});
});
@@ -300,13 +311,13 @@ class JobJellyfinSync {
// setting the status to AVAILABLE if all of a type is there, partially if some,
// and then not modifying the status if there are 0 items
existingSeason.status =
totalStandard === season.episode_count
totalStandard >= season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
this.enable4kShow && total4k === season.episode_count
this.enable4kShow && total4k >= season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
@@ -318,13 +329,13 @@ class JobJellyfinSync {
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
// if we dont have any items for the season
status:
totalStandard === season.episode_count
totalStandard >= season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow && total4k === season.episode_count
this.enable4kShow && total4k >= season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE

View File

@@ -1,5 +1,6 @@
import { MediaServerType } from '@server/constants/server';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr';
@@ -15,7 +16,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
running?: () => boolean;
cancelFn?: () => void;
@@ -33,7 +34,7 @@ export const startJobs = (): void => {
id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan',
type: 'process',
interval: 'short',
interval: 'minutes',
cronSchedule: jobs['plex-recently-added-scan'].schedule,
job: schedule.scheduleJob(
jobs['plex-recently-added-scan'].schedule,
@@ -53,7 +54,7 @@ export const startJobs = (): void => {
id: 'plex-full-scan',
name: 'Plex Full Library Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['plex-full-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Full Library Scan', {
@@ -73,7 +74,7 @@ export const startJobs = (): void => {
id: 'jellyfin-recently-added-sync',
name: 'Jellyfin Recently Added Sync',
type: 'process',
interval: 'long',
interval: 'minutes',
cronSchedule: jobs['jellyfin-recently-added-sync'].schedule,
job: schedule.scheduleJob(
jobs['jellyfin-recently-added-sync'].schedule,
@@ -93,7 +94,7 @@ export const startJobs = (): void => {
id: 'jellyfin-full-sync',
name: 'Jellyfin Full Library Sync',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['jellyfin-full-sync'].schedule,
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
logger.info('Starting scheduled job: Jellyfin Full Sync', {
@@ -111,7 +112,7 @@ export const startJobs = (): void => {
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'long',
interval: 'minutes',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
@@ -126,7 +127,7 @@ export const startJobs = (): void => {
id: 'radarr-scan',
name: 'Radarr Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['radarr-scan'].schedule,
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
@@ -141,7 +142,7 @@ export const startJobs = (): void => {
id: 'sonarr-scan',
name: 'Sonarr Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['sonarr-scan'].schedule,
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
@@ -151,12 +152,30 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});
// Checks if media is still available in plex/sonarr/radarr libs
/* scheduledJobs.push({
id: 'availability-sync',
name: 'Media Availability Sync',
type: 'process',
interval: 'hours',
cronSchedule: jobs['availability-sync'].schedule,
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
logger.info('Starting scheduled job: Media Availability Sync', {
label: 'Jobs',
});
availabilitySync.run();
}),
running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(),
});
*/
// Run download sync every minute
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
interval: 'fixed',
interval: 'seconds',
cronSchedule: jobs['download-sync'].schedule,
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
logger.debug('Starting scheduled job: Download Sync', {
@@ -171,7 +190,7 @@ export const startJobs = (): void => {
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['download-sync-reset'].schedule,
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
logger.info('Starting scheduled job: Download Sync Reset', {
@@ -181,5 +200,21 @@ export const startJobs = (): void => {
}),
});
// Run image cache cleanup every 24 hours
scheduledJobs.push({
id: 'image-cache-cleanup',
name: 'Image Cache Cleanup',
type: 'process',
interval: 'hours',
cronSchedule: jobs['image-cache-cleanup'].schedule,
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
logger.info('Starting scheduled job: Image Cache Cleanup', {
label: 'Jobs',
});
// Clean TMDB image cache
ImageProxy.clearCache('tmdb');
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

View File

@@ -0,0 +1,718 @@
import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import RadarrAPI from '@server/api/servarr/radarr';
import type { SonarrSeason } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import MediaRequest from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
class AvailabilitySync {
public running = false;
private plexClient: PlexAPI;
private plexSeasonsCache: Record<string, PlexMetadata[]> = {};
private sonarrSeasonsCache: Record<string, SonarrSeason[]> = {};
private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[];
async run() {
const settings = getSettings();
this.running = true;
this.plexSeasonsCache = {};
this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
await this.initPlexClient();
if (!this.plexClient) {
return;
}
logger.info(`Starting availability sync...`, {
label: 'AvailabilitySync',
});
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const seasonRepository = getRepository(Season);
const seasonRequestRepository = getRepository(SeasonRequest);
const pageSize = 50;
try {
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
try {
if (!this.running) {
throw new Error('Job aborted');
}
const mediaExists = await this.mediaExists(media);
//We can not delete media so if both versions do not exist, we will change both columns to unknown or null
if (!mediaExists) {
if (
media.status !== MediaStatus.UNKNOWN ||
media.status4k !== MediaStatus.UNKNOWN
) {
const request = await requestRepository.find({
relations: {
media: true,
},
where: { media: { id: media.id } },
});
logger.info(
`${
media.mediaType === 'tv' ? media.tvdbId : media.tmdbId
} does not exist in any of your media instances. We will change its status to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
serviceId: null,
serviceId4k: null,
externalServiceId: null,
externalServiceId4k: null,
externalServiceSlug: null,
externalServiceSlug4k: null,
ratingKey: null,
ratingKey4k: null,
});
await requestRepository.remove(request);
}
}
if (media.mediaType === 'tv') {
// ok, the show itself exists, but do all it's seasons?
const seasons = await seasonRepository.find({
where: [
{ status: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
{ status4k: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status4k: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
],
});
let didDeleteSeasons = false;
for (const season of seasons) {
if (
!mediaExists &&
(season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN)
) {
await seasonRepository.update(
{ id: season.id },
{
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}
);
} else {
const seasonExists = await this.seasonExists(media, season);
if (!seasonExists) {
logger.info(
`Removing season ${season.seasonNumber}, media id: ${media.tvdbId} because it does not exist in any of your media instances.`,
{ label: 'AvailabilitySync' }
);
if (
season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN
) {
await seasonRepository.update(
{ id: season.id },
{
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}
);
}
const seasonToBeDeleted =
await seasonRequestRepository.findOne({
relations: {
request: {
media: true,
},
},
where: {
request: {
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
didDeleteSeasons = true;
}
}
if (didDeleteSeasons) {
if (
media.status === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.AVAILABLE
) {
logger.info(
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted some of its seasons.`,
{ label: 'AvailabilitySync' }
);
if (media.status === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE,
});
}
if (media.status4k === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
}
}
} catch (ex) {
logger.error('Failure with media.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
}
} catch (ex) {
logger.error('Failed to complete availability sync.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
} finally {
logger.info(`Availability sync complete.`, {
label: 'AvailabilitySync',
});
this.running = false;
}
}
public cancel() {
this.running = false;
}
private async *loadAvailableMediaPaginated(pageSize: number) {
let offset = 0;
const mediaRepository = getRepository(Media);
const whereOptions = [
{ status: MediaStatus.AVAILABLE },
{ status: MediaStatus.PARTIALLY_AVAILABLE },
{ status4k: MediaStatus.AVAILABLE },
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
];
let mediaPage: Media[];
do {
yield* (mediaPage = await mediaRepository.find({
where: whereOptions,
skip: offset,
take: pageSize,
}));
offset += pageSize;
} while (mediaPage.length > 0);
}
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const isTVType = media.mediaType === 'tv';
const request = await requestRepository.findOne({
relations: {
media: true,
},
where: { media: { id: media.id }, is4k: is4k ? true : false },
});
logger.info(
`${media.tmdbId} does not exist in your ${is4k ? '4k' : 'non-4k'} ${
isTVType ? 'sonarr' : 'radarr'
} and plex instance. We will change its status to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(
media.id,
is4k
? {
status4k: MediaStatus.UNKNOWN,
serviceId4k: null,
externalServiceId4k: null,
externalServiceSlug4k: null,
ratingKey4k: null,
}
: {
status: MediaStatus.UNKNOWN,
serviceId: null,
externalServiceId: null,
externalServiceSlug: null,
ratingKey: null,
}
);
if (isTVType) {
const seasonRepository = getRepository(Season);
await seasonRepository?.update(
{ media: { id: media.id } },
is4k
? { status4k: MediaStatus.UNKNOWN }
: { status: MediaStatus.UNKNOWN }
);
}
await requestRepository.delete({ id: request?.id });
}
private async mediaExistsInRadarr(
media: Media,
existsInPlex: boolean,
existsInPlex4k: boolean
): Promise<boolean> {
let existsInRadarr = true;
let existsInRadarr4k = true;
for (const server of this.radarrServers) {
const api = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
const meta = await api.getMovieByTmdbId(media.tmdbId);
//check if both exist or if a single non-4k or 4k exists
//if both do not exist we will return false
if (!server.is4k && !meta.id) {
existsInRadarr = false;
}
if (server.is4k && !meta.id) {
existsInRadarr4k = false;
}
}
if (existsInRadarr && existsInRadarr4k) {
return true;
}
if (!existsInRadarr && existsInPlex) {
return true;
}
if (!existsInRadarr4k && existsInPlex4k) {
return true;
}
//if only a single non-4k or 4k exists, then change entity columns accordingly
//related media request will then be deleted
if (!existsInRadarr && existsInRadarr4k && !existsInPlex) {
if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false);
}
}
if (existsInRadarr && !existsInRadarr4k && !existsInPlex4k) {
if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true);
}
}
if (existsInRadarr || existsInRadarr4k) {
return true;
}
return false;
}
private async mediaExistsInSonarr(
media: Media,
existsInPlex: boolean,
existsInPlex4k: boolean
): Promise<boolean> {
if (!media.tvdbId) {
return false;
}
let existsInSonarr = true;
let existsInSonarr4k = true;
for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const meta = await api.getSeriesByTvdbId(media.tvdbId);
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = meta.seasons;
//check if both exist or if a single non-4k or 4k exists
//if both do not exist we will return false
if (!server.is4k && !meta.id) {
existsInSonarr = false;
}
if (server.is4k && !meta.id) {
existsInSonarr4k = false;
}
}
if (existsInSonarr && existsInSonarr4k) {
return true;
}
if (!existsInSonarr && existsInPlex) {
return true;
}
if (!existsInSonarr4k && existsInPlex4k) {
return true;
}
//if only a single non-4k or 4k exists, then change entity columns accordingly
//related media request will then be deleted
if (!existsInSonarr && existsInSonarr4k && !existsInPlex) {
if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false);
}
}
if (existsInSonarr && !existsInSonarr4k && !existsInPlex4k) {
if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true);
}
}
if (existsInSonarr || existsInSonarr4k) {
return true;
}
return false;
}
private async seasonExistsInSonarr(
media: Media,
season: Season,
seasonExistsInPlex: boolean,
seasonExistsInPlex4k: boolean
): Promise<boolean> {
if (!media.tvdbId) {
return false;
}
let seasonExistsInSonarr = true;
let seasonExistsInSonarr4k = true;
const mediaRepository = getRepository(Media);
const seasonRepository = getRepository(Season);
const seasonRequestRepository = getRepository(SeasonRequest);
for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const seasons =
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] ??
(await api.getSeriesByTvdbId(media.tvdbId)).seasons;
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = seasons;
const hasMonitoredSeason = seasons.find(
({ monitored, seasonNumber }) =>
monitored && season.seasonNumber === seasonNumber
);
if (!server.is4k && !hasMonitoredSeason) {
seasonExistsInSonarr = false;
}
if (server.is4k && !hasMonitoredSeason) {
seasonExistsInSonarr4k = false;
}
}
if (seasonExistsInSonarr && seasonExistsInSonarr4k) {
return true;
}
if (!seasonExistsInSonarr && seasonExistsInPlex) {
return true;
}
if (!seasonExistsInSonarr4k && seasonExistsInPlex4k) {
return true;
}
const seasonToBeDeleted = await seasonRequestRepository.findOne({
relations: {
request: {
media: true,
},
},
where: {
request: {
is4k: seasonExistsInSonarr ? true : false,
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
});
//if season does not exist, we will change status to unknown and delete related season request
//if parent media request is empty(all related seasons have been removed), parent is automatically deleted
if (
!seasonExistsInSonarr &&
seasonExistsInSonarr4k &&
!seasonExistsInPlex
) {
if (season.status !== MediaStatus.UNKNOWN) {
logger.info(
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your non-4k sonarr and plex instance. We will change its status to unknown.`,
{ label: 'AvailabilitySync' }
);
await seasonRepository.update(season.id, {
status: MediaStatus.UNKNOWN,
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
if (media.status === MediaStatus.AVAILABLE) {
logger.info(
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
if (
seasonExistsInSonarr &&
!seasonExistsInSonarr4k &&
!seasonExistsInPlex4k
) {
if (season.status4k !== MediaStatus.UNKNOWN) {
logger.info(
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your 4k sonarr and plex instance. We will change its status to unknown.`,
{ label: 'AvailabilitySync' }
);
await seasonRepository.update(season.id, {
status4k: MediaStatus.UNKNOWN,
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
if (media.status4k === MediaStatus.AVAILABLE) {
logger.info(
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
if (seasonExistsInSonarr || seasonExistsInSonarr4k) {
return true;
}
return false;
}
private async mediaExists(media: Media): Promise<boolean> {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let existsInPlex = false;
let existsInPlex4k = false;
//check each plex instance to see if media exists
try {
if (ratingKey) {
const meta = await this.plexClient?.getMetadata(ratingKey);
if (meta) {
existsInPlex = true;
}
}
if (ratingKey4k) {
const meta4k = await this.plexClient?.getMetadata(ratingKey4k);
if (meta4k) {
existsInPlex4k = true;
}
}
} catch (ex) {
// TODO: oof, not the nicest way of handling this, but plex-api does not leave us with any other options...
if (!ex.message.includes('response code: 404')) {
throw ex;
}
}
//base case for if both media versions exist in plex
if (existsInPlex && existsInPlex4k) {
return true;
}
//we then check radarr or sonarr has that specific media. If not, then we will move to delete
//if a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
if (media.mediaType === 'movie') {
const existsInRadarr = await this.mediaExistsInRadarr(
media,
existsInPlex,
existsInPlex4k
);
//if true, media exists in at least one radarr or plex instance.
if (existsInRadarr) {
logger.warn(
`${media.tmdbId} exists in at least one radarr or plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}
if (media.mediaType === 'tv') {
const existsInSonarr = await this.mediaExistsInSonarr(
media,
existsInPlex,
existsInPlex4k
);
//if true, media exists in at least one sonarr or plex instance.
if (existsInSonarr) {
logger.warn(
`${media.tvdbId} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}
return false;
}
private async seasonExists(media: Media, season: Season) {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let seasonExistsInPlex = false;
let seasonExistsInPlex4k = false;
if (ratingKey) {
const children =
this.plexSeasonsCache[ratingKey] ??
(await this.plexClient?.getChildrenMetadata(ratingKey)) ??
[];
this.plexSeasonsCache[ratingKey] = children;
const seasonMeta = children?.find(
(child) => child.index === season.seasonNumber
);
if (seasonMeta) {
seasonExistsInPlex = true;
}
}
if (ratingKey4k) {
const children4k =
this.plexSeasonsCache[ratingKey4k] ??
(await this.plexClient?.getChildrenMetadata(ratingKey4k)) ??
[];
this.plexSeasonsCache[ratingKey4k] = children4k;
const seasonMeta4k = children4k?.find(
(child) => child.index === season.seasonNumber
);
if (seasonMeta4k) {
seasonExistsInPlex4k = true;
}
}
//base case for if both season versions exist in plex
if (seasonExistsInPlex && seasonExistsInPlex4k) {
return true;
}
const existsInSonarr = await this.seasonExistsInSonarr(
media,
season,
seasonExistsInPlex,
seasonExistsInPlex4k
);
if (existsInSonarr) {
logger.warn(
`${media.tvdbId}, season: ${season.seasonNumber} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
return false;
}
private async initPlexClient() {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (!admin) {
logger.warning('No admin configured. Availability sync skipped.');
return;
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
}
}
const availabilitySync = new AvailabilitySync();
export default availabilitySync;

View File

@@ -5,6 +5,12 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { uniqWith } from 'lodash';
interface EpisodeNumberResult {
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber: number;
id: number;
}
export interface DownloadingItem {
mediaType: MediaType;
externalId: number;
@@ -14,6 +20,7 @@ export interface DownloadingItem {
timeLeft: string;
estimatedCompletionTime: Date;
title: string;
episode?: EpisodeNumberResult;
}
class DownloadTracker {
@@ -164,6 +171,7 @@ class DownloadTracker {
status: item.status,
timeLeft: item.timeleft,
title: item.title,
episode: item.episode,
}));
if (queueItems.length > 0) {

266
server/lib/imageproxy.ts Normal file
View File

@@ -0,0 +1,266 @@
import logger from '@server/logger';
import axios from 'axios';
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import { createHash } from 'crypto';
import { promises } from 'fs';
import path, { join } from 'path';
type ImageResponse = {
meta: {
revalidateAfter: number;
curRevalidate: number;
isStale: boolean;
etag: string;
extension: string;
cacheKey: string;
cacheMiss: boolean;
};
imageBuffer: Buffer;
};
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/cache/images`
: path.join(__dirname, '../../config/cache/images');
class ImageProxy {
public static async clearCache(key: string) {
let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key);
const files = await promises.readdir(cacheDirectory);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile));
deletedImages += 1;
}
}
}
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
label: 'Image Cache',
});
}
public static async getImageStats(
key: string
): Promise<{ size: number; imageCount: number }> {
const cacheDirectory = path.join(baseCacheDirectory, key);
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
return {
size: imageTotalSize,
imageCount,
};
}
private static async getDirectorySize(dir: string): Promise<number> {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
const paths = files.map(async (file) => {
const path = join(dir, file.name);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isFile()) {
const { size } = await promises.stat(path);
return size;
}
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
}
private static async getImageCount(dir: string) {
const files = await promises.readdir(dir);
return files.length;
}
private axios;
private cacheVersion;
private key;
constructor(
key: string,
baseUrl: string,
options: {
cacheVersion?: number;
rateLimitOptions?: rateLimitOptions;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
this.key = key;
this.axios = axios.create({
baseURL: baseUrl,
});
if (options.rateLimitOptions) {
this.axios = rateLimit(this.axios, options.rateLimitOptions);
}
}
public async getImage(path: string): Promise<ImageResponse> {
const cacheKey = this.getCacheKey(path);
const imageResponse = await this.get(cacheKey);
if (!imageResponse) {
const newImage = await this.set(path, cacheKey);
if (!newImage) {
throw new Error('Failed to load image');
}
return newImage;
}
// If the image is stale, we will revalidate it in the background.
if (imageResponse.meta.isStale) {
this.set(path, cacheKey);
}
return imageResponse;
}
private async get(cacheKey: string): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
const now = Date.now();
for (const file of files) {
const [maxAgeSt, expireAtSt, etag, extension] = file.split('.');
const buffer = await promises.readFile(join(directory, file));
const expireAt = Number(expireAtSt);
const maxAge = Number(maxAgeSt);
return {
meta: {
curRevalidate: maxAge,
revalidateAfter: maxAge * 1000 + now,
isStale: now > expireAt,
etag,
extension,
cacheKey,
cacheMiss: false,
},
imageBuffer: buffer,
};
}
} catch (e) {
// No files. Treat as empty cache.
}
return null;
}
private async set(
path: string,
cacheKey: string
): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const response = await this.axios.get(path, {
responseType: 'arraybuffer',
});
const buffer = Buffer.from(response.data, 'binary');
const extension = path.split('.').pop() ?? '';
const maxAge = Number(
(response.headers['cache-control'] ?? '0').split('=')[1]
);
const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.etag ?? '').replace(/"/g, '');
await this.writeToCacheDir(
directory,
extension,
maxAge,
expireAt,
buffer,
etag
);
return {
meta: {
curRevalidate: maxAge,
revalidateAfter: expireAt,
isStale: false,
etag,
extension,
cacheKey,
cacheMiss: true,
},
imageBuffer: buffer,
};
} catch (e) {
logger.debug('Something went wrong caching image.', {
label: 'Image Cache',
errorMessage: e.message,
});
return null;
}
}
private async writeToCacheDir(
dir: string,
extension: string,
maxAge: number,
expireAt: number,
buffer: Buffer,
etag: string
) {
const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`);
await promises.rm(dir, { force: true, recursive: true }).catch(() => {
// do nothing
});
await promises.mkdir(dir, { recursive: true });
await promises.writeFile(filename, buffer);
}
private getCacheKey(path: string) {
return this.getHash([this.key, this.cacheVersion, path]);
}
private getHash(items: (string | number | Buffer)[]) {
const hash = createHash('sha256');
for (const item of items) {
if (typeof item === 'number') hash.update(String(item));
else {
hash.update(item);
}
}
// See https://en.wikipedia.org/wiki/Base64#Filenames
return hash.digest('base64').replace(/\//g, '-');
}
private getCacheDirectory() {
return path.join(baseCacheDirectory, this.key);
}
}
export default ImageProxy;

View File

@@ -96,7 +96,8 @@ class PlexScanner
// We remove 10 minutes from the last scan as a buffer
addedAt: library.lastScan - 1000 * 60 * 10,
}
: undefined
: undefined,
library.type
);
// Bundle items up by rating keys

View File

@@ -38,7 +38,7 @@ export interface PlexSettings {
export interface JellyfinSettings {
name: string;
hostname?: string;
hostname: string;
externalHostname?: string;
libraries: Library[];
serverId: string;
@@ -263,7 +263,9 @@ export type JobId =
| 'download-sync'
| 'download-sync-reset'
| 'jellyfin-recently-added-sync'
| 'jellyfin-full-sync';
| 'jellyfin-full-sync'
| 'image-cache-cleanup'
| 'availability-sync';
interface AllSettings {
clientId: string;
@@ -434,6 +436,9 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},
'download-sync': {
schedule: '0 * * * * *',
},
@@ -446,6 +451,9 @@ class Settings {
'jellyfin-full-sync': {
schedule: '0 0 3 * * *',
},
'image-cache-cleanup': {
schedule: '0 0 5 * * *',
},
},
};
if (initialSettings) {
@@ -586,7 +594,7 @@ class Settings {
}
private generateApiKey(): string {
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
}
private generateVapidKeys(force = false): void {

View File

@@ -0,0 +1,6 @@
const clearCookies: Middleware = (_req, res, next) => {
res.removeHeader('Set-Cookie');
next();
};
export default clearCookies;

View File

@@ -0,0 +1,15 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDiscoverSlider1672041273674 implements MigrationInterface {
name = 'AddDiscoverSlider1672041273674';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "discover_slider"`);
}
}

View File

@@ -9,6 +9,7 @@ import type {
Crew,
ExternalIds,
Genre,
Keyword,
ProductionCompany,
WatchProviders,
} from './common';
@@ -83,6 +84,7 @@ export interface MovieDetails {
externalIds: ExternalIds;
mediaUrl?: string;
watchProviders?: WatchProviders[];
keywords: Keyword[];
}
export const mapProductionCompany = (
@@ -142,4 +144,8 @@ export const mapMovieDetails = (
externalIds: mapExternalIds(movie.external_ids),
mediaInfo: media,
watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}),
keywords: movie.keywords.keywords.map((keyword) => ({
id: keyword.id,
name: keyword.name,
})),
});

View File

@@ -29,7 +29,7 @@ import type { Video } from './Movie';
interface Episode {
id: number;
name: string;
airDate: string;
airDate: string | null;
episodeNumber: number;
overview: string;
productionCode: string;
@@ -50,7 +50,7 @@ interface Season {
seasonNumber: number;
}
export interface SeasonWithEpisodes extends Season {
export interface SeasonWithEpisodes extends Omit<Season, 'episodeCount'> {
episodes: Episode[];
externalIds: ExternalIds;
}
@@ -141,7 +141,6 @@ export const mapSeasonWithEpisodes = (
season: TmdbSeasonWithEpisodes
): SeasonWithEpisodes => ({
airDate: season.air_date,
episodeCount: season.episode_count,
episodes: season.episodes.map(mapEpisodeResult),
externalIds: mapExternalIds(season.external_ids),
id: season.id,

View File

@@ -89,13 +89,28 @@ authRoutes.post('/plex', async (req, res, next) => {
await userRepository.save(user);
} else {
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, plexId: true },
select: { id: true, plexToken: true, plexId: true, email: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (!account.id) {
logger.error('Plex ID was missing from Plex.tv response', {
label: 'API',
ip: req.ip,
email: account.email,
plexUsername: account.username,
});
return next({
status: 500,
message: 'Something went wrong. Try again.',
});
}
if (
account.id === mainUser.plexId ||
(account.email === mainUser.email && !mainUser.plexId) ||
(await mainPlexTv.checkUserAccess(account.id))
) {
if (user) {
@@ -226,7 +241,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
const hostname =
settings.jellyfin.hostname !== ''
? settings.jellyfin.hostname
: body.hostname;
: body.hostname ?? '';
const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -244,11 +259,15 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}
// First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const account = await jellyfinserver.login(body.username, body.password);
// Next let's see if the user already exists
user = await userRepository.findOne({

View File

@@ -1,5 +1,7 @@
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
@@ -20,6 +22,7 @@ import { mapNetwork } from '@server/models/Tv';
import { isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
import { z } from 'zod';
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const settings = getSettings();
@@ -46,25 +49,76 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const discoverRoutes = Router();
const QueryFilterOptions = z.object({
page: z.coerce.string().optional(),
sortBy: z.coerce.string().optional(),
primaryReleaseDateGte: z.coerce.string().optional(),
primaryReleaseDateLte: z.coerce.string().optional(),
firstAirDateGte: z.coerce.string().optional(),
firstAirDateLte: z.coerce.string().optional(),
studio: z.coerce.string().optional(),
genre: z.coerce.string().optional(),
keywords: z.coerce.string().optional(),
language: z.coerce.string().optional(),
withRuntimeGte: z.coerce.string().optional(),
withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(),
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
discoverRoutes.get('/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined,
studio: req.query.studio ? Number(req.query.studio) : undefined,
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
originalLanguage: query.language,
genre: query.genre,
studio: query.studio,
primaryReleaseDateLte: query.primaryReleaseDateLte
? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0]
: undefined,
primaryReleaseDateGte: query.primaryReleaseDateGte
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
: undefined,
keywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapMovieResult(
result,
@@ -163,7 +217,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId),
genre: req.params.genreId as string,
});
const media = await Media.getRelatedMedia(
@@ -210,7 +264,7 @@ discoverRoutes.get<{ studioId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
studio: Number(req.params.studioId),
studio: req.params.studioId as string,
});
const media = await Media.getRelatedMedia(
@@ -296,21 +350,50 @@ discoverRoutes.get('/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined,
network: req.query.network ? Number(req.query.network) : undefined,
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
genre: query.genre,
network: query.network ? Number(query.network) : undefined,
firstAirDateLte: query.firstAirDateLte
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
: undefined,
firstAirDateGte: query.firstAirDateGte
? new Date(query.firstAirDateGte).toISOString().split('T')[0]
: undefined,
originalLanguage: query.language,
keywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapTvResult(
result,
@@ -408,7 +491,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: Number(req.params.genreId),
genre: req.params.genreId,
});
const media = await Media.getRelatedMedia(
@@ -643,7 +726,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverMovies({ genre: genre.id });
const genreData = await tmdb.getDiscoverMovies({
genre: genre.id.toString(),
});
mappedGenres.push({
id: genre.id,
@@ -685,7 +770,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
const genreData = await tmdb.getDiscoverTv({
genre: genre.id.toString(),
});
mappedGenres.push({
id: genre.id,
@@ -713,12 +800,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
'/watchlist',
async (req, res) => {
const userRepository = getRepository(User);
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({
@@ -742,8 +829,8 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,

View File

@@ -0,0 +1,39 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
/**
* Image Proxy
*/
router.get('/*', async (req, res) => {
const imagePath = req.path.replace('/image', '');
try {
const imageData = await tmdbImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy image', {
imagePath,
errorMessage: e.message,
});
res.status(500).send();
}
});
export default router;

View File

@@ -4,11 +4,14 @@ import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import settingsRoutes from '@server/routes/settings';
@@ -102,6 +105,13 @@ router.get('/settings/public', async (req, res) => {
return res.status(200).json(settings.fullPublicSettings);
}
});
router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const sliders = await sliderRepository.find({ order: { order: 'ASC' } });
return res.json(sliders);
});
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
@@ -269,6 +279,87 @@ router.get('/backdrops', async (req, res, next) => {
}
});
router.get('/keyword/:keywordId', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getKeywordDetails({
keywordId: Number(req.params.keywordId),
});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving keyword data', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve keyword data.',
});
}
});
router.get('/watchproviders/regions', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getAvailableWatchProviderRegions({});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving watch provider regions', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve watch provider regions.',
});
}
});
router.get('/watchproviders/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getMovieWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving movie watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve movie watch providers.',
});
}
});
router.get('/watchproviders/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getTvWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving tv watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve tv watch providers.',
});
}
});
router.get('/', (_req, res) => {
return res.status(200).json({
api: 'Overseerr API',

View File

@@ -308,7 +308,9 @@ issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
issueRoutes.post<{ issueId: string; status: string }, Issue>(
'/:issueId/:status',
isAuthenticated(Permission.MANAGE_ISSUES),
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
async (req, res, next) => {
const issueRepository = getRepository(Issue);
// Satisfy typescript here. User is set, we assure you!
@@ -321,6 +323,16 @@ issueRoutes.post<{ issueId: string; status: string }, Issue>(
where: { id: Number(req.params.issueId) },
});
if (
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== req.user?.id
) {
return next({
status: 401,
message: 'You do not have permission to modify this issue.',
});
}
let newStatus: IssueStatus | undefined;
switch (req.params.status) {

View File

@@ -492,8 +492,10 @@ requestRoutes.post<{
relations: { requestedBy: true, modifiedBy: true },
});
await request.updateParentStatus();
await request.sendMedia();
// this also triggers updating the parent media's status & sending to *arr
request.status = MediaRequestStatus.APPROVED;
await requestRepository.save(request);
return res.status(200).json(request);
} catch (e) {
logger.error('Error processing request retry', {

View File

@@ -56,4 +56,50 @@ searchRoutes.get('/', async (req, res, next) => {
}
});
searchRoutes.get('/keyword', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const results = await tmdb.searchKeyword({
query: req.query.query as string,
page: Number(req.query.page),
});
return res.status(200).json(results);
} catch (e) {
logger.debug('Something went wrong retrieving keyword search results', {
label: 'API',
errorMessage: e.message,
query: req.query.query,
});
return next({
status: 500,
message: 'Unable to retrieve keyword search results.',
});
}
});
searchRoutes.get('/company', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const results = await tmdb.searchCompany({
query: req.query.query as string,
page: Number(req.query.page),
});
return res.status(200).json(results);
} catch (e) {
logger.debug('Something went wrong retrieving company search results', {
label: 'API',
errorMessage: e.message,
query: req.query.query,
});
return next({
status: 500,
message: 'Unable to retrieve company search results.',
});
}
});
export default searchRoutes;

View File

@@ -0,0 +1,131 @@
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import logger from '@server/logger';
import { Router } from 'express';
const discoverSettingRoutes = Router();
discoverSettingRoutes.post('/', async (req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const sliders = req.body as DiscoverSlider[];
if (!Array.isArray(sliders)) {
return res.status(400).json({ message: 'Invalid request body.' });
}
for (let x = 0; x < sliders.length; x++) {
const slider = sliders[x];
const existingSlider = await sliderRepository.findOne({
where: {
id: slider.id,
},
});
if (existingSlider && slider.id) {
existingSlider.enabled = slider.enabled;
existingSlider.order = x;
// Only allow changes to the following when the slider is not built in
if (!existingSlider.isBuiltIn) {
existingSlider.title = slider.title;
existingSlider.data = slider.data;
existingSlider.type = slider.type;
}
await sliderRepository.save(existingSlider);
} else {
const newSlider = new DiscoverSlider({
isBuiltIn: false,
data: slider.data,
title: slider.title,
enabled: slider.enabled,
order: x,
type: slider.type,
});
await sliderRepository.save(newSlider);
}
}
return res.json(sliders);
});
discoverSettingRoutes.post('/add', async (req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const slider = req.body as DiscoverSlider;
const newSlider = new DiscoverSlider({
isBuiltIn: false,
data: slider.data,
title: slider.title,
enabled: false,
order: -1,
type: slider.type,
});
await sliderRepository.save(newSlider);
return res.json(newSlider);
});
discoverSettingRoutes.get('/reset', async (_req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
await sliderRepository.clear();
await DiscoverSlider.bootstrapSliders();
return res.status(204).send();
});
discoverSettingRoutes.put('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider);
const slider = req.body as DiscoverSlider;
try {
const existingSlider = await sliderRepository.findOneOrFail({
where: {
id: Number(req.params.sliderId),
},
});
// Only allow changes to the following when the slider is not built in
if (!existingSlider.isBuiltIn) {
existingSlider.title = slider.title;
existingSlider.data = slider.data;
existingSlider.type = slider.type;
}
await sliderRepository.save(existingSlider);
return res.status(200).json(existingSlider);
} catch (e) {
logger.error('Something went wrong updating a slider.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Slider not found or cannot be updated.' });
}
});
discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider);
try {
const slider = await sliderRepository.findOneOrFail({
where: { id: Number(req.params.sliderId), isBuiltIn: false },
});
await sliderRepository.remove(slider);
return res.status(204).send();
} catch (e) {
logger.error('Something went wrong deleting a slider.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Slider not found or cannot be deleted.' });
}
});
export default discoverSettingRoutes;

View File

@@ -16,12 +16,14 @@ import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
import { scheduledJobs } from '@server/job/schedule';
import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { plexFullScanner } from '@server/lib/scanners/plex';
import type { Library, MainSettings } from '@server/lib/settings';
import type { JobId, Library, MainSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { Router } from 'express';
@@ -41,6 +43,7 @@ const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
const filteredMainSettings = (
user: User,
@@ -307,11 +310,14 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings();
const { hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -601,7 +607,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
});
});
settingsRoutes.post<{ jobId: string }>(
settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/cancel',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -628,7 +634,7 @@ settingsRoutes.post<{ jobId: string }>(
}
);
settingsRoutes.post<{ jobId: string }>(
settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -663,16 +669,23 @@ settingsRoutes.post<{ jobId: string }>(
}
);
settingsRoutes.get('/cache', (req, res) => {
const caches = cacheManager.getAllCaches();
settingsRoutes.get('/cache', async (_req, res) => {
const cacheManagerCaches = cacheManager.getAllCaches();
return res.status(200).json(
Object.values(caches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}))
);
const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
},
});
});
settingsRoutes.post<{ cacheId: AvailableCacheIds }>(

View File

@@ -497,11 +497,14 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
const { hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers();
@@ -682,7 +685,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
}
);
router.get<{ id: string; page?: number }, WatchlistResponse>(
router.get<{ id: string }, WatchlistResponse>(
'/:id/watchlist',
async (req, res, next) => {
if (
@@ -702,7 +705,7 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
}
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const user = await getRepository(User).findOneOrFail({
@@ -726,8 +729,8 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,

View File

@@ -4,6 +4,7 @@ import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import IssueComment from '@server/entity/IssueComment';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
@@ -32,6 +33,10 @@ export class IssueCommentSubscriber
})
).issue;
const createdBy = await getRepository(User).findOneOrFail({
where: { id: issue.createdBy.id },
});
const media = await getRepository(Media).findOneOrFail({
where: { id: issue.media.id },
});
@@ -71,9 +76,9 @@ export class IssueCommentSubscriber
notifyAdmin: true,
notifySystem: true,
notifyUser:
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== entity.user.id
? issue.createdBy
!createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
createdBy.id !== entity.user.id
? createdBy
: undefined,
});
}

View File

@@ -87,6 +87,7 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
notifySystem: true,
notifyUser:
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
entity.modifiedBy?.id !== entity.createdBy.id &&
(type === Notification.ISSUE_RESOLVED ||
type === Notification.ISSUE_REOPENED)
? entity.createdBy

9
server/types/express-session.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import 'express-session';
// Declaration merging to apply our own types to SessionData
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
declare module 'express-session' {
interface SessionData {
userId: number;
}
}

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { User } from '@server/entity/User';
import type { NextFunction, Request, Response } from 'express';
import 'express-session';
declare global {
namespace Express {
@@ -16,11 +17,3 @@ declare global {
next: NextFunction
) => Promise<void | NextFunction> | void | NextFunction;
}
// Declaration merging to apply our own types to SessionData
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
declare module 'express-session' {
export interface SessionData {
userId: number;
}
}

View File

@@ -1,10 +1,11 @@
name: overseerr
adopt-info: overseerr
name: jellyseerr
adopt-info: jellyseerr
license: MIT
summary: Request management and media discovery tool for the Plex ecosystem.
summary: Request management and media discovery tool for media servers
description: >
Overseerr is a free and open source software application for managing requests for your media library.
It integrates with your existing services such as Sonarr, Radarr and Plex!
Jellyseerr is a free and open source software application for managing requests for your media library.
It is a a fork of Overseerr built to bring support for & focusing mainly on Jellyfin & Emby media servers!
It integrates with your existing services such as Sonarr, Radarr, and Jellyfin/Emby/Plex.
base: core18
confinement: strict
@@ -14,7 +15,7 @@ architectures:
- build-on: armhf
parts:
overseerr:
jellyseerr:
plugin: nodejs
nodejs-version: '16.17.0'
nodejs-package-manager: 'yarn'
@@ -36,7 +37,7 @@ parts:
override-pull: |
snapcraftctl pull
# Get information to determine snap grade and version
git config --global --add safe.directory /data/parts/overseerr/src
git config --global --add safe.directory /data/parts/jellyyseerr/src
#setup yarn.rc
echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc
BRANCH=$(git rev-parse --abbrev-ref HEAD)

View File

@@ -37,6 +37,7 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})}
</Badge>
{showRelative && (

View File

@@ -10,13 +10,14 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { DownloadIcon } from '@heroicons/react/outline';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import type { Collection } from '@server/models/Collection';
import { uniq } from 'lodash';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
@@ -39,6 +40,19 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const [requestModal, setRequestModal] = useState(false);
const [is4k, setIs4k] = useState(false);
const returnCollectionDownloadItems = (data: Collection | undefined) => {
const [downloadStatus, downloadStatus4k] = [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
return { downloadStatus, downloadStatus4k };
};
const {
data,
error,
@@ -46,11 +60,31 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
fallbackData: collection,
revalidateOnMount: true,
refreshInterval: refreshIntervalHelper(
returnCollectionDownloadItems(collection),
15000
),
});
const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
const [downloadStatus, downloadStatus4k] = useMemo(() => {
const downloadItems = returnCollectionDownloadItems(data);
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
}, [data]);
const [titles, titles4k] = useMemo(() => {
return [
data?.parts
.filter((media) => (media.mediaInfo?.downloadStatus ?? []).length > 0)
.map((title) => title.title),
data?.parts
.filter((media) => (media.mediaInfo?.downloadStatus4k ?? []).length > 0)
.map((title) => title.title),
];
}, [data?.parts]);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -205,6 +239,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<div className="media-status">
<StatusBadge
status={collectionStatus}
downloadItem={downloadStatus}
title={titles}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
@@ -218,6 +254,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
) && (
<StatusBadge
status={collectionStatus4k}
downloadItem={downloadStatus4k}
title={titles4k}
is4k
inProgress={data.parts.some(
(part) =>
@@ -250,7 +288,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
}}
text={
<>
<DownloadIcon />
<ArrowDownTrayIcon />
<span>
{intl.formatMessage(
hasRequestable
@@ -269,7 +307,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
setIs4k(true);
}}
>
<DownloadIcon />
<ArrowDownTrayIcon />
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>

View File

@@ -1,8 +1,8 @@
import {
ExclamationIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/solid';
} from '@heroicons/react/24/solid';
interface AlertProps {
title?: React.ReactNode;
@@ -16,7 +16,7 @@ const Alert = ({ title, children, type }: AlertProps) => {
'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20',
titleColor: 'text-yellow-100',
textColor: 'text-yellow-300',
svg: <ExclamationIcon className="h-5 w-5" />,
svg: <ExclamationTriangleIcon className="h-5 w-5" />,
};
switch (type) {

View File

@@ -46,7 +46,7 @@ function Button<P extends ElementTypes = 'button'>(
ref?: React.Ref<Element<P>>
): JSX.Element {
const buttonStyle = [
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
'inline-flex items-center justify-center border leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
];
switch (buttonType) {
case 'primary':
@@ -61,7 +61,7 @@ function Button<P extends ElementTypes = 'button'>(
break;
case 'warning':
buttonStyle.push(
'text-white border border-yellow-500 backdrop-blur bg-yellow-500 bg-opacity-80 hover:bg-opacity-100 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-opacity-100 active:border-yellow-700'
'text-white border border-yellow-500 bg-yellow-500 bg-opacity-80 hover:bg-opacity-100 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-opacity-100 active:border-yellow-700'
);
break;
case 'success':
@@ -71,7 +71,7 @@ function Button<P extends ElementTypes = 'button'>(
break;
case 'ghost':
buttonStyle.push(
'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
'text-white bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
);
break;
default:

View File

@@ -1,7 +1,7 @@
import useClickOutside from '@app/hooks/useClickOutside';
import { withProperties } from '@app/utils/typeHelpers';
import { Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/solid';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
import { Fragment, useRef, useState } from 'react';
@@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
<Transition
as={Fragment}
show={isOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
<div

View File

@@ -1,18 +1,27 @@
import useSettings from '@app/hooks/useSettings';
import type { ImageProps } from 'next/image';
import type { ImageLoader, ImageProps } from 'next/image';
import Image from 'next/image';
const imageLoader: ImageLoader = ({ src }) => src;
/**
* The CachedImage component should be used wherever
* we want to offer the option to locally cache images.
*
* It uses the `next/image` Image component but overrides
* the `unoptimized` prop based on the application setting `cacheImages`.
**/
const CachedImage = (props: ImageProps) => {
const CachedImage = ({ src, ...props }: ImageProps) => {
const { currentSettings } = useSettings();
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;
let imageUrl = src;
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
const parsedUrl = new URL(imageUrl);
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
}
}
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
};
export default CachedImage;

View File

@@ -1,6 +1,6 @@
import Button from '@app/components/Common/Button';
import useClickOutside from '@app/hooks/useClickOutside';
import { useRef, useState } from 'react';
import { forwardRef, useRef, useState } from 'react';
interface ConfirmButtonProps {
onClick: () => void;
@@ -9,50 +9,51 @@ interface ConfirmButtonProps {
children: React.ReactNode;
}
const ConfirmButton = ({
onClick,
children,
confirmText,
className,
}: ConfirmButtonProps) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
const ConfirmButton = forwardRef<HTMLButtonElement, ConfirmButtonProps>(
({ onClick, children, confirmText, className }, parentRef) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
ref={parentRef}
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
&nbsp;
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
};
<div
ref={ref}
className={`relative inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
>
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? 'translate-y-0 opacity-100'
: 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
}
);
ConfirmButton.displayName = 'ConfirmButton';
export default ConfirmButton;

View File

@@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
appear
as="div"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
ref={parentRef}
@@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<Transition
appear
as={Fragment}
enter="transition opacity-0 duration-300 transform scale-75"
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
@@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
</div>
</Transition>
<Transition
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
@@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
}}
appear
as="div"
enter="transition opacity-0 duration-300 transform scale-75"
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}

View File

@@ -0,0 +1,113 @@
import Tooltip from '@app/components/Common/Tooltip';
import useDebouncedState from '@app/hooks/useDebouncedState';
import { useEffect, useRef } from 'react';
type MultiRangeSliderProps = {
min: number;
max: number;
defaultMinValue?: number;
defaultMaxValue?: number;
subText?: string;
onUpdateMin: (min: number) => void;
onUpdateMax: (max: number) => void;
};
const MultiRangeSlider = ({
min,
max,
defaultMinValue,
defaultMaxValue,
subText,
onUpdateMin,
onUpdateMax,
}: MultiRangeSliderProps) => {
const touched = useRef(false);
const [valueMin, finalValueMin, setValueMin] = useDebouncedState(
defaultMinValue ?? min
);
const [valueMax, finalValueMax, setValueMax] = useDebouncedState(
defaultMaxValue ?? max
);
const minThumb = ((valueMin - min) / (max - min)) * 100;
const maxThumb = ((valueMax - min) / (max - min)) * 100;
useEffect(() => {
if (touched.current) {
onUpdateMin(finalValueMin);
}
}, [finalValueMin, onUpdateMin]);
useEffect(() => {
if (touched.current) {
onUpdateMax(finalValueMax);
}
}, [finalValueMax, onUpdateMax]);
useEffect(() => {
touched.current = false;
setValueMax(defaultMaxValue ?? max);
setValueMin(defaultMinValue ?? min);
}, [defaultMinValue, defaultMaxValue, setValueMax, setValueMin, min, max]);
return (
<div className={`relative ${subText ? 'h-8' : 'h-4'} w-full`}>
<Tooltip
content={valueMin.toString()}
tooltipConfig={{
placement: 'top',
}}
>
<input
type="range"
min={min}
max={max}
value={valueMin}
className={`pointer-events-none absolute h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 ${
valueMin >= valueMax && valueMin !== min ? 'z-30' : 'z-10'
}`}
onChange={(e) => {
const value = Number(e.target.value);
if (value <= valueMax) {
touched.current = true;
setValueMin(value);
}
}}
/>
</Tooltip>
<Tooltip content={valueMax}>
<input
type="range"
min={min}
max={max}
value={valueMax}
step="1"
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
onChange={(e) => {
const value = Number(e.target.value);
if (value >= valueMin) {
touched.current = true;
setValueMax(value);
}
}}
/>
</Tooltip>
<div
className="pointer-events-none absolute top-0 z-30 ml-1 mr-1 h-2 bg-indigo-500"
style={{
left: `${minThumb}%`,
right: `${100 - maxThumb}%`,
}}
/>
{subText && (
<div className="relative top-4 z-30 flex w-full justify-center text-sm text-gray-400">
<span>{subText}</span>
</div>
)}
</div>
);
};
export default MultiRangeSlider;

View File

@@ -1,4 +1,4 @@
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/solid';
import { Field } from 'formik';
import { useState } from 'react';
@@ -43,7 +43,7 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
type="button"
className="input-action"
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
{isHidden ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</>
);

View File

@@ -0,0 +1,38 @@
type SlideCheckboxProps = {
onClick: () => void;
checked?: boolean;
};
const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
return (
<span
role="checkbox"
tabIndex={0}
aria-checked={false}
onClick={() => {
onClick();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
onClick();
}
}}
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`}
>
<span
aria-hidden="true"
className={`${
checked ? 'bg-indigo-500' : 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
);
};
export default SlideCheckbox;

View File

@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import { Transition } from '@headlessui/react';
import { XIcon } from '@heroicons/react/outline';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { Fragment, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
@@ -37,10 +37,10 @@ const SlideOver = ({
as={Fragment}
show={show}
appear
enter="opacity-0 transition ease-in-out duration-300"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition ease-in-out duration-300"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@@ -58,20 +58,20 @@ const SlideOver = ({
<section className="absolute inset-y-0 right-0 flex max-w-full">
<Transition.Child
appear
enter="transform transition ease-in-out duration-500 sm:duration-700"
enter="transition-transform ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leave="transition-transform ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="slideover h-full w-screen max-w-md p-2 sm:p-4"
className="slideover relative h-full w-screen max-w-md p-2 sm:p-3"
ref={slideoverRef}
onClick={(e) => e.stopPropagation()}
>
<div className="hide-scrollbar flex h-full flex-col overflow-y-scroll rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
<div className="flex h-full flex-col rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
<header className="space-y-1 border-b border-gray-700 py-4 px-4">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-overseerr text-2xl font-bold leading-7">
@@ -83,7 +83,7 @@ const SlideOver = ({
className="text-gray-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
<XIcon className="h-6 w-6" />
<XMarkIcon className="h-6 w-6" />
</button>
</div>
</div>
@@ -95,8 +95,10 @@ const SlideOver = ({
</div>
)}
</header>
<div className="relative flex-1 px-4 py-6 text-white">
{children}
<div className="hide-scrollbar flex flex-1 flex-col overflow-y-auto">
<div className="flex-1 px-4 py-6 text-white">
{children}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,71 @@
import Spinner from '@app/assets/spinner.svg';
import { CheckCircleIcon } from '@heroicons/react/20/solid';
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media';
interface StatusBadgeMiniProps {
status: MediaStatus;
is4k?: boolean;
inProgress?: boolean;
// Should the badge shrink on mobile to a smaller size? (TitleCard)
shrink?: boolean;
}
const StatusBadgeMini = ({
status,
is4k = false,
inProgress = false,
shrink = false,
}: StatusBadgeMiniProps) => {
const badgeStyle = [
`rounded-full bg-opacity-80 shadow-md ${
shrink ? 'w-4 sm:w-5 border p-0' : 'w-5 ring-1 p-0.5'
}`,
];
let indicatorIcon: React.ReactNode;
switch (status) {
case MediaStatus.PROCESSING:
badgeStyle.push(
'bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100'
);
indicatorIcon = <ClockIcon />;
break;
case MediaStatus.AVAILABLE:
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <CheckCircleIcon />;
break;
case MediaStatus.PENDING:
badgeStyle.push(
'bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100'
);
indicatorIcon = <BellIcon />;
break;
case MediaStatus.PARTIALLY_AVAILABLE:
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <MinusSmallIcon />;
break;
}
if (inProgress) {
indicatorIcon = <Spinner />;
}
return (
<div
className={`relative inline-flex whitespace-nowrap rounded-full border-gray-700 text-xs font-semibold leading-5 ring-gray-700 ${
shrink ? '' : 'ring-1'
}`}
>
<div className={badgeStyle.join(' ')}>{indicatorIcon}</div>
{is4k && <span className="pl-1 pr-2 text-gray-200">4K</span>}
</div>
);
};
export default StatusBadgeMini;

View File

@@ -0,0 +1,24 @@
import { TagIcon } from '@heroicons/react/24/outline';
import React from 'react';
type TagProps = {
children: React.ReactNode;
iconSvg?: JSX.Element;
};
const Tag = ({ children, iconSvg }: TagProps) => {
return (
<div className="inline-flex cursor-pointer items-center rounded-full bg-gray-800 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-600 transition hover:bg-gray-700">
{iconSvg ? (
React.cloneElement(iconSvg, {
className: 'mr-1 h-4 w-4',
})
) : (
<TagIcon className="mr-1 h-4 w-4" />
)}
<span>{children}</span>
</div>
);
};
export default Tag;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import type { Config } from 'react-popper-tooltip';
import { usePopperTooltip } from 'react-popper-tooltip';
@@ -6,9 +7,15 @@ type TooltipProps = {
content: React.ReactNode;
children: React.ReactElement;
tooltipConfig?: Partial<Config>;
className?: string;
};
const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
const Tooltip = ({
children,
content,
tooltipConfig,
className,
}: TooltipProps) => {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
usePopperTooltip({
followCursor: true,
@@ -17,20 +24,30 @@ const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
...tooltipConfig,
});
const tooltipStyle = [
'z-50 text-sm absolute font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
];
if (className) {
tooltipStyle.push(className);
}
return (
<>
{React.cloneElement(children, { ref: setTriggerRef })}
{visible && (
<div
ref={setTooltipRef}
{...getTooltipProps({
className:
'z-50 text-sm font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
})}
>
{content}
</div>
)}
{visible &&
content &&
ReactDOM.createPortal(
<div
ref={setTooltipRef}
{...getTooltipProps({
className: tooltipStyle.join(' '),
})}
>
{content}
</div>,
document.body
)}
</>
);
};

View File

@@ -1,3 +1,4 @@
import CachedImage from '@app/components/Common/CachedImage';
import Link from 'next/link';
import { useState } from 'react';
@@ -30,11 +31,15 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
role="link"
tabIndex={0}
>
<img
src={image}
alt={name}
className="relative z-40 max-h-full max-w-full"
/>
<div className="relative h-full w-full">
<CachedImage
src={image}
alt={name}
className="relative z-40 h-full w-full"
layout="fill"
objectFit="contain"
/>
</div>
<div
className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900'

View File

@@ -0,0 +1,28 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { BuildingOffice2Icon } from '@heroicons/react/24/outline';
import type { ProductionCompany, TvNetwork } from '@server/models/common';
import useSWR from 'swr';
type CompanyTagProps = {
type: 'studio' | 'network';
companyId: number;
};
const CompanyTag = ({ companyId, type }: CompanyTagProps) => {
const { data, error } = useSWR<TvNetwork | ProductionCompany>(
`/api/v1/${type}/${companyId}`
);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
return <Tag iconSvg={<BuildingOffice2Icon />}>{data?.name}</Tag>;
};
export default CompanyTag;

View File

@@ -0,0 +1,506 @@
import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import AsyncSelect from 'react-select/async';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
const messages = defineMessages({
addSlider: 'Add Slider',
editSlider: 'Edit Slider',
slidernameplaceholder: 'Slider Name',
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
providetmdbgenreid: 'Provide a TMDB Genre ID',
providetmdbsearch: 'Provide a search query',
providetmdbstudio: 'Provide TMDB Studio ID',
providetmdbnetwork: 'Provide TMDB Network ID',
addsuccess: 'Created new slider and saved discover customization settings.',
addfail: 'Failed to create new slider.',
editsuccess: 'Edited slider and saved discover customization settings.',
editfail: 'Failed to edit slider.',
needresults: 'You need to have at least 1 result.',
validationDatarequired: 'You must provide a data value.',
validationTitlerequired: 'You must provide a title.',
addcustomslider: 'Create Custom Slider',
searchKeywords: 'Search keywords…',
searchGenres: 'Search genres…',
searchStudios: 'Search studios…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
});
type CreateSliderProps = {
onCreate: () => void;
slider?: Partial<DiscoverSlider>;
};
type CreateOption = {
type: DiscoverSliderType;
title: string;
dataUrl: string;
params?: string;
titlePlaceholderText: string;
dataPlaceholderText: string;
};
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [resultCount, setResultCount] = useState(0);
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
if (slider) {
const loadDefaultKeywords = async (): Promise<void> => {
if (!slider.data) {
return;
}
const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
setDefaultDataValue(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
};
const loadDefaultGenre = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<TmdbGenre[]>(
`/api/v1/genres/${
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
}`
);
const genre = response.data.find(
(genre) => genre.id === Number(slider.data)
);
setDefaultDataValue([
{
label: genre?.name ?? '',
value: genre?.id ?? 0,
},
]);
};
const loadDefaultCompany = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<ProductionCompany>(
`/api/v1/studio/${slider.data}`
);
const studio = response.data;
setDefaultDataValue([
{
label: studio.name ?? '',
value: studio.id ?? 0,
},
]);
};
switch (slider.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
loadDefaultKeywords();
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
case DiscoverSliderType.TMDB_TV_GENRE:
loadDefaultGenre();
break;
case DiscoverSliderType.TMDB_STUDIO:
loadDefaultCompany();
break;
}
}
}, [slider]);
const CreateSliderSchema = Yup.object().shape({
title: Yup.string().required(
intl.formatMessage(messages.validationTitlerequired)
),
data: Yup.string().required(
intl.formatMessage(messages.validationDatarequired)
),
});
const updateResultCount = useCallback(
(count: number) => {
setResultCount(count);
},
[setResultCount]
);
const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadCompanyOptions = async (inputValue: string) => {
if (inputValue === '') {
return [];
}
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadMovieGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/movie'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadTvGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/tv'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const options: CreateOption[] = [
{
type: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbmoviekeyword),
dataUrl: '/api/v1/discover/movies',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_TV_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbtvkeyword),
dataUrl: '/api/v1/discover/tv',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_MOVIE_GENRE,
title: intl.formatMessage(sliderTitles.tmdbmoviegenre),
dataUrl: '/api/v1/discover/movies/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_TV_GENRE,
title: intl.formatMessage(sliderTitles.tmdbtvgenre),
dataUrl: '/api/v1/discover/tv/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_STUDIO,
title: intl.formatMessage(sliderTitles.tmdbstudio),
dataUrl: '/api/v1/discover/movies/studio/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbstudio),
},
{
type: DiscoverSliderType.TMDB_NETWORK,
title: intl.formatMessage(sliderTitles.tmdbnetwork),
dataUrl: '/api/v1/discover/tv/network/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbnetwork),
},
{
type: DiscoverSliderType.TMDB_SEARCH,
title: intl.formatMessage(sliderTitles.tmdbsearch),
dataUrl: '/api/v1/search',
params: 'query=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
},
];
return (
<Formik
initialValues={
slider
? {
sliderType: slider.type,
title: slider.title,
data: slider.data,
}
: {
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: '',
data: '',
}
}
validationSchema={CreateSliderSchema}
enableReinitialize
onSubmit={async (values, { resetForm }) => {
try {
if (slider) {
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
} else {
await axios.post('/api/v1/settings/discover/add', {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
}
addToast(
intl.formatMessage(
slider ? messages.editsuccess : messages.addsuccess
),
{
appearance: 'success',
autoDismiss: true,
}
);
onCreate();
resetForm();
} catch (e) {
addToast(
intl.formatMessage(slider ? messages.editfail : messages.addfail),
{
appearance: 'error',
autoDismiss: true,
}
);
}
}}
>
{({ values, isValid, isSubmitting, errors, touched, setFieldValue }) => {
const activeOption = options.find(
(option) => option.type === Number(values.sliderType)
);
let dataInput: React.ReactNode;
switch (activeOption?.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
dataInput = (
<AsyncSelect
key={`keyword-select-${defaultDataValue}`}
inputId="data"
isMulti
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
inputValue === ''
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
defaultValue={defaultDataValue}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={(value) => {
const keywords = value.map((item) => item.value).join(',');
setFieldValue('data', keywords);
}}
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
dataInput = (
<AsyncSelect
key={`movie-genre-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadMovieGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
case DiscoverSliderType.TMDB_TV_GENRE:
dataInput = (
<AsyncSelect
key={`tv-genre-select-${defaultDataValue}}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadTvGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
case DiscoverSliderType.TMDB_STUDIO:
dataInput = (
<AsyncSelect
key={`studio-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadCompanyOptions}
placeholder={intl.formatMessage(messages.searchStudios)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
default:
dataInput = (
<Field
type="text"
name="data"
id="data"
placeholder={activeOption?.dataPlaceholderText}
/>
);
}
return (
<Form data-testid="create-discover-option-form">
<div className="flex flex-col space-y-2 text-gray-100">
<Field as="select" id="sliderType" name="sliderType">
{options.map((option) => (
<option value={option.type} key={`type-${option.type}`}>
{option.title}
</option>
))}
</Field>
<Field
type="text"
name="title"
id="title"
placeholder={activeOption?.titlePlaceholderText}
/>
{errors.title &&
touched.title &&
typeof errors.title === 'string' && (
<div className="error">{errors.title}</div>
)}
{dataInput}
{errors.data &&
touched.data &&
typeof errors.data === 'string' && (
<div className="error">{errors.data}</div>
)}
<div className="flex-1"></div>
{resultCount === 0 ? (
<Tooltip content={intl.formatMessage(messages.needresults)}>
<div>
<Button buttonType="primary" buttonSize="sm" disabled>
{intl.formatMessage(messages.addSlider)}
</Button>
</div>
</Tooltip>
) : (
<div>
<Button
buttonType="primary"
buttonSize="sm"
disabled={isSubmitting || !isValid}
>
{intl.formatMessage(
slider ? messages.editSlider : messages.addSlider
)}
</Button>
</div>
)}
</div>
{activeOption && values.title && values.data && (
<div className="relative py-4">
<MediaSlider
sliderKey={`preview-${values.title}`}
title={values.title}
url={activeOption?.dataUrl.replace(
'$value',
encodeURIExtraParams(values.data)
)}
extraParams={activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)}
onNewTitles={updateResultCount}
/>
</div>
)}
</Form>
);
}}
</Formik>
);
};
export default CreateSlider;

View File

@@ -1,16 +1,20 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovermovies: 'Popular Movies',
keywordMovies: '{keywordTitle} Movies',
});
const DiscoverMovies = () => {
const DiscoverMovieKeyword = () => {
const router = useRouter();
const intl = useIntl();
const {
@@ -21,13 +25,25 @@ const DiscoverMovies = () => {
titles,
fetchMore,
error,
} = useDiscover<MovieResult>('/api/v1/discover/movies');
firstResultData,
} = useDiscover<MovieResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/movies`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovermovies);
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordMovies, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
return (
<>
@@ -48,4 +64,4 @@ const DiscoverMovies = () => {
);
};
export default DiscoverMovies;
export default DiscoverMovieKeyword;

View File

@@ -0,0 +1,147 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import {
countActiveFilters,
prepareFilterValues,
} from '@app/components/Discover/constants';
import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovermovies: 'Movies',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
sortPopularityAsc: 'Popularity Ascending',
sortPopularityDesc: 'Popularity Descending',
sortReleaseDateAsc: 'Release Date Ascending',
sortReleaseDateDesc: 'Release Date Descending',
sortTmdbRatingAsc: 'TMDB Rating Ascending',
sortTmdbRatingDesc: 'TMDB Rating Descending',
sortTitleAsc: 'Title (A-Z) Ascending',
sortTitleDesc: 'Title (Z-A) Descending',
});
const SortOptions: Record<string, TMDBSortOptions> = {
PopularityAsc: 'popularity.asc',
PopularityDesc: 'popularity.desc',
ReleaseDateAsc: 'release_date.asc',
ReleaseDateDesc: 'release_date.desc',
TmdbRatingAsc: 'vote_average.asc',
TmdbRatingDesc: 'vote_average.desc',
TitleAsc: 'original_title.asc',
TitleDesc: 'original_title.desc',
} as const;
const DiscoverMovies = () => {
const intl = useIntl();
const router = useRouter();
const updateQueryParams = useUpdateQueryParams({});
const preparedFilters = prepareFilterValues(router.query);
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MovieResult, unknown, FilterOptions>(
'/api/v1/discover/movies',
preparedFilters
);
const [showFilters, setShowFilters] = useState(false);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovermovies);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sortBy"
name="sortBy"
className="rounded-r-only"
value={preparedFilters.sortBy}
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
>
<option value={SortOptions.PopularityDesc}>
{intl.formatMessage(messages.sortPopularityDesc)}
</option>
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.ReleaseDateDesc}>
{intl.formatMessage(messages.sortReleaseDateDesc)}
</option>
<option value={SortOptions.ReleaseDateAsc}>
{intl.formatMessage(messages.sortReleaseDateAsc)}
</option>
<option value={SortOptions.TmdbRatingDesc}>
{intl.formatMessage(messages.sortTmdbRatingDesc)}
</option>
<option value={SortOptions.TmdbRatingAsc}>
{intl.formatMessage(messages.sortTmdbRatingAsc)}
</option>
<option value={SortOptions.TitleAsc}>
{intl.formatMessage(messages.sortTitleAsc)}
</option>
<option value={SortOptions.TitleDesc}>
{intl.formatMessage(messages.sortTitleDesc)}
</option>
</select>
</div>
<FilterSlideover
type="movie"
currentFilters={preparedFilters}
onClose={() => setShowFilters(false)}
show={showFilters}
/>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<Button onClick={() => setShowFilters(true)} className="w-full">
<FunnelIcon />
<span>
{intl.formatMessage(messages.activefilters, {
count: countActiveFilters(preparedFilters),
})}
</span>
</Button>
</div>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovies;

View File

@@ -0,0 +1,334 @@
import Button from '@app/components/Common/Button';
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
import Tag from '@app/components/Common/Tag';
import Tooltip from '@app/components/Common/Tooltip';
import CompanyTag from '@app/components/CompanyTag';
import { sliderTitles } from '@app/components/Discover/constants';
import CreateSlider from '@app/components/Discover/CreateSlider';
import GenreTag from '@app/components/GenreTag';
import KeywordTag from '@app/components/KeywordTag';
import globalMessages from '@app/i18n/globalMessages';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import {
ArrowUturnLeftIcon,
Bars3Icon,
ChevronDownIcon,
ChevronUpIcon,
PencilIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
deletesuccess: 'Sucessfully deleted slider.',
deletefail: 'Failed to delete slider.',
remove: 'Remove',
enable: 'Toggle Visibility',
});
const Position = {
None: 'None',
Above: 'Above',
Below: 'Below',
} as const;
type DiscoverSliderEditProps = {
slider: Partial<DiscoverSlider>;
onEnable: () => void;
onDelete: () => void;
onPositionUpdate: (
updatedItemId: number,
position: keyof typeof Position,
isClickable: boolean
) => void;
children: React.ReactNode;
disableUpButton: boolean;
disableDownButton: boolean;
};
const DiscoverSliderEdit = ({
slider,
children,
onEnable,
onDelete,
onPositionUpdate,
disableUpButton,
disableDownButton,
}: DiscoverSliderEditProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [isEditing, setIsEditing] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
Position.None
);
const { dragProps, isDragging } = useDrag({
getItems() {
return [{ id: (slider.id ?? -1).toString(), title: slider.title ?? '' }];
},
});
const deleteSlider = async () => {
try {
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
addToast(intl.formatMessage(messages.deletesuccess), {
appearance: 'success',
autoDismiss: true,
});
onDelete();
} catch (e) {
addToast(intl.formatMessage(messages.deletefail), {
appearance: 'error',
autoDismiss: true,
});
}
};
const { dropProps } = useDrop({
ref,
onDropMove: (e) => {
if (ref.current) {
const middlePoint = ref.current.offsetHeight / 2;
if (e.y < middlePoint) {
setHoverPosition(Position.Above);
} else {
setHoverPosition(Position.Below);
}
}
},
onDropExit: () => {
setHoverPosition(Position.None);
},
onDrop: async (e) => {
const items = await Promise.all(
e.items
.filter((item) => item.kind === 'text' && item.types.has('id'))
.map(async (item) => {
if (item.kind === 'text') {
return item.getText('id');
}
})
);
if (items?.[0]) {
const dropped = Number(items[0]);
onPositionUpdate(dropped, hoverPosition, false);
}
},
});
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
return intl.formatMessage(sliderTitles.recentlyAdded);
case DiscoverSliderType.RECENT_REQUESTS:
return intl.formatMessage(sliderTitles.recentrequests);
case DiscoverSliderType.PLEX_WATCHLIST:
return intl.formatMessage(sliderTitles.plexwatchlist);
case DiscoverSliderType.TRENDING:
return intl.formatMessage(sliderTitles.trending);
case DiscoverSliderType.POPULAR_MOVIES:
return intl.formatMessage(sliderTitles.popularmovies);
case DiscoverSliderType.MOVIE_GENRES:
return intl.formatMessage(sliderTitles.moviegenres);
case DiscoverSliderType.UPCOMING_MOVIES:
return intl.formatMessage(sliderTitles.upcoming);
case DiscoverSliderType.STUDIOS:
return intl.formatMessage(sliderTitles.studios);
case DiscoverSliderType.POPULAR_TV:
return intl.formatMessage(sliderTitles.populartv);
case DiscoverSliderType.TV_GENRES:
return intl.formatMessage(sliderTitles.tvgenres);
case DiscoverSliderType.UPCOMING_TV:
return intl.formatMessage(sliderTitles.upcomingtv);
case DiscoverSliderType.NETWORKS:
return intl.formatMessage(sliderTitles.networks);
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbmoviekeyword);
case DiscoverSliderType.TMDB_TV_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbtvkeyword);
case DiscoverSliderType.TMDB_MOVIE_GENRE:
return intl.formatMessage(sliderTitles.tmdbmoviegenre);
case DiscoverSliderType.TMDB_TV_GENRE:
return intl.formatMessage(sliderTitles.tmdbtvgenre);
case DiscoverSliderType.TMDB_STUDIO:
return intl.formatMessage(sliderTitles.tmdbstudio);
case DiscoverSliderType.TMDB_NETWORK:
return intl.formatMessage(sliderTitles.tmdbnetwork);
case DiscoverSliderType.TMDB_SEARCH:
return intl.formatMessage(sliderTitles.tmdbsearch);
default:
return 'Unknown Slider';
}
};
return (
<div
key={`discover-slider-${slider.id}-editing`}
data-testid="discover-slider-edit-mode"
className={`relative mb-4 rounded-lg bg-gray-800 shadow-md ${
isDragging ? 'opacity-0' : 'opacity-100'
}`}
{...dragProps}
{...dropProps}
ref={ref}
>
{hoverPosition === Position.Above && (
<div
className={`absolute -top-3 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
{hoverPosition === Position.Below && (
<div
className={`absolute -bottom-2 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
<div className="flex w-full flex-col rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-gray-400 md:flex-row md:items-center md:space-x-2">
<div
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
>
<Bars3Icon className="h-6 w-6" />
<div>{getSliderTitle(slider)}</div>
</div>
<div
className={`pointer-events-none ${
slider.data ? 'mb-4' : ''
} flex-1 md:mb-0`}
>
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD ||
slider.type === DiscoverSliderType.TMDB_TV_KEYWORD) && (
<div className="flex space-x-2">
{slider.data?.split(',').map((keywordId) => (
<KeywordTag
key={`slider-keywords-${slider.id}-${keywordId}`}
keywordId={Number(keywordId)}
/>
))}
</div>
)}
{(slider.type === DiscoverSliderType.TMDB_NETWORK ||
slider.type === DiscoverSliderType.TMDB_STUDIO) && (
<CompanyTag
type={
slider.type === DiscoverSliderType.TMDB_STUDIO
? 'studio'
: 'network'
}
companyId={Number(slider.data)}
/>
)}
{(slider.type === DiscoverSliderType.TMDB_TV_GENRE ||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE) && (
<GenreTag
type={
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE
? 'movie'
: 'tv'
}
genreId={Number(slider.data)}
/>
)}
{slider.type === DiscoverSliderType.TMDB_SEARCH && (
<Tag iconSvg={<MagnifyingGlassIcon />}>{slider.data}</Tag>
)}
</div>
<div className="flex items-center space-x-2">
{!slider.isBuiltIn && (
<>
{!isEditing ? (
<Button
buttonType="warning"
buttonSize="sm"
onClick={() => {
setIsEditing(true);
}}
>
<PencilIcon />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</Button>
) : (
<Button
buttonType="default"
buttonSize="sm"
onClick={() => {
setIsEditing(false);
}}
>
<ArrowUturnLeftIcon />
<span>{intl.formatMessage(globalMessages.cancel)}</span>
</Button>
)}
<Button
data-testid="discover-slider-remove-button"
buttonType="danger"
buttonSize="sm"
onClick={() => {
deleteSlider();
}}
>
<XMarkIcon />
<span>{intl.formatMessage(messages.remove)}</span>
</Button>
</>
)}
<div className="absolute right-14 top-4 flex px-2 md:relative md:top-0 md:right-0">
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Above, true)
}
disabled={disableUpButton}
>
<ChevronUpIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Below, true)
}
disabled={disableDownButton}
>
<ChevronDownIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
</div>
<div className="absolute top-4 right-4 flex-1 text-right md:relative md:top-0 md:right-0">
<Tooltip content={intl.formatMessage(messages.enable)}>
<div>
<SlideCheckbox
onClick={() => {
onEnable();
}}
checked={slider.enabled}
/>
</div>
</Tooltip>
</div>
</div>
</div>
{isEditing ? (
<div className="p-4">
<CreateSlider
onCreate={() => {
onDelete();
setIsEditing(false);
}}
slider={slider}
/>
</div>
) : (
<div className={`-mt-6 p-4 ${!slider.enabled ? 'opacity-50' : ''}`}>
{children}
</div>
)}
</div>
);
};
export default DiscoverSliderEdit;

View File

@@ -0,0 +1,145 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import {
countActiveFilters,
prepareFilterValues,
} from '@app/components/Discover/constants';
import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovertv: 'Series',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
sortPopularityAsc: 'Popularity Ascending',
sortPopularityDesc: 'Popularity Descending',
sortFirstAirDateAsc: 'First Air Date Ascending',
sortFirstAirDateDesc: 'First Air Date Descending',
sortTmdbRatingAsc: 'TMDB Rating Ascending',
sortTmdbRatingDesc: 'TMDB Rating Descending',
sortTitleAsc: 'Title (A-Z) Ascending',
sortTitleDesc: 'Title (Z-A) Descending',
});
const SortOptions: Record<string, TMDBSortOptions> = {
PopularityAsc: 'popularity.asc',
PopularityDesc: 'popularity.desc',
FirstAirDateAsc: 'first_air_date.asc',
FirstAirDateDesc: 'first_air_date.desc',
TmdbRatingAsc: 'vote_average.asc',
TmdbRatingDesc: 'vote_average.desc',
TitleAsc: 'original_title.asc',
TitleDesc: 'original_title.desc',
} as const;
const DiscoverTv = () => {
const intl = useIntl();
const router = useRouter();
const [showFilters, setShowFilters] = useState(false);
const preparedFilters = prepareFilterValues(router.query);
const updateQueryParams = useUpdateQueryParams({});
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<TvResult, never, FilterOptions>('/api/v1/discover/tv', {
...preparedFilters,
});
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovertv);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sortBy"
name="sortBy"
className="rounded-r-only"
value={preparedFilters.sortBy}
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
>
<option value={SortOptions.PopularityDesc}>
{intl.formatMessage(messages.sortPopularityDesc)}
</option>
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.FirstAirDateDesc}>
{intl.formatMessage(messages.sortFirstAirDateDesc)}
</option>
<option value={SortOptions.FirstAirDateAsc}>
{intl.formatMessage(messages.sortFirstAirDateAsc)}
</option>
<option value={SortOptions.TmdbRatingDesc}>
{intl.formatMessage(messages.sortTmdbRatingDesc)}
</option>
<option value={SortOptions.TmdbRatingAsc}>
{intl.formatMessage(messages.sortTmdbRatingAsc)}
</option>
<option value={SortOptions.TitleAsc}>
{intl.formatMessage(messages.sortTitleAsc)}
</option>
<option value={SortOptions.TitleDesc}>
{intl.formatMessage(messages.sortTitleDesc)}
</option>
</select>
</div>
<FilterSlideover
type="tv"
currentFilters={preparedFilters}
onClose={() => setShowFilters(false)}
show={showFilters}
/>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<Button onClick={() => setShowFilters(true)} className="w-full">
<FunnelIcon />
<span>
{intl.formatMessage(messages.activefilters, {
count: countActiveFilters(preparedFilters),
})}
</span>
</Button>
</div>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTv;

View File

@@ -1,16 +1,20 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovertv: 'Popular Series',
keywordSeries: '{keywordTitle} Series',
});
const DiscoverTv = () => {
const DiscoverTvKeyword = () => {
const router = useRouter();
const intl = useIntl();
const {
@@ -21,13 +25,25 @@ const DiscoverTv = () => {
titles,
fetchMore,
error,
} = useDiscover<TvResult>('/api/v1/discover/tv');
firstResultData,
} = useDiscover<TvResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/tv`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovertv);
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordSeries, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
return (
<>
@@ -38,14 +54,14 @@ const DiscoverTv = () => {
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTv;
export default DiscoverTvKeyword;

View File

@@ -0,0 +1,297 @@
import Button from '@app/components/Common/Button';
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
import SlideOver from '@app/components/Common/SlideOver';
import type { FilterOptions } from '@app/components/Discover/constants';
import { countActiveFilters } from '@app/components/Discover/constants';
import LanguageSelector from '@app/components/LanguageSelector';
import {
CompanySelector,
GenreSelector,
KeywordSelector,
WatchProviderSelector,
} from '@app/components/Selector';
import useSettings from '@app/hooks/useSettings';
import {
useBatchUpdateQueryParams,
useUpdateQueryParams,
} from '@app/hooks/useUpdateQueryParams';
import { XCircleIcon } from '@heroicons/react/24/outline';
import { defineMessages, useIntl } from 'react-intl';
import Datepicker from 'react-tailwindcss-datepicker-sct';
const messages = defineMessages({
filters: 'Filters',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
releaseDate: 'Release Date',
firstAirDate: 'First Air Date',
from: 'From',
to: 'To',
studio: 'Studio',
genres: 'Genres',
keywords: 'Keywords',
originalLanguage: 'Original Language',
runtimeText: '{minValue}-{maxValue} minute runtime',
ratingText: 'Ratings between {minValue} and {maxValue}',
clearfilters: 'Clear Active Filters',
tmdbuserscore: 'TMDB User Score',
runtime: 'Runtime',
streamingservices: 'Streaming Services',
});
type FilterSlideoverProps = {
show: boolean;
onClose: () => void;
type: 'movie' | 'tv';
currentFilters: FilterOptions;
};
const FilterSlideover = ({
show,
onClose,
type,
currentFilters,
}: FilterSlideoverProps) => {
const intl = useIntl();
const { currentSettings } = useSettings();
const updateQueryParams = useUpdateQueryParams({});
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
const dateGte =
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
const dateLte =
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
return (
<SlideOver
show={show}
title={intl.formatMessage(messages.filters)}
subText={intl.formatMessage(messages.activefilters, {
count: countActiveFilters(currentFilters),
})}
onClose={() => onClose()}
>
<div className="flex flex-col space-y-4">
<div>
<div className="mb-2 text-lg font-semibold">
{intl.formatMessage(
type === 'movie' ? messages.releaseDate : messages.firstAirDate
)}
</div>
<div className="relative z-40 flex space-x-2">
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateGte] ?? null,
endDate: currentFilters[dateGte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateGte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="fromdate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateLte] ?? null,
endDate: currentFilters[dateLte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateLte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="todate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
</div>
</div>
{type === 'movie' && (
<>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.studio)}
</span>
<CompanySelector
defaultValue={currentFilters.studio}
onChange={(value) => {
updateQueryParams('studio', value?.value.toString());
}}
/>
</>
)}
<span className="text-lg font-semibold">
{intl.formatMessage(messages.genres)}
</span>
<GenreSelector
type={type}
defaultValue={currentFilters.genre}
isMulti
onChange={(value) => {
updateQueryParams('genre', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.keywords)}
</span>
<KeywordSelector
defaultValue={currentFilters.keywords}
isMulti
onChange={(value) => {
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.originalLanguage)}
</span>
<LanguageSelector
value={currentFilters.language}
serverValue={currentSettings.originalLanguage}
isUserSettings
setFieldValue={(_key, value) => {
updateQueryParams('language', value);
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.runtime)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={400}
onUpdateMin={(min) => {
updateQueryParams(
'withRuntimeGte',
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'withRuntimeLte',
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
? max.toString()
: undefined
);
}}
defaultMaxValue={
currentFilters.withRuntimeLte
? Number(currentFilters.withRuntimeLte)
: undefined
}
defaultMinValue={
currentFilters.withRuntimeGte
? Number(currentFilters.withRuntimeGte)
: undefined
}
subText={intl.formatMessage(messages.runtimeText, {
minValue: currentFilters.withRuntimeGte ?? 0,
maxValue: currentFilters.withRuntimeLte ?? 400,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuserscore)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={1}
max={10}
defaultMaxValue={
currentFilters.voteAverageLte
? Number(currentFilters.voteAverageLte)
: undefined
}
defaultMinValue={
currentFilters.voteAverageGte
? Number(currentFilters.voteAverageGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteAverageGte',
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteAverageLte',
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.ratingText, {
minValue: currentFilters.voteAverageGte ?? 1,
maxValue: currentFilters.voteAverageLte ?? 10,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)}
</span>
<WatchProviderSelector
type={type}
region={currentFilters.watchRegion}
activeProviders={
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
[]
}
onChange={(region, providers) => {
if (providers.length) {
batchUpdateQueryParams({
watchRegion: region,
watchProviders: providers.join('|'),
});
} else {
batchUpdateQueryParams({
watchRegion: undefined,
watchProviders: undefined,
});
}
}}
/>
<div className="pt-4">
<Button
className="w-full"
disabled={Object.keys(currentFilters).length === 0}
onClick={() => {
const copyCurrent = Object.assign({}, currentFilters);
(
Object.keys(copyCurrent) as (keyof typeof currentFilters)[]
).forEach((k) => {
copyCurrent[k] = undefined;
});
batchUpdateQueryParams(copyCurrent);
onClose();
}}
>
<XCircleIcon />
<span>{intl.formatMessage(messages.clearfilters)}</span>
</Button>
</div>
</div>
</SlideOver>
);
};
export default FilterSlideover;

View File

@@ -1,7 +1,7 @@
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import React from 'react';
@@ -28,7 +28,7 @@ const MovieGenreSlider = () => {
<Link href="/discover/movies/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.moviegenres)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</a>
</Link>
</div>
@@ -43,7 +43,7 @@ const MovieGenreSlider = () => {
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/movies/genre/${genre.id}`}
url={`/discover/movies?genre=${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}

View File

@@ -0,0 +1,79 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { UserType, useUser } from '@app/hooks/useUser';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
plexwatchlist: 'Your Plex Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
});
const PlexWatchlistSlider = () => {
const intl = useIntl();
const { user } = useUser();
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
if (
user?.userType !== UserType.PLEX ||
(watchlistItems &&
watchlistItems.results.length === 0 &&
!user?.settings?.watchlistSyncMovies &&
!user?.settings?.watchlistSyncTv) ||
watchlistError
) {
return null;
}
return (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowRightCircleIcon />
</a>
</Link>
</div>
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems}
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
})}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
/>
))}
/>
</>
);
};
export default PlexWatchlistSlider;

View File

@@ -0,0 +1,49 @@
import { sliderTitles } from '@app/components/Discover/constants';
import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const RecentRequestsSlider = () => {
const intl = useIntl();
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{
revalidateOnMount: true,
}
);
if (requests && requests.results.length === 0 && !requestError) {
return null;
}
return (
<>
<div className="slider-header">
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
<ArrowRightCircleIcon />
</a>
</Link>
</div>
<Slider
sliderKey="requests"
isLoading={!requests}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
/>
))}
placeholder={<RequestCard.Placeholder />}
/>
</>
);
};
export default RecentRequestsSlider;

View File

@@ -0,0 +1,53 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
recentlyAdded: 'Recently Added',
});
const RecentlyAddedSlider = () => {
const intl = useIntl();
const { hasPermission } = useUser();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
if (
(media && !media.results.length && !mediaError) ||
!hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
type: 'or',
})
) {
return null;
}
return (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={(media?.results ?? []).map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
/>
))}
/>
</>
);
};
export default RecentlyAddedSlider;

View File

@@ -1,7 +1,7 @@
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import React from 'react';
@@ -28,7 +28,7 @@ const TvGenreSlider = () => {
<Link href="/discover/tv/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.tvgenres)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</a>
</Link>
</div>
@@ -43,7 +43,7 @@ const TvGenreSlider = () => {
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/tv/genre/${genre.id}`}
url={`/discover/tv?genre=${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}

View File

@@ -1,3 +1,7 @@
import type { ParsedUrlQuery } from 'querystring';
import { defineMessages } from 'react-intl';
import { z } from 'zod';
type AvailableColors =
| 'black'
| 'red'
@@ -61,3 +65,142 @@ export const genreColorMap: Record<number, [string, string]> = {
10767: colorTones.lightgreen, // Talk
10768: colorTones.darkred, // War & Politics
};
export const sliderTitles = defineMessages({
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
moviegenres: 'Movie Genres',
tvgenres: 'Series Genres',
studios: 'Studios',
networks: 'Networks',
tmdbmoviekeyword: 'TMDB Movie Keyword',
tmdbtvkeyword: 'TMDB Series Keyword',
tmdbmoviegenre: 'TMDB Movie Genre',
tmdbtvgenre: 'TMDB Series Genre',
tmdbnetwork: 'TMDB Network',
tmdbstudio: 'TMDB Studio',
tmdbsearch: 'TMDB Search',
});
export const QueryFilterOptions = z.object({
sortBy: z.string().optional(),
primaryReleaseDateGte: z.string().optional(),
primaryReleaseDateLte: z.string().optional(),
firstAirDateGte: z.string().optional(),
firstAirDateLte: z.string().optional(),
studio: z.string().optional(),
genre: z.string().optional(),
keywords: z.string().optional(),
language: z.string().optional(),
withRuntimeGte: z.string().optional(),
withRuntimeLte: z.string().optional(),
voteAverageGte: z.string().optional(),
voteAverageLte: z.string().optional(),
watchRegion: z.string().optional(),
watchProviders: z.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
export const prepareFilterValues = (
inputValues: ParsedUrlQuery
): FilterOptions => {
const filterValues: FilterOptions = {};
const values = QueryFilterOptions.parse(inputValues);
if (values.sortBy) {
filterValues.sortBy = values.sortBy;
}
if (values.primaryReleaseDateGte) {
filterValues.primaryReleaseDateGte = values.primaryReleaseDateGte;
}
if (values.primaryReleaseDateLte) {
filterValues.primaryReleaseDateLte = values.primaryReleaseDateLte;
}
if (values.firstAirDateGte) {
filterValues.firstAirDateGte = values.firstAirDateGte;
}
if (values.firstAirDateLte) {
filterValues.firstAirDateLte = values.firstAirDateLte;
}
if (values.studio) {
filterValues.studio = values.studio;
}
if (values.genre) {
filterValues.genre = values.genre;
}
if (values.keywords) {
filterValues.keywords = values.keywords;
}
if (values.language) {
filterValues.language = values.language;
}
if (values.withRuntimeGte) {
filterValues.withRuntimeGte = values.withRuntimeGte;
}
if (values.withRuntimeLte) {
filterValues.withRuntimeLte = values.withRuntimeLte;
}
if (values.voteAverageGte) {
filterValues.voteAverageGte = values.voteAverageGte;
}
if (values.voteAverageLte) {
filterValues.voteAverageLte = values.voteAverageLte;
}
if (values.watchProviders) {
filterValues.watchProviders = values.watchProviders;
}
if (values.watchRegion) {
filterValues.watchRegion = values.watchRegion;
}
return filterValues;
};
export const countActiveFilters = (filterValues: FilterOptions): number => {
let totalCount = 0;
const clonedFilters = Object.assign({}, filterValues);
if (clonedFilters.voteAverageGte || filterValues.voteAverageLte) {
totalCount += 1;
delete clonedFilters.voteAverageGte;
delete clonedFilters.voteAverageLte;
}
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
totalCount += 1;
delete clonedFilters.withRuntimeGte;
delete clonedFilters.withRuntimeLte;
}
if (clonedFilters.watchProviders) {
totalCount += 1;
delete clonedFilters.watchProviders;
delete clonedFilters.watchRegion;
}
totalCount += Object.keys(clonedFilters).length;
return totalCount;
};

View File

@@ -1,189 +1,430 @@
import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import CreateSlider from '@app/components/Discover/CreateSlider';
import DiscoverSliderEdit from '@app/components/Discover/DiscoverSliderEdit';
import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
import NetworkSlider from '@app/components/Discover/NetworkSlider';
import PlexWatchlistSlider from '@app/components/Discover/PlexWatchlistSlider';
import RecentlyAddedSlider from '@app/components/Discover/RecentlyAddedSlider';
import RecentRequestsSlider from '@app/components/Discover/RecentRequestsSlider';
import StudioSlider from '@app/components/Discover/StudioSlider';
import TvGenreSlider from '@app/components/Discover/TvGenreSlider';
import MediaSlider from '@app/components/MediaSlider';
import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import {
ArrowDownOnSquareIcon,
ArrowPathIcon,
ArrowUturnLeftIcon,
PencilIcon,
PlusIcon,
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages({
discover: 'Discover',
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
resettodefault: 'Reset to Default',
resetwarning:
'Reset all sliders to default. This will also delete any custom sliders!',
updatesuccess: 'Updated discover customization settings.',
updatefailed:
'Something went wrong updating the discover customization settings.',
resetsuccess: 'Sucessfully reset discover customization settings.',
resetfailed:
'Something went wrong resetting the discover customization settings.',
customizediscover: 'Customize Discover',
stopediting: 'Stop Editing',
createnewslider: 'Create New Slider',
});
const Discover = () => {
const intl = useIntl();
const { user, hasPermission } = useUser();
const { hasPermission } = useUser();
const { addToast } = useToasts();
const {
data: discoverData,
error: discoverError,
mutate,
} = useSWR<DiscoverSlider[]>('/api/v1/settings/discover');
const [sliders, setSliders] = useState<Partial<DiscoverSlider>[]>([]);
const [isEditing, setIsEditing] = useState(false);
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
// We need to sync the state here so that we can modify the changes locally without commiting
// anything to the server until the user decides to save the changes
useEffect(() => {
if (discoverData && !isEditing) {
setSliders(discoverData);
}
}, [discoverData, isEditing]);
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{
revalidateOnMount: true,
}
);
const hasChanged = () => !Object.is(discoverData, sliders);
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
const updateSliders = async () => {
try {
await axios.post('/api/v1/settings/discover', sliders);
addToast(intl.formatMessage(messages.updatesuccess), {
appearance: 'success',
autoDismiss: true,
});
setIsEditing(false);
mutate();
} catch (e) {
addToast(intl.formatMessage(messages.updatefailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const resetSliders = async () => {
try {
await axios.get('/api/v1/settings/discover/reset');
addToast(intl.formatMessage(messages.resetsuccess), {
appearance: 'success',
autoDismiss: true,
});
setIsEditing(false);
mutate();
} catch (e) {
addToast(intl.formatMessage(messages.resetfailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const now = new Date();
const offset = now.getTimezoneOffset();
const upcomingDate = new Date(now.getTime() - offset * 60 * 1000)
.toISOString()
.split('T')[0];
if (!discoverData && !discoverError) {
return <LoadingSpinner />;
}
return (
<>
<PageTitle title={intl.formatMessage(messages.discover)} />
{(!media || !!media.results.length) &&
!mediaError &&
hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
type: 'or',
}) && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
{hasPermission(Permission.ADMIN) && (
<>
{isEditing && (
<div className="my-6 rounded-lg bg-gray-800">
<div className="flex items-center space-x-2 rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-lg font-semibold text-gray-400">
<PlusIcon className="w-6" />
<span data-testid="create-slider-header">
{intl.formatMessage(messages.createnewslider)}
</span>
</div>
<div className="p-4">
<CreateSlider
onCreate={async () => {
const newSliders = await mutate();
if (newSliders) {
setSliders(newSliders);
}
}}
/>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={(media?.results ?? []).map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
{(!requests || !!requests.results.length) && !requestError && (
<>
<div className="slider-header">
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(messages.recentrequests)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="requests"
isLoading={!requests}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
/>
))}
placeholder={<RequestCard.Placeholder />}
/>
)}
<Transition
show={!isEditing}
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute-bottom-shift fixed right-6 z-50 flex items-center sm:bottom-8"
>
<button
onClick={() => setIsEditing(true)}
data-testid="discover-start-editing"
className="h-12 w-12 rounded-full border-2 border-gray-600 bg-gray-700 bg-opacity-90 p-3 text-gray-400 shadow transition-all hover:bg-opacity-100"
>
<PencilIcon className="h-full w-full" />
</button>
</Transition>
<Transition
show={isEditing}
enter="transition duration-300"
enterFrom="opacity-0 translate-y-6"
enterTo="opacity-100 translate-y-0"
leave="transition duration-300"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-6"
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
>
<Button
buttonType="default"
onClick={() => setIsEditing(false)}
className="w-full sm:w-auto"
>
<ArrowUturnLeftIcon />
<span>{intl.formatMessage(messages.stopediting)}</span>
</Button>
<Tooltip content={intl.formatMessage(messages.resetwarning)}>
<ConfirmButton
onClick={() => resetSliders()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full sm:w-auto"
>
<ArrowPathIcon />
<span>{intl.formatMessage(messages.resettodefault)}</span>
</ConfirmButton>
</Tooltip>
<Button
buttonType="primary"
type="submit"
disabled={!hasChanged()}
onClick={() => updateSliders()}
data-testid="discover-customize-submit"
className="w-full sm:w-auto"
>
<ArrowDownOnSquareIcon />
<span>{intl.formatMessage(globalMessages.save)}</span>
</Button>
</Transition>
</>
)}
{user?.userType === UserType.PLEX &&
(!watchlistItems ||
!!watchlistItems.results.length ||
user.settings?.watchlistSyncMovies ||
user.settings?.watchlistSyncTv) &&
!watchlistError && (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems}
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
})}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(messages.trending)}
url="/api/v1/discover/trending"
linkUrl="/discover/trending"
/>
<MediaSlider
sliderKey="popular-movies"
title={intl.formatMessage(messages.popularmovies)}
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
<MovieGenreSlider />
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
<StudioSlider />
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(messages.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
<TvGenreSlider />
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(messages.upcomingtv)}
url="/api/v1/discover/tv/upcoming"
linkUrl="/discover/tv/upcoming"
/>
<NetworkSlider />
{(isEditing ? sliders : discoverData)?.map((slider, index) => {
let sliderComponent: React.ReactNode;
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
sliderComponent = <RecentlyAddedSlider />;
break;
case DiscoverSliderType.RECENT_REQUESTS:
sliderComponent = <RecentRequestsSlider />;
break;
case DiscoverSliderType.PLEX_WATCHLIST:
sliderComponent = <PlexWatchlistSlider />;
break;
case DiscoverSliderType.TRENDING:
sliderComponent = (
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(sliderTitles.trending)}
url="/api/v1/discover/trending"
linkUrl="/discover/trending"
/>
);
break;
case DiscoverSliderType.POPULAR_MOVIES:
sliderComponent = (
<MediaSlider
sliderKey="popular-movies"
title={intl.formatMessage(sliderTitles.popularmovies)}
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
);
break;
case DiscoverSliderType.MOVIE_GENRES:
sliderComponent = <MovieGenreSlider />;
break;
case DiscoverSliderType.UPCOMING_MOVIES:
sliderComponent = (
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(sliderTitles.upcoming)}
linkUrl={`/discover/movies?primaryReleaseDateGte=${upcomingDate}`}
url="/api/v1/discover/movies"
extraParams={`primaryReleaseDateGte=${upcomingDate}`}
/>
);
break;
case DiscoverSliderType.STUDIOS:
sliderComponent = <StudioSlider />;
break;
case DiscoverSliderType.POPULAR_TV:
sliderComponent = (
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(sliderTitles.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
);
break;
case DiscoverSliderType.TV_GENRES:
sliderComponent = <TvGenreSlider />;
break;
case DiscoverSliderType.UPCOMING_TV:
sliderComponent = (
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(sliderTitles.upcomingtv)}
linkUrl={`/discover/tv?firstAirDateGte=${upcomingDate}`}
url="/api/v1/discover/tv"
extraParams={`firstAirDateGte=${upcomingDate}`}
/>
);
break;
case DiscoverSliderType.NETWORKS:
sliderComponent = <NetworkSlider />;
break;
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/movies"
extraParams={
slider.data
? `keywords=${encodeURIExtraParams(slider.data)}`
: ''
}
linkUrl={`/discover/movies?keywords=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_TV_KEYWORD:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/tv"
extraParams={
slider.data
? `keywords=${encodeURIExtraParams(slider.data)}`
: ''
}
linkUrl={`/discover/tv?keywords=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/movies`}
extraParams={`genre=${slider.data}`}
linkUrl={`/discover/movies?genre=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_TV_GENRE:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/tv`}
extraParams={`genre=${slider.data}`}
linkUrl={`/discover/tv?genre=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_STUDIO:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/movies/studio/${slider.data}`}
linkUrl={`/discover/movies/studio/${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_NETWORK:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/tv/network/${slider.data}`}
linkUrl={`/discover/tv/network/${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_SEARCH:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/search"
extraParams={`query=${slider.data}`}
linkUrl={`/search?query=${slider.data}`}
/>
);
break;
}
if (isEditing) {
return (
<DiscoverSliderEdit
key={`discover-slider-${slider.id}-edit`}
slider={slider}
onDelete={async () => {
const newSliders = await mutate();
if (newSliders) {
setSliders(newSliders);
}
}}
onEnable={() => {
const tempSliders = sliders.slice();
tempSliders[index].enabled = !tempSliders[index].enabled;
setSliders(tempSliders);
}}
onPositionUpdate={(updatedItemId, position, hasClickedArrows) => {
const originalPosition = sliders.findIndex(
(item) => item.id === updatedItemId
);
const originalItem = sliders[originalPosition];
const tempSliders = sliders.slice();
tempSliders.splice(originalPosition, 1);
hasClickedArrows
? tempSliders.splice(
position === 'Above' ? index - 1 : index + 1,
0,
originalItem
)
: tempSliders.splice(
position === 'Above' && index > originalPosition
? Math.max(index - 1, 0)
: index,
0,
originalItem
);
setSliders(tempSliders);
}}
disableUpButton={index === 0}
disableDownButton={index === sliders.length - 1}
>
{sliderComponent}
</DiscoverSliderEdit>
);
}
if (!slider.enabled) {
return null;
}
return (
<div key={`discover-slider-${slider.id}`}>{sliderComponent}</div>
);
})}
</>
);
};

View File

@@ -1,23 +1,39 @@
import Badge from '@app/components/Common/Badge';
import { Permission, useUser } from '@app/hooks/useUser';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
const messages = defineMessages({
estimatedtime: 'Estimated {time}',
formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}',
});
interface DownloadBlockProps {
downloadItem: DownloadingItem;
is4k?: boolean;
title?: string;
}
const DownloadBlock = ({ downloadItem, is4k = false }: DownloadBlockProps) => {
const DownloadBlock = ({
downloadItem,
is4k = false,
title,
}: DownloadBlockProps) => {
const intl = useIntl();
const { hasPermission } = useUser();
return (
<div className="p-4">
<div className="mb-2 w-56 truncate text-sm sm:w-80 md:w-full">
{downloadItem.title}
{hasPermission(Permission.ADMIN)
? downloadItem.title
: downloadItem.episode
? intl.formatMessage(messages.formattedTitle, {
title,
seasonNumber: downloadItem?.episode?.seasonNumber,
episodeNumber: downloadItem?.episode?.episodeNumber,
})
: title}
</div>
<div className="relative mb-2 h-6 min-w-0 overflow-hidden rounded-full bg-gray-700">
<div

View File

@@ -0,0 +1,28 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { RectangleStackIcon } from '@heroicons/react/24/outline';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import useSWR from 'swr';
type GenreTagProps = {
type: 'tv' | 'movie';
genreId: number;
};
const GenreTag = ({ genreId, type }: GenreTagProps) => {
const { data, error } = useSWR<TmdbGenre[]>(`/api/v1/genres/${type}`);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
const genre = data?.find((genre) => genre.id === genreId);
return <Tag iconSvg={<RectangleStackIcon />}>{genre?.name}</Tag>;
};
export default GenreTag;

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