* feat(pushover): attach image to pushover notification payload (#3701) * fix: api language query parameter (#3720) * docs: add j0srisk as a contributor for code (#3745) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat(tooltip): add tooltip to display exact time on date hover (#3773) Co-authored-by: Loetwiek <lodommerholtcm@gmail.com> * docs: add Loetwiek as a contributor for code (#3776) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * fix(ui): ensure title fits into the `view collection` box (#3696) * fix(docs): correct openapi docs minor issues (#3648) * docs: add Fuochi as a contributor for doc (#3826) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat: translations update from Hosted Weblate (#3597) * feat(lang): translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (1234 of 1234 strings) feat(lang): translated using Weblate (Portuguese (Brazil)) Currently translated at 99.8% (1232 of 1234 strings) Co-authored-by: Cleiton Carvalho <cleitonsilvacarvalho@gmail.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (German) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (German) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de> Co-authored-by: Thomas Schöneberg <ta.schoeneberg@gmail.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Danish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Danish) Currently translated at 100.0% (1236 of 1236 strings) feat(lang): translated using Weblate (Danish) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Anders Ecklon <aecklon@gmail.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: Kenneth Hansen <erathor@live.dk> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/da/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Greek) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Greek) Currently translated at 100.0% (1236 of 1236 strings) Co-authored-by: BeardedWatermelon <BeardedWatermelon@users.noreply.hosted.weblate.org> Co-authored-by: Hosted Weblate <hosted@weblate.org> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/el/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Russian) Currently translated at 99.5% (1234 of 1240 strings) feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1234 of 1234 strings) feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1234 of 1234 strings) feat(lang): translated using Weblate (Russian) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: SoundwaveUwU <SoundwaveUwU@users.noreply.hosted.weblate.org> Co-authored-by: SoundwaveUwU <noreply@1000-7.space> Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com> Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ru/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Romanian) Currently translated at 37.1% (461 of 1240 strings) feat(lang): translated using Weblate (Romanian) Currently translated at 37.0% (459 of 1240 strings) feat(lang): translated using Weblate (Romanian) Currently translated at 34.8% (432 of 1240 strings) Co-authored-by: Don Cezar <goldie.czr@gmail.com> Co-authored-by: Dragos <themsk@yahoo.com> Co-authored-by: Eduard Oancea <uberfly@420blaze.it> Co-authored-by: Hosted Weblate <hosted@weblate.org> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ro/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Bulgarian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Bulgarian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Bulgarian) Currently translated at 57.4% (712 of 1240 strings) feat(lang): translated using Weblate (Bulgarian) Currently translated at 13.2% (164 of 1240 strings) feat(lang): translated using Weblate (Bulgarian) Currently translated at 4.8% (60 of 1240 strings) feat(lang): added translation using Weblate (Bulgarian) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: sct <sctsnipe@gmail.com> Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/bg/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 99.1% (1230 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 99.1% (1230 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 99.1% (1230 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 97.9% (1215 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 82.0% (1017 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 72.9% (905 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 72.9% (905 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 71.3% (885 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 64.9% (805 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 64.4% (799 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 63.8% (792 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 63.7% (791 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 57.5% (714 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 49.9% (619 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 35.9% (446 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 35.9% (446 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 32.1% (399 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 24.6% (306 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 18.9% (235 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 17.5% (217 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 17.3% (215 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 8.0% (100 of 1240 strings) feat(lang): translated using Weblate (Ukrainian) Currently translated at 3.3% (41 of 1240 strings) feat(lang): added translation using Weblate (Ukrainian) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: Michael Michael <michaelvelosk@gmail.com> Co-authored-by: sct <sctsnipe@gmail.com> Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/uk/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Catalan) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Catalan) Currently translated at 100.0% (1240 of 1240 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/ca/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Czech) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Czech) Currently translated at 99.6% (1236 of 1240 strings) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: Karel Krýda <karel.kryda@gmail.com> Co-authored-by: Smexhy <roman.bartik@icloud.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/cs/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Croatian) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.8% (1238 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.8% (1238 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.6% (1236 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.5% (1235 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.5% (1235 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 99.1% (1230 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 97.5% (1210 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.5% (1185 of 1240 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.6% (1182 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.6% (1182 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.2% (1177 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 95.2% (1177 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 94.3% (1166 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 91.7% (1134 of 1236 strings) feat(lang): translated using Weblate (Croatian) Currently translated at 91.7% (1134 of 1236 strings) Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: Milo Ivir <mail@milotype.de> Co-authored-by: Stjepan <stjepstjepanovic@gmail.com> Co-authored-by: lpispek <lpispek@gmail.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hr/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Hungarian) Currently translated at 91.3% (1133 of 1240 strings) feat(lang): translated using Weblate (Hungarian) Currently translated at 89.3% (1108 of 1240 strings) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: Levente Szajkó <leviko112@gmail.com> 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 (Hebrew) Currently translated at 13.9% (172 of 1236 strings) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: osh <osh@osh.cc> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/he/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Polish) Currently translated at 99.1% (1225 of 1236 strings) Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pl/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Italian) Currently translated at 92.8% (1148 of 1236 strings) Co-authored-by: Francesco <francy.ammirati@hotmail.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Arabic) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Arabic) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Arabic) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Fhd-pro <juve.11@msn.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ar/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Dutch) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Dutch) Currently translated at 100.0% (1234 of 1234 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 (Spanish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Spanish) Currently translated at 100.0% (1236 of 1236 strings) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: gallegonovato <fran-carro@hotmail.es> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (French) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (French) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (French) Currently translated at 100.0% (1236 of 1236 strings) feat(lang): translated using Weblate (French) Currently translated at 99.9% (1235 of 1236 strings) feat(lang): translated using Weblate (French) Currently translated at 99.9% (1235 of 1236 strings) Co-authored-by: Baptiste <baptiste.nee@me.com> Co-authored-by: Dimitri <dimitridroeck@gmail.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: Maxime Lafarie <maxime.lafarie@gmail.com> Co-authored-by: Miguel <mig.mllr@gmail.com> Co-authored-by: asurare <jonathan.biteau16@gmail.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (1236 of 1236 strings) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: Per Erik <urbanlolface@gmail.com> Co-authored-by: Shjosan <shjosan@kakmix.co> Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Finnish) Currently translated at 2.6% (33 of 1240 strings) feat(lang): added translation using Weblate (Finnish) Co-authored-by: Eero Konttaniemi <eero.konttaniemi@gmail.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: sct <sctsnipe@gmail.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fi/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Serbian) Currently translated at 50.8% (630 of 1240 strings) Co-authored-by: Hosted Weblate <hosted@weblate.org> Co-authored-by: Milan Smudja <smudja@gmail.com> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sr/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Korean) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Developer J <jshsakura@gmail.com> Co-authored-by: Hosted Weblate <hosted@weblate.org> Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ko/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1240 of 1240 strings) feat(lang): translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1234 of 1234 strings) Co-authored-by: Haohao Zhang <hyacz@foxmail.com> 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/zh_Hans/ Translation: Overseerr/Overseerr Frontend --------- Co-authored-by: Cleiton Carvalho <cleitonsilvacarvalho@gmail.com> Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de> Co-authored-by: Thomas Schöneberg <ta.schoeneberg@gmail.com> Co-authored-by: Anders Ecklon <aecklon@gmail.com> Co-authored-by: Kenneth Hansen <erathor@live.dk> Co-authored-by: BeardedWatermelon <BeardedWatermelon@users.noreply.hosted.weblate.org> Co-authored-by: SoundwaveUwU <SoundwaveUwU@users.noreply.hosted.weblate.org> Co-authored-by: SoundwaveUwU <noreply@1000-7.space> Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com> Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com> Co-authored-by: Don Cezar <goldie.czr@gmail.com> Co-authored-by: Dragos <themsk@yahoo.com> Co-authored-by: Eduard Oancea <uberfly@420blaze.it> Co-authored-by: sct <sctsnipe@gmail.com> Co-authored-by: Michael Michael <michaelvelosk@gmail.com> Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com> Co-authored-by: dtalens <databio@gmail.com> Co-authored-by: Karel Krýda <karel.kryda@gmail.com> Co-authored-by: Smexhy <roman.bartik@icloud.com> Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com> Co-authored-by: Milo Ivir <mail@milotype.de> Co-authored-by: Stjepan <stjepstjepanovic@gmail.com> Co-authored-by: lpispek <lpispek@gmail.com> Co-authored-by: Levente Szajkó <leviko112@gmail.com> Co-authored-by: osh <osh@osh.cc> Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com> Co-authored-by: Francesco <francy.ammirati@hotmail.com> Co-authored-by: Fhd-pro <juve.11@msn.com> Co-authored-by: Kobe <kobaubarr@gmail.com> Co-authored-by: gallegonovato <fran-carro@hotmail.es> Co-authored-by: Baptiste <baptiste.nee@me.com> Co-authored-by: Dimitri <dimitridroeck@gmail.com> Co-authored-by: Maxime Lafarie <maxime.lafarie@gmail.com> Co-authored-by: Miguel <mig.mllr@gmail.com> Co-authored-by: asurare <jonathan.biteau16@gmail.com> Co-authored-by: Per Erik <urbanlolface@gmail.com> Co-authored-by: Shjosan <shjosan@kakmix.co> Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl> Co-authored-by: Eero Konttaniemi <eero.konttaniemi@gmail.com> Co-authored-by: Milan Smudja <smudja@gmail.com> Co-authored-by: Developer J <jshsakura@gmail.com> Co-authored-by: Haohao Zhang <hyacz@foxmail.com> Co-authored-by: lkw123 <lkw20010211@gmail.com> * feat(lang): add lang config for Bulgarian, Finnish, Ukrainian, Indonesian, Slovak, Turkish and Maori (#3834) * fix: correct deeplinks on iPad (#3883) * feat(studios): add a24 to studios list (#3902) * docs: add demrich as a contributor for code (#3906) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat(watchlist): Cache watchlist requests with matching E-Tags (#3901) * perf(watchlist): add E-Tag caching to Plex watchlist requests * refactor(watchlist): increase frequency of watchlist requests * fix: sync watchlist every 3 min instead of 3 sec * docs: add maxnatamo as a contributor for code (#3907) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * feat(plex): refresh token schedule (#3875) * feat: refresh token schedule fix #3861 * fix(i18n): add i18n message * refactor(plextv): use randomUUID crypto instead custom function * docs: add DamsDev1 as a contributor for code (#3924) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> * fix: correct icon showing on certain phones when not pulled (#3939) * feat: add support for requesting "Specials" for TV Shows (#3724) * feat: add support for requesting "Specials" for TV Shows This commit is responsible for adding support in Overseerr for requesting "Special" episodes for TV Shows. This request has become especially pertinent when you consider shows like "Doctor Who". These shows have Specials that are critical to understanding the plot of a TV show. fix #779 * chore(yarn.lock): undo inappropriate changes to yarn.lock I was informed by @sct in a comment on the #3724 PR that it was not appropriate to commit the changes that ended up being made to the yarn.lock file. This commit is responsible, then, for undoing the changes to the yarn.lock file that ended up being submitted. * refactor: change loose equality to strict equality I received a comment from OwsleyJr pointing out that we are using loose equality when we could alternatively just be using strict equality to increase the robustness of our code. This commit does exactly that by squashing out previous usages of loose equality in my commits and replacing them with strict equality * refactor: move 'Specials' string to a global message Owsley pointed out that we are redefining the 'Specials' string multiple times throughout this PR. Instead, we can just move it as a global message. This commit does exactly that. It squashes out and previous declarations of the 'Specials' string inside the src files, and moves it directly to the global messages file. * docs: add AhmedNSidd as a contributor for code (#3964) [skip ci] * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --------- Co-authored-by: Isaac M <masesisaac@gmail.com> Co-authored-by: Joseph Risk <j0srisk@gmail.com> Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: Loetwiek <79059734+Loetwiek@users.noreply.github.com> Co-authored-by: Loetwiek <lodommerholtcm@gmail.com> Co-authored-by: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Co-authored-by: Fuochi <ffuochi@hotmail.com> Co-authored-by: Weblate (bot) <hosted@weblate.org> Co-authored-by: Cleiton Carvalho <cleitonsilvacarvalho@gmail.com> Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de> Co-authored-by: Thomas Schöneberg <ta.schoeneberg@gmail.com> Co-authored-by: Anders Ecklon <aecklon@gmail.com> Co-authored-by: Kenneth Hansen <erathor@live.dk> Co-authored-by: BeardedWatermelon <BeardedWatermelon@users.noreply.hosted.weblate.org> Co-authored-by: SoundwaveUwU <SoundwaveUwU@users.noreply.hosted.weblate.org> Co-authored-by: SoundwaveUwU <noreply@1000-7.space> Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com> Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com> Co-authored-by: Don Cezar <goldie.czr@gmail.com> Co-authored-by: Dragos <themsk@yahoo.com> Co-authored-by: Eduard Oancea <uberfly@420blaze.it> Co-authored-by: sct <sctsnipe@gmail.com> Co-authored-by: Michael Michael <michaelvelosk@gmail.com> Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com> Co-authored-by: dtalens <databio@gmail.com> Co-authored-by: Karel Krýda <karel.kryda@gmail.com> Co-authored-by: Smexhy <roman.bartik@icloud.com> Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com> Co-authored-by: Milo Ivir <mail@milotype.de> Co-authored-by: Stjepan <stjepstjepanovic@gmail.com> Co-authored-by: lpispek <lpispek@gmail.com> Co-authored-by: Levente Szajkó <leviko112@gmail.com> Co-authored-by: osh <osh@osh.cc> Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com> Co-authored-by: Francesco <francy.ammirati@hotmail.com> Co-authored-by: Fhd-pro <juve.11@msn.com> Co-authored-by: Kobe <kobaubarr@gmail.com> Co-authored-by: gallegonovato <fran-carro@hotmail.es> Co-authored-by: Baptiste <baptiste.nee@me.com> Co-authored-by: Dimitri <dimitridroeck@gmail.com> Co-authored-by: Maxime Lafarie <maxime.lafarie@gmail.com> Co-authored-by: Miguel <mig.mllr@gmail.com> Co-authored-by: asurare <jonathan.biteau16@gmail.com> Co-authored-by: Per Erik <urbanlolface@gmail.com> Co-authored-by: Shjosan <shjosan@kakmix.co> Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl> Co-authored-by: Eero Konttaniemi <eero.konttaniemi@gmail.com> Co-authored-by: Milan Smudja <smudja@gmail.com> Co-authored-by: Developer J <jshsakura@gmail.com> Co-authored-by: Haohao Zhang <hyacz@foxmail.com> Co-authored-by: lkw123 <lkw20010211@gmail.com> Co-authored-by: Jordan Jones <me@jjones.tech> Co-authored-by: Brandon Cohen <brandon@z3hn.dev> Co-authored-by: David Emrich <demrich@me.com> Co-authored-by: Max T. Kristiansen <me@maxtrier.dk> Co-authored-by: Damien Fajole <60252259+DamsDev1@users.noreply.github.com> Co-authored-by: Ahmed Siddiqui <36286128+AhmedNSidd@users.noreply.github.com>
741 lines
28 KiB
TypeScript
741 lines
28 KiB
TypeScript
import Alert from '@app/components/Common/Alert';
|
|
import Badge from '@app/components/Common/Badge';
|
|
import Modal from '@app/components/Common/Modal';
|
|
import type { RequestOverrides } from '@app/components/RequestModal/AdvancedRequester';
|
|
import AdvancedRequester from '@app/components/RequestModal/AdvancedRequester';
|
|
import QuotaDisplay from '@app/components/RequestModal/QuotaDisplay';
|
|
import SearchByNameModal from '@app/components/RequestModal/SearchByNameModal';
|
|
import useSettings from '@app/hooks/useSettings';
|
|
import { useUser } from '@app/hooks/useUser';
|
|
import globalMessages from '@app/i18n/globalMessages';
|
|
import defineMessages from '@app/utils/defineMessages';
|
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
|
import type SeasonRequest from '@server/entity/SeasonRequest';
|
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
|
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
|
import { Permission } from '@server/lib/permissions';
|
|
import type { TvDetails } from '@server/models/Tv';
|
|
import { useState } from 'react';
|
|
import { useIntl } from 'react-intl';
|
|
import { useToasts } from 'react-toast-notifications';
|
|
import useSWR, { mutate } from 'swr';
|
|
|
|
const messages = defineMessages('components.RequestModal', {
|
|
requestadmin: 'This request will be approved automatically.',
|
|
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
|
requestseriestitle: 'Request Series',
|
|
requestseries4ktitle: 'Request Series in 4K',
|
|
edit: 'Edit Request',
|
|
approve: 'Approve Request',
|
|
cancel: 'Cancel Request',
|
|
pendingrequest: 'Pending Request',
|
|
pending4krequest: 'Pending 4K Request',
|
|
requestfrom: "{username}'s request is pending approval.",
|
|
requestseasons:
|
|
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}',
|
|
requestseasons4k:
|
|
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}} in 4K',
|
|
alreadyrequested: 'Already Requested',
|
|
selectseason: 'Select Season(s)',
|
|
season: 'Season',
|
|
numberofepisodes: '# of Episodes',
|
|
seasonnumber: 'Season {number}',
|
|
errorediting: 'Something went wrong while editing the request.',
|
|
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
|
|
requestApproved: 'Request for <strong>{title}</strong> approved!',
|
|
requestcancelled: 'Request for <strong>{title}</strong> canceled.',
|
|
autoapproval: 'Automatic Approval',
|
|
requesterror: 'Something went wrong while submitting the request.',
|
|
pendingapproval: 'Your request is pending approval.',
|
|
});
|
|
|
|
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
tmdbId: number;
|
|
onCancel?: () => void;
|
|
onComplete?: (newStatus: MediaStatus) => void;
|
|
onUpdating?: (isUpdating: boolean) => void;
|
|
is4k?: boolean;
|
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
|
}
|
|
|
|
const TvRequestModal = ({
|
|
onCancel,
|
|
onComplete,
|
|
tmdbId,
|
|
onUpdating,
|
|
editRequest,
|
|
is4k = false,
|
|
}: RequestModalProps) => {
|
|
const settings = useSettings();
|
|
const { addToast } = useToasts();
|
|
const editingSeasons: number[] = (editRequest?.seasons ?? []).map(
|
|
(season) => season.seasonNumber
|
|
);
|
|
const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${tmdbId}`);
|
|
const [requestOverrides, setRequestOverrides] =
|
|
useState<RequestOverrides | null>(null);
|
|
const [selectedSeasons, setSelectedSeasons] = useState<number[]>(
|
|
editRequest ? editingSeasons : []
|
|
);
|
|
const intl = useIntl();
|
|
const { user, hasPermission } = useUser();
|
|
const [searchModal, setSearchModal] = useState<{
|
|
show: boolean;
|
|
}>({
|
|
show: true,
|
|
});
|
|
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined);
|
|
const { data: quota } = useSWR<QuotaResponse>(
|
|
user &&
|
|
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
|
|
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
|
|
: null
|
|
);
|
|
|
|
const currentlyRemaining =
|
|
(quota?.tv.remaining ?? 0) -
|
|
selectedSeasons.length +
|
|
(editRequest?.seasons ?? []).length;
|
|
|
|
const updateRequest = async (alsoApproveRequest = false) => {
|
|
if (!editRequest) {
|
|
return;
|
|
}
|
|
|
|
if (onUpdating) {
|
|
onUpdating(true);
|
|
}
|
|
|
|
try {
|
|
if (selectedSeasons.length > 0) {
|
|
const res = await fetch(`/api/v1/request/${editRequest.id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
mediaType: 'tv',
|
|
serverId: requestOverrides?.server,
|
|
profileId: requestOverrides?.profile,
|
|
rootFolder: requestOverrides?.folder,
|
|
languageProfileId: requestOverrides?.language,
|
|
userId: requestOverrides?.user?.id,
|
|
tags: requestOverrides?.tags,
|
|
seasons: selectedSeasons,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
|
|
if (alsoApproveRequest) {
|
|
const res = await fetch(`/api/v1/request/${editRequest.id}/approve`, {
|
|
method: 'POST',
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
}
|
|
} else {
|
|
const res = await fetch(`/api/v1/request/${editRequest.id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
}
|
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
|
|
|
addToast(
|
|
<span>
|
|
{selectedSeasons.length > 0
|
|
? intl.formatMessage(
|
|
alsoApproveRequest
|
|
? messages.requestApproved
|
|
: messages.requestedited,
|
|
{
|
|
title: data?.name,
|
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
|
}
|
|
)
|
|
: intl.formatMessage(messages.requestcancelled, {
|
|
title: data?.name,
|
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
|
})}
|
|
</span>,
|
|
{
|
|
appearance: 'success',
|
|
autoDismiss: true,
|
|
}
|
|
);
|
|
if (onComplete) {
|
|
onComplete(MediaStatus.PENDING);
|
|
}
|
|
} catch (e) {
|
|
addToast(<span>{intl.formatMessage(messages.errorediting)}</span>, {
|
|
appearance: 'error',
|
|
autoDismiss: true,
|
|
});
|
|
} finally {
|
|
if (onUpdating) {
|
|
onUpdating(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const sendRequest = async () => {
|
|
if (
|
|
settings.currentSettings.partialRequestsEnabled &&
|
|
selectedSeasons.length === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (onUpdating) {
|
|
onUpdating(true);
|
|
}
|
|
|
|
try {
|
|
let overrideParams = {};
|
|
if (requestOverrides) {
|
|
overrideParams = {
|
|
serverId: requestOverrides.server,
|
|
profileId: requestOverrides.profile,
|
|
rootFolder: requestOverrides.folder,
|
|
languageProfileId: requestOverrides.language,
|
|
userId: requestOverrides?.user?.id,
|
|
tags: requestOverrides.tags,
|
|
};
|
|
}
|
|
const res = await fetch('/api/v1/request', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
mediaId: data?.id,
|
|
tvdbId: tvdbId ?? data?.externalIds.tvdbId,
|
|
mediaType: 'tv',
|
|
is4k,
|
|
seasons: settings.currentSettings.partialRequestsEnabled
|
|
? selectedSeasons
|
|
: getAllSeasons().filter(
|
|
(season) => !getAllRequestedSeasons().includes(season)
|
|
),
|
|
...overrideParams,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
const mediaRequest: MediaRequest = await res.json();
|
|
|
|
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
|
|
|
if (mediaRequest) {
|
|
if (onComplete) {
|
|
onComplete(mediaRequest.media.status);
|
|
}
|
|
addToast(
|
|
<span>
|
|
{intl.formatMessage(messages.requestSuccess, {
|
|
title: data?.name,
|
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
|
})}
|
|
</span>,
|
|
{ appearance: 'success', autoDismiss: true }
|
|
);
|
|
}
|
|
} catch (e) {
|
|
addToast(intl.formatMessage(messages.requesterror), {
|
|
appearance: 'error',
|
|
autoDismiss: true,
|
|
});
|
|
} finally {
|
|
if (onUpdating) {
|
|
onUpdating(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getAllSeasons = (): number[] => {
|
|
return (data?.seasons ?? [])
|
|
.filter((season) => season.episodeCount !== 0)
|
|
.map((season) => season.seasonNumber);
|
|
};
|
|
|
|
const getAllRequestedSeasons = (): number[] => {
|
|
const requestedSeasons = (data?.mediaInfo?.requests ?? [])
|
|
.filter(
|
|
(request) =>
|
|
request.is4k === is4k &&
|
|
request.status !== MediaRequestStatus.DECLINED
|
|
)
|
|
.reduce((requestedSeasons, request) => {
|
|
return [
|
|
...requestedSeasons,
|
|
...request.seasons
|
|
.filter((season) => !editingSeasons.includes(season.seasonNumber))
|
|
.map((sr) => sr.seasonNumber),
|
|
];
|
|
}, [] as number[]);
|
|
|
|
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
|
|
.filter(
|
|
(season) =>
|
|
(season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
|
season[is4k ? 'status4k' : 'status'] ===
|
|
MediaStatus.PARTIALLY_AVAILABLE ||
|
|
season[is4k ? 'status4k' : 'status'] === MediaStatus.PROCESSING) &&
|
|
!requestedSeasons.includes(season.seasonNumber)
|
|
)
|
|
.map((season) => season.seasonNumber);
|
|
|
|
return [...requestedSeasons, ...availableSeasons];
|
|
};
|
|
|
|
const isSelectedSeason = (seasonNumber: number): boolean =>
|
|
selectedSeasons.includes(seasonNumber);
|
|
|
|
const toggleSeason = (seasonNumber: number): void => {
|
|
// If this season already has a pending request, don't allow it to be toggled
|
|
if (getAllRequestedSeasons().includes(seasonNumber)) {
|
|
return;
|
|
}
|
|
|
|
// If there are no more remaining requests available, block toggle
|
|
if (
|
|
quota?.tv.limit &&
|
|
currentlyRemaining <= 0 &&
|
|
!isSelectedSeason(seasonNumber)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (selectedSeasons.includes(seasonNumber)) {
|
|
setSelectedSeasons((seasons) =>
|
|
seasons.filter((sn) => sn !== seasonNumber)
|
|
);
|
|
} else {
|
|
setSelectedSeasons((seasons) => [...seasons, seasonNumber]);
|
|
}
|
|
};
|
|
|
|
const unrequestedSeasons = getAllSeasons().filter(
|
|
(season) => !getAllRequestedSeasons().includes(season)
|
|
);
|
|
|
|
const toggleAllSeasons = (): void => {
|
|
// If the user has a quota and not enough requests for all seasons, block toggleAllSeasons
|
|
if (
|
|
quota?.tv.limit &&
|
|
(quota?.tv.remaining ?? 0) < unrequestedSeasons.length
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data &&
|
|
selectedSeasons.length >= 0 &&
|
|
selectedSeasons.length < unrequestedSeasons.length
|
|
) {
|
|
setSelectedSeasons(unrequestedSeasons);
|
|
} else {
|
|
setSelectedSeasons([]);
|
|
}
|
|
};
|
|
|
|
const isAllSeasons = (): boolean => {
|
|
if (!data) {
|
|
return false;
|
|
}
|
|
return (
|
|
selectedSeasons.length ===
|
|
getAllSeasons().filter(
|
|
(season) => !getAllRequestedSeasons().includes(season)
|
|
).length
|
|
);
|
|
};
|
|
|
|
const getSeasonRequest = (
|
|
seasonNumber: number
|
|
): SeasonRequest | undefined => {
|
|
let seasonRequest: SeasonRequest | undefined;
|
|
|
|
if (
|
|
data?.mediaInfo &&
|
|
(data.mediaInfo.requests || []).filter(
|
|
(request) =>
|
|
request.is4k === is4k &&
|
|
request.status !== MediaRequestStatus.DECLINED
|
|
).length > 0
|
|
) {
|
|
data.mediaInfo.requests
|
|
.filter((request) => request.is4k === is4k)
|
|
.forEach((request) => {
|
|
if (!seasonRequest) {
|
|
seasonRequest = request.seasons.find(
|
|
(season) => season.seasonNumber === seasonNumber
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
return seasonRequest;
|
|
};
|
|
|
|
const isOwner = editRequest && editRequest.requestedBy.id === user?.id;
|
|
|
|
return data && !error && !data.externalIds.tvdbId && searchModal.show ? (
|
|
<SearchByNameModal
|
|
tvdbId={tvdbId}
|
|
setTvdbId={setTvdbId}
|
|
closeModal={() => setSearchModal({ show: false })}
|
|
onCancel={onCancel}
|
|
modalTitle={intl.formatMessage(
|
|
is4k ? messages.requestseries4ktitle : messages.requestseriestitle
|
|
)}
|
|
modalSubTitle={data.name}
|
|
tmdbId={tmdbId}
|
|
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
|
/>
|
|
) : (
|
|
<Modal
|
|
loading={!data && !error}
|
|
backgroundClickable
|
|
onCancel={tvdbId ? () => setSearchModal({ show: true }) : onCancel}
|
|
onOk={() =>
|
|
editRequest
|
|
? hasPermission(Permission.MANAGE_REQUESTS)
|
|
? updateRequest(true)
|
|
: updateRequest()
|
|
: sendRequest()
|
|
}
|
|
title={intl.formatMessage(
|
|
editRequest
|
|
? is4k
|
|
? messages.pending4krequest
|
|
: messages.pendingrequest
|
|
: is4k
|
|
? messages.requestseries4ktitle
|
|
: messages.requestseriestitle
|
|
)}
|
|
subTitle={data?.name}
|
|
okText={
|
|
editRequest
|
|
? selectedSeasons.length === 0
|
|
? intl.formatMessage(messages.cancel)
|
|
: hasPermission(Permission.MANAGE_REQUESTS)
|
|
? intl.formatMessage(messages.approve)
|
|
: intl.formatMessage(messages.edit)
|
|
: getAllRequestedSeasons().length >= getAllSeasons().length
|
|
? intl.formatMessage(messages.alreadyrequested)
|
|
: !settings.currentSettings.partialRequestsEnabled
|
|
? intl.formatMessage(
|
|
is4k ? globalMessages.request4k : globalMessages.request
|
|
)
|
|
: selectedSeasons.length === 0
|
|
? intl.formatMessage(messages.selectseason)
|
|
: intl.formatMessage(
|
|
is4k ? messages.requestseasons4k : messages.requestseasons,
|
|
{
|
|
seasonCount: selectedSeasons.length,
|
|
}
|
|
)
|
|
}
|
|
okDisabled={
|
|
editRequest
|
|
? false
|
|
: !settings.currentSettings.partialRequestsEnabled &&
|
|
quota?.tv.limit &&
|
|
unrequestedSeasons.length > quota.tv.limit
|
|
? true
|
|
: getAllRequestedSeasons().length >= getAllSeasons().length ||
|
|
(settings.currentSettings.partialRequestsEnabled &&
|
|
selectedSeasons.length === 0)
|
|
}
|
|
okButtonType={
|
|
editRequest
|
|
? settings.currentSettings.partialRequestsEnabled &&
|
|
selectedSeasons.length === 0
|
|
? 'danger'
|
|
: hasPermission(Permission.MANAGE_REQUESTS)
|
|
? 'success'
|
|
: 'primary'
|
|
: 'primary'
|
|
}
|
|
cancelText={
|
|
editRequest
|
|
? intl.formatMessage(globalMessages.close)
|
|
: tvdbId
|
|
? intl.formatMessage(globalMessages.back)
|
|
: intl.formatMessage(globalMessages.cancel)
|
|
}
|
|
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
|
>
|
|
{editRequest
|
|
? isOwner
|
|
? intl.formatMessage(messages.pendingapproval)
|
|
: intl.formatMessage(messages.requestfrom, {
|
|
username: editRequest?.requestedBy.displayName,
|
|
})
|
|
: null}
|
|
{hasPermission(
|
|
[
|
|
Permission.MANAGE_REQUESTS,
|
|
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
|
|
is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV,
|
|
],
|
|
{ type: 'or' }
|
|
) &&
|
|
!(
|
|
quota?.tv.limit &&
|
|
!settings.currentSettings.partialRequestsEnabled &&
|
|
unrequestedSeasons.length > (quota?.tv.remaining ?? 0)
|
|
) &&
|
|
getAllRequestedSeasons().length < getAllSeasons().length &&
|
|
!editRequest && (
|
|
<p className="mt-6">
|
|
<Alert
|
|
title={intl.formatMessage(messages.requestadmin)}
|
|
type="info"
|
|
/>
|
|
</p>
|
|
)}
|
|
{(quota?.tv.limit ?? 0) > 0 && (
|
|
<QuotaDisplay
|
|
mediaType="tv"
|
|
quota={quota?.tv}
|
|
remaining={
|
|
!settings.currentSettings.partialRequestsEnabled &&
|
|
unrequestedSeasons.length > (quota?.tv.remaining ?? 0)
|
|
? 0
|
|
: currentlyRemaining
|
|
}
|
|
userOverride={
|
|
requestOverrides?.user && requestOverrides.user.id !== user?.id
|
|
? requestOverrides?.user?.id
|
|
: undefined
|
|
}
|
|
overLimit={
|
|
!settings.currentSettings.partialRequestsEnabled &&
|
|
unrequestedSeasons.length > (quota?.tv.remaining ?? 0)
|
|
? unrequestedSeasons.length
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
<div className="flex flex-col">
|
|
<div className="-mx-4 sm:mx-0">
|
|
<div className="inline-block min-w-full py-2 align-middle">
|
|
<div className="overflow-hidden border border-gray-700 shadow backdrop-blur sm:rounded-lg">
|
|
<table className="min-w-full">
|
|
<thead>
|
|
<tr>
|
|
<th
|
|
className={`w-16 bg-gray-700 bg-opacity-80 px-4 py-3 ${
|
|
!settings.currentSettings.partialRequestsEnabled &&
|
|
'hidden'
|
|
}`}
|
|
>
|
|
<span
|
|
role="checkbox"
|
|
tabIndex={0}
|
|
aria-checked={isAllSeasons()}
|
|
onClick={() => toggleAllSeasons()}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === 'Space') {
|
|
toggleAllSeasons();
|
|
}
|
|
}}
|
|
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
|
|
quota?.tv.remaining &&
|
|
quota.tv.limit &&
|
|
quota.tv.remaining < unrequestedSeasons.length
|
|
? 'opacity-50'
|
|
: ''
|
|
}`}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`${
|
|
isAllSeasons() ? 'bg-indigo-500' : 'bg-gray-800'
|
|
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
|
></span>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`${
|
|
isAllSeasons() ? '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>
|
|
</th>
|
|
<th className="bg-gray-700 bg-opacity-80 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
|
|
{intl.formatMessage(messages.season)}
|
|
</th>
|
|
<th className="bg-gray-700 bg-opacity-80 px-5 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
|
|
{intl.formatMessage(messages.numberofepisodes)}
|
|
</th>
|
|
<th className="bg-gray-700 bg-opacity-80 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
|
|
{intl.formatMessage(globalMessages.status)}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-700">
|
|
{data?.seasons
|
|
.filter((season) => season.episodeCount !== 0)
|
|
.map((season) => {
|
|
const seasonRequest = getSeasonRequest(
|
|
season.seasonNumber
|
|
);
|
|
const mediaSeason = data?.mediaInfo?.seasons.find(
|
|
(sn) =>
|
|
sn.seasonNumber === season.seasonNumber &&
|
|
sn[is4k ? 'status4k' : 'status'] !==
|
|
MediaStatus.UNKNOWN
|
|
);
|
|
return (
|
|
<tr key={`season-${season.id}`}>
|
|
<td
|
|
className={`whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100 ${
|
|
!settings.currentSettings
|
|
.partialRequestsEnabled && 'hidden'
|
|
}`}
|
|
>
|
|
<span
|
|
role="checkbox"
|
|
tabIndex={0}
|
|
aria-checked={
|
|
!!mediaSeason ||
|
|
(!!seasonRequest &&
|
|
!editingSeasons.includes(
|
|
season.seasonNumber
|
|
)) ||
|
|
isSelectedSeason(season.seasonNumber)
|
|
}
|
|
onClick={() => toggleSeason(season.seasonNumber)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === 'Space') {
|
|
toggleSeason(season.seasonNumber);
|
|
}
|
|
}}
|
|
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
|
|
mediaSeason ||
|
|
(quota?.tv.limit &&
|
|
currentlyRemaining <= 0 &&
|
|
!isSelectedSeason(season.seasonNumber)) ||
|
|
(!!seasonRequest &&
|
|
!editingSeasons.includes(season.seasonNumber))
|
|
? 'opacity-50'
|
|
: ''
|
|
}`}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`${
|
|
!!mediaSeason ||
|
|
(!!seasonRequest &&
|
|
!editingSeasons.includes(
|
|
season.seasonNumber
|
|
)) ||
|
|
isSelectedSeason(season.seasonNumber)
|
|
? '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={`${
|
|
!!mediaSeason ||
|
|
(!!seasonRequest &&
|
|
!editingSeasons.includes(
|
|
season.seasonNumber
|
|
)) ||
|
|
isSelectedSeason(season.seasonNumber)
|
|
? '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>
|
|
</td>
|
|
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
|
{season.seasonNumber === 0
|
|
? intl.formatMessage(globalMessages.specials)
|
|
: intl.formatMessage(messages.seasonnumber, {
|
|
number: season.seasonNumber,
|
|
})}
|
|
</td>
|
|
<td className="whitespace-nowrap px-5 py-4 text-sm leading-5 text-gray-200 md:px-6">
|
|
{season.episodeCount}
|
|
</td>
|
|
<td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
|
|
{!seasonRequest && !mediaSeason && (
|
|
<Badge>
|
|
{intl.formatMessage(
|
|
globalMessages.notrequested
|
|
)}
|
|
</Badge>
|
|
)}
|
|
{!mediaSeason &&
|
|
seasonRequest?.status ===
|
|
MediaRequestStatus.PENDING && (
|
|
<Badge badgeType="warning">
|
|
{intl.formatMessage(globalMessages.pending)}
|
|
</Badge>
|
|
)}
|
|
{((!mediaSeason &&
|
|
seasonRequest?.status ===
|
|
MediaRequestStatus.APPROVED) ||
|
|
mediaSeason?.[is4k ? 'status4k' : 'status'] ===
|
|
MediaStatus.PROCESSING) && (
|
|
<Badge badgeType="primary">
|
|
{intl.formatMessage(globalMessages.requested)}
|
|
</Badge>
|
|
)}
|
|
{mediaSeason?.[is4k ? 'status4k' : 'status'] ===
|
|
MediaStatus.PARTIALLY_AVAILABLE && (
|
|
<Badge badgeType="success">
|
|
{intl.formatMessage(
|
|
globalMessages.partiallyavailable
|
|
)}
|
|
</Badge>
|
|
)}
|
|
{mediaSeason?.[is4k ? 'status4k' : 'status'] ===
|
|
MediaStatus.AVAILABLE && (
|
|
<Badge badgeType="success">
|
|
{intl.formatMessage(globalMessages.available)}
|
|
</Badge>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
|
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
|
<AdvancedRequester
|
|
type="tv"
|
|
is4k={is4k}
|
|
isAnime={data?.keywords.some(
|
|
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
|
)}
|
|
onChange={(overrides) => setRequestOverrides(overrides)}
|
|
requestUser={editRequest?.requestedBy}
|
|
defaultOverrides={
|
|
editRequest
|
|
? {
|
|
folder: editRequest.rootFolder,
|
|
profile: editRequest.profileId,
|
|
server: editRequest.serverId,
|
|
language: editRequest.languageProfileId,
|
|
tags: editRequest.tags,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default TvRequestModal;
|