1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-16 21:15:33 -04:00

Compare commits

..

76 Commits

Author SHA1 Message Date
kephasdev
d58135bf17 Fixed: Augmenting languages for releases with MULTI and other languages (#10842) 2025-01-17 20:32:09 +02:00
Bogdan
b452c10da3 Bump SonarCloud azure extension for UI analysis to 3.X
(cherry picked from commit 396b2ae7c10c7df749ea23ea93608b56482175a1)
2025-01-14 11:43:51 +02:00
Stevie Robinson
f6b364725d Additional logging for delay profile decisions
(cherry picked from commit fa0f77659cbd3e9efdae55bbedb30fd8288622a6)

Closes #10831
2025-01-12 20:40:59 +02:00
Stevie Robinson
99f6be3f3d New: Show release source in history grab details
(cherry picked from commit 1609f0c9647b89bf55b8c043eeffc8a61653a1e5)

Closes #10830
2025-01-12 20:40:59 +02:00
Stevie Robinson
c2ac49a873 Additional logging for custom format score
(cherry picked from commit 3c8268c428688cc703af76b648c9b3385858274f)

Closes #10828
2025-01-12 20:40:58 +02:00
Weblate
0e24a3e8bc Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <Ano10@users.noreply.translate.servarr.com>
Co-authored-by: Dimitar \"Topper\" Maznekov <d.maznekov@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Mickaël O <mickael.ouillon@ac-bordeaux.fr>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: xumei51201314 <xumei51201314@163.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_Hans/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2025-01-12 15:19:47 +02:00
Weblate
18032cc83b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <Ano10@users.noreply.translate.servarr.com>
Co-authored-by: Dimitar \"Topper\" Maznekov <d.maznekov@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Mickaël O <mickael.ouillon@ac-bordeaux.fr>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2025-01-12 15:19:09 +02:00
Bogdan
927eb38945 Bump version to 5.18.1 2025-01-12 15:16:00 +02:00
Qstick
5fac348613 Bump SonarCloud azure extension to 3.X
(cherry picked from commit 7b8e352d876cd8f8e5b6296f0c3938bed4db8bb8)
2025-01-11 17:44:00 -06:00
Bogdan
7ba9603449 Fixed: Sending Discord notifications with images without absolute links 2025-01-06 13:27:14 +02:00
Bogdan
e36de8ab8d New: Auto tag based on movie status 2025-01-06 04:24:29 +02:00
Stevie Robinson
f8704a1655 Translate backend: Autotagging + CF specs
Signed-off-by: Stevie Robinson <stevie.robinson@gmail.com>
(cherry picked from commit de1cc25c903924fecbca79fedb458d729ae584fd)

Towards #9647
2025-01-06 04:06:34 +02:00
Stevie Robinson
f507d5154e Fixed: Listening on all IPv4 Addresses
(cherry picked from commit 035c474f10c257331a5f47e863d24af82537e335)
2025-01-05 13:53:29 +02:00
Stevie Robinson
5f03e7142a Fixed: qBittorrent Ratio Limit Check
(cherry picked from commit 4dcc015fb19ceb57d2e8f4985c5137e765829d1c)
2025-01-05 13:53:17 +02:00
Bogdan
c0ebbee7c9 Bump version to 5.18.0 2025-01-05 13:52:57 +02:00
Bogdan
4051cf3d80 Fixed: Increase rate limit for PassThePopcorn 2025-01-02 23:16:48 +02:00
Weblate
9876ed64e2 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: 1 <1228553526@qq.com>
Co-authored-by: Alexander Balya <alexander.balya@gmail.com>
Co-authored-by: Ano10 <Ano10@users.noreply.translate.servarr.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Matti Meikäläinen <diefor-93@hotmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Tommy Au <smarttommyau@gmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: marapavelka <mara.pavelka@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2025-01-02 23:13:41 +02:00
Bogdan
2f26974ecc New: Add Tagalog language 2025-01-02 21:16:30 +02:00
Siddhant Naik
25f66a3029 New: Add Marathi language 2025-01-02 19:41:08 +02:00
Bogdan
0e25b2708c Fixed: Sending Manual Interaction Required notifications to Discord for unknown movies 2024-12-31 18:20:16 +02:00
Bogdan
410870d21e Check if backup folder is writable on backup
(cherry picked from commit 8aad79fd3e14eb885724a5e5790803c289be2f25)

Closes #10806
2024-12-31 12:16:28 +02:00
Bogdan
a64d931904 Suggest adding IP to RPC whitelist for on failed Transmission auth
(cherry picked from commit f05e552e8e6dc02cd26444073ab9a678dcb36492)
2024-12-31 12:12:52 +02:00
Bogdan
f0a9e76cfc Bump version to 5.17.2 2024-12-30 00:58:56 +02:00
Mark McDowall
6f23c465ee Don't send session information to Sentry
(cherry picked from commit fae24e98fb9230c2f3701caef457332952c6723f)
2024-12-28 03:32:11 +02:00
Bogdan
af60cca9ae Fixed: Advanced settings for Metadata consumers 2024-12-23 12:15:04 +02:00
Mark McDowall
d34d23a052 Fixed: Movies updated during Import List Sync not reflected in the UI
(cherry picked from commit 1c30ecd66dd0fd1dafaf9ab0e41a11a54eaac132)

Closes #10794
2024-12-23 12:08:25 +02:00
Bogdan
0a0da42543 Bump version to 5.17.1 2024-12-22 13:23:52 +02:00
Bogdan
e5419f6f06 Bump System.Memory
Closes #10791
2024-12-21 11:20:28 +02:00
Bogdan
88d9c08f1a Bump MailKit to 4.8.0 and Microsoft.Data.SqlClient to 2.1.7
Closes #10790
2024-12-21 11:15:14 +02:00
Bogdan
6b4259757c Add test for do not prefer repacks/propers 2024-12-20 20:33:18 +02:00
Mark McDowall
f1d7c56d94 Fixed: Custom Format score bypassing upgrades not being allowed
(cherry picked from commit ebe23104d4b29a3c900a982fb84e75c27ed531ab)

Co-authored-by: CeruleanRed <toni.suta@gmail.com>
2024-12-20 20:33:18 +02:00
Mark McDowall
c81b2e80ee Convert MediaInfo to TypeScript
(cherry picked from commit 4e4bf3507f20c0f8581c66804f8ef406c41952d8)

Closes #10753
2024-12-20 15:37:16 +02:00
Mark McDowall
5efefd804b Upgrade @typescript-eslint packages to 8.181.1
(cherry picked from commit ed10b63fa0c161cac7e0a2084e53785ab1798208)
2024-12-17 13:15:06 +02:00
Mark McDowall
38f9543526 Upgrade Font Awesome to 6.7.1
(cherry picked from commit 016b5718386593c030f14fcac307c93ee1ceeca6)
2024-12-17 13:11:07 +02:00
Mark McDowall
aae68e681e Upgrade babel to 7.26.0
(cherry picked from commit bfcd017012730c97eb587ae2d2e91f72ee7a1de3)
2024-12-17 13:08:36 +02:00
Bogdan
1d21bbf78f Bump version to 5.17.0 2024-12-16 20:24:54 +02:00
Stevie Robinson
99c3c8ce5b Replace URLs in translations with tokens
(cherry picked from commit 98d60e1a8e9abce6b31b3cdd745eff0fed181458)
2024-12-16 15:40:58 +02:00
Weblate
85171e40a5 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2024-12-16 15:28:28 +02:00
Bogdan
86b656d323 Use minor version for core-js in babel/preset-env 2024-12-16 12:40:37 +02:00
Bogdan
5ae5d1043a Improve opening add movie modal for Discover Overview 2024-12-16 01:25:03 +02:00
Mark McDowall
b801aa0935 New: Add metadata links to telegram messages
Co-authored-by: Ivar Stangeby <istangeby@gmail.com>

Fixed errors sending Telegram notifications when links aren't available

(cherry picked from commit 4eab168267db716a9e897a992e3a7f6889571f9f)
(cherry picked from commit 4d7a3d0909437268b4ad0a0dbeb59d45b4435118)

Closes #10242
Closes #10489
2024-12-15 15:20:16 +02:00
Stevie Robinson
b2b5aa1f79 New: Optionally as Instance Name to Telegram notifications
(cherry picked from commit 36633b5d08c19158f185c0fa5faabbaec607fcb5)

Closes #10757
2024-12-15 14:43:23 +02:00
Mark McDowall
8c6ba9a543 Fixed: Augmenting languages from indexer for release with stale indexer ID
(cherry picked from commit cb7489ce8fe933920ea04297bd2941496a0c07c6)

Closes #10768
2024-12-15 14:43:23 +02:00
Mark McDowall
4e024c51d3 Fixed: Movies without tags bypassing tags on Download Client
(cherry picked from commit c0e264cfc520ee387bfc882c95a5822c655e0d9b)

Closes #10765
2024-12-15 14:43:23 +02:00
Mark McDowall
e4106f0ede Upgrade TypeScript and core-js
(cherry picked from commit 148480909917f69ff3b2ca547ccb4716dd56606e)

Closes #10763
2024-12-15 14:43:20 +02:00
Bogdan
9032ac20ff Bump version to 5.16.3 2024-12-15 10:04:38 +02:00
Bogdan
23fce4bf2e Fixed: Refresh backup list on deletion
(cherry picked from commit 3b00112447361b19c04851a510e63f812597a043)
2024-12-15 05:29:12 +02:00
Mark McDowall
64fd8552f8 Fixed: Error getting processes in some cases
(cherry picked from commit b552d4e9f7ca7388404aa0d52566010a54cb0244)
2024-12-15 05:28:39 +02:00
Weblate
e016410c10 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Rodion <rodyon009@gmail.com>
Co-authored-by: Tomer Horowitz <tomerh2001@gmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: farebyting <farelbyting@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: hhjuhl <hans@kopula.dk>
Co-authored-by: kaisernet <afimark7@gmail.com>
Co-authored-by: keysuck <joshkkim@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2024-12-14 02:33:47 +02:00
Bogdan
bea943adf8 New: Tooltip with extra genres on search and collections 2024-12-13 19:17:59 +02:00
Bogdan
9780d20f8a Improve is visible property check for discover movies 2024-12-13 19:14:41 +02:00
Bogdan
62722d45b0 Fixed: Using all movie genres for collection filters 2024-12-13 19:13:55 +02:00
Bogdan
27dd8e8cd5 New: Tooltip with extra genres on movie details page 2024-12-12 21:59:19 +02:00
Bogdan
6c47ede76b Fixed: Refreshing movie genres 2024-12-12 21:58:54 +02:00
Mark McDowall
7b9562bb38 Update React
(cherry picked from commit 4491df3ae7530f2167beebc3548dd01fd2cc1a12)

Towards #10703
2024-12-12 21:01:19 +02:00
Stevie Robinson
8b0b7c1cb0 New: Reactive search button on Wanted pages
(cherry picked from commit e8c3aa20bd92701a16dcd97c5e103b79b3683105)

Closes #10750
2024-12-12 20:42:55 +02:00
Mark McDowall
7ebd341cd6 Sync TimeSpanConverter with upstream
(cherry picked from commit 1374240321f08d1400faf95e84217e4b7a2d116b)

Closes #10756
2024-12-09 14:03:12 +02:00
Bogdan
6c85f166ff Bump version to 5.16.2 2024-12-08 11:22:50 +02:00
Servarr
45aabce107 Automated API Docs update 2024-12-04 14:09:02 +02:00
soup
0caa793df4 New: Add config file setting for CGNAT authentication bypass
(cherry picked from commit 4c41a4f368046f73f82306bbd73bec992392938b)
2024-12-04 13:30:56 +02:00
Stevie Robinson
9a107cc8d7 New: Add Languages to Webhook Notifications
(cherry picked from commit e039dc45e267cf717e00c0a49ba637012f37e3d7)

Closes #10733
2024-12-02 16:59:45 +02:00
Mark McDowall
a6d727fe2a New: Kometa metadata file creation disabled
(cherry picked from commit c62fc9d05bb9e1fe51b454d78e80bd9250e31f89)

Closes #10738
2024-12-02 16:56:12 +02:00
Servarr
01a53d3624 Automated API Docs update 2024-12-02 16:03:25 +02:00
Weblate
348c29c9d7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: Robin Dadswell <robin@robindadswell.tech>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: mryx007 <mryx@mail.de>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_Hans/
Translation: Servarr/Radarr
2024-12-02 15:34:43 +02:00
Bogdan
64739712c6 Add return type for movie lookup and import endpoints
Closes #10737
2024-12-02 15:29:50 +02:00
Bogdan
6ac9cca953 Avoid default category on existing Transmission configurations
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
(cherry picked from commit bd656ae7f66fc9224ef2a57857152ee5d54d54f8)
2024-12-02 15:12:07 +02:00
Bogdan
a2b38c5b7d New: Labels support for Transmission 4.0
(cherry picked from commit 675e3cd38a14ea33c27f2d66a4be2bf802e17d88)
2024-12-02 15:12:07 +02:00
Bogdan
3cc4105d71 Bump NLog, Npgsql, Ical.Net, IPAddressRange, ImageSharp and Polly 2024-12-02 14:30:38 +02:00
Mark McDowall
3449a5d3fe Fixed: Don't fail import if symlink target can't be resolved
(cherry picked from commit 8cb58a63d8ec1b290bc57ad2cf1e90809ceebce9)
2024-12-02 02:32:21 +02:00
Gylesie
5bac157d36 Remove unnecessary heap allocations in local IP check
(cherry picked from commit ed536a85ad5f2062bf6f01f80efddb19fa935f63)
2024-12-02 02:31:28 +02:00
Mark McDowall
114d260f42 Deprecate Sizeleft and Timeleft queue item properties
Rename SizeLeft and TimeLeft queue item properties

(cherry picked from commit b51a49097941e5f306cae5785c63985b319784fd)

Fixed: Error loading queue

(cherry picked from commit f9606518eef78117f1e06a8bcc34af57ab0d2454)
2024-12-01 19:25:45 +02:00
Bogdan
617b9c5d35 Console warnings for missing translations on development builds
(cherry picked from commit 67a1ecb0fea4e6c7dfdb68fbe3ef30d4c22398d8)

Closes #10669
2024-12-01 18:54:53 +02:00
Mark McDowall
ba4ccbb0bd Deluge communication improvements
(cherry picked from commit 183b8b574a4dd948b5fada94d0e645b87710f223)
2024-12-01 14:01:41 +02:00
Mark McDowall
b845268b3d New: Support for new SABnzbd history retention values
Closes #10699

(cherry picked from commit e361f18837d98c089f7dc9c0190221ca8e2cf225)
2024-12-01 14:01:09 +02:00
Bogdan
0fee552074 Bump version to 5.16.1 2024-12-01 14:00:15 +02:00
Mark McDowall
828b994ef4 Support Postgres with non-standard version string
(cherry picked from commit 40f4ef27b22113c1dae0d0cbdee8205132bed68a)
2024-12-01 11:47:19 +00:00
215 changed files with 5385 additions and 3253 deletions

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.16.0'
majorVersion: '5.18.1'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
@@ -1116,20 +1116,20 @@ stages:
vmImage: ${{ variables.windowsImage }}
steps:
- checkout: self # Need history for Sonar analysis
- task: SonarCloudPrepare@2
- task: SonarCloudPrepare@3
env:
SONAR_SCANNER_OPTS: ''
inputs:
SonarCloud: 'SonarCloud'
organization: 'radarr'
scannerMode: 'CLI'
scannerMode: 'cli'
configMode: 'manual'
cliProjectKey: 'Radarr_Radarr.UI'
cliProjectName: 'RadarrUI'
cliProjectVersion: '$(radarrVersion)'
cliSources: './frontend'
- task: SonarCloudAnalyze@2
- task: SonarCloudAnalyze@3
- job: Api_Docs
displayName: API Docs
dependsOn: Prepare
@@ -1205,12 +1205,12 @@ stages:
submodules: true
- powershell: Set-Service SCardSvr -StartupType Manual
displayName: Enable Windows Test Service
- task: SonarCloudPrepare@2
- task: SonarCloudPrepare@3
condition: eq(variables['System.PullRequest.IsFork'], 'False')
inputs:
SonarCloud: 'SonarCloud'
organization: 'radarr'
scannerMode: 'MSBuild'
scannerMode: 'dotnet'
projectKey: 'Radarr_Radarr'
projectName: 'Radarr'
projectVersion: '$(radarrVersion)'
@@ -1223,7 +1223,7 @@ stages:
./build.sh --backend -f net6.0 -r win-x64
TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage
displayName: Coverage Unit Tests
- task: SonarCloudAnalyze@2
- task: SonarCloudAnalyze@3
condition: eq(variables['System.PullRequest.IsFork'], 'False')
displayName: Publish SonarCloud Results
- task: reportgenerator@5.3.11

View File

@@ -210,7 +210,6 @@ module.exports = {
'no-undef-init': 'off',
'no-undefined': 'off',
'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }],
'no-use-before-define': 'error',
// Node.js and CommonJS

View File

@@ -187,7 +187,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: 3
corejs: '3.39'
}
]
]

View File

@@ -41,6 +41,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
indexer,
releaseGroup,
movieMatchType,
releaseSource,
customFormatScore,
nzbInfoUrl,
downloadClient,
@@ -53,6 +54,31 @@ function HistoryDetails(props: HistoryDetailsProps) {
const downloadClientNameInfo = downloadClientName ?? downloadClient;
let releaseSourceMessage = '';
switch (releaseSource) {
case 'Unknown':
releaseSourceMessage = translate('Unknown');
break;
case 'Rss':
releaseSourceMessage = translate('Rss');
break;
case 'Search':
releaseSourceMessage = translate('Search');
break;
case 'UserInvokedSearch':
releaseSourceMessage = translate('UserInvokedSearch');
break;
case 'InteractiveSearch':
releaseSourceMessage = translate('InteractiveSearch');
break;
case 'ReleasePush':
releaseSourceMessage = translate('ReleasePush');
break;
default:
releaseSourceMessage = '';
}
return (
<DescriptionList>
<DescriptionListItem
@@ -88,6 +114,14 @@ function HistoryDetails(props: HistoryDetailsProps) {
/>
) : null}
{releaseSource ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseSource')}
data={releaseSourceMessage}
/>
) : null}
{nzbInfoUrl ? (
<span>
<DescriptionListItemTitle>

View File

@@ -91,6 +91,10 @@
margin-left: 5px;
}
.genres {
pointer-events: all;
}
.links {
margin-left: 5px;
pointer-events: all;

View File

@@ -10,6 +10,7 @@ import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
import MovieStatusLabel from 'Movie/Details/MovieStatusLabel';
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
import MovieGenres from 'Movie/MovieGenres';
import MoviePoster from 'Movie/MoviePoster';
import formatRuntime from 'Utilities/Date/formatRuntime';
import translate from 'Utilities/String/translate';
@@ -249,9 +250,7 @@ class AddNewMovieSearchResult extends Component {
name={icons.GENRE}
size={13}
/>
<span className={styles.genres}>
{genres.slice(0, 3).join(', ')}
</span>
<MovieGenres className={styles.genres} genres={genres} />
</Label> :
null
}
@@ -280,7 +279,7 @@ class AddNewMovieSearchResult extends Component {
}
canFlip={true}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
position={tooltipPositions.TOP}
/>
{

View File

@@ -1,11 +1,16 @@
import Column from 'Components/Table/Column';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { ValidationFailure } from 'typings/pending';
import { FilterBuilderProp, PropertyFilter } from './AppState';
export interface Error {
responseJSON: {
message: string;
};
status?: number;
responseJSON:
| {
message: string | undefined;
}
| ValidationFailure[]
| undefined;
}
export interface AppSectionDeleteState {
@@ -51,6 +56,16 @@ export interface AppSectionItemState<T> {
item: T;
}
export interface AppSectionProviderState<T>
extends AppSectionDeleteState,
AppSectionSaveState {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: T[];
pendingChanges: Partial<T>;
}
interface AppSectionState<T> {
isFetching: boolean;
isPopulated: boolean;

View File

@@ -0,0 +1,6 @@
import { AppSectionProviderState } from 'App/State/AppSectionState';
import Metadata from 'typings/Metadata';
type MetadataAppState = AppSectionProviderState<Metadata>;
export default MetadataAppState;

View File

@@ -1,6 +1,6 @@
import AppSectionState from 'App/State/AppSectionState';
import MovieCredit from 'typings/MovieCredit';
interface MovieCreditAppState extends AppSectionState<MovieCredit> {}
type MovieCreditAppState = AppSectionState<MovieCredit>;
export default MovieCreditAppState;

View File

@@ -20,6 +20,7 @@ import NamingConfig from 'typings/Settings/NamingConfig';
import NamingExample from 'typings/Settings/NamingExample';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import UiSettings from 'typings/Settings/UiSettings';
import MetadataAppState from './MetadataAppState';
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
@@ -36,8 +37,7 @@ export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
AppSectionSaveState {}
export interface NamingExamplesAppState
extends AppSectionItemState<NamingExample> {}
export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
export interface ImportListAppState
extends AppSectionState<ImportList>,
@@ -97,6 +97,7 @@ interface SettingsAppState {
indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
metadata: MetadataAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;

View File

@@ -22,7 +22,7 @@ function createMapStateToProps() {
return {
...collection,
movies: [...collection.movies].sort((a, b) => b.year - a.year),
genres: Array.from(new Set(allGenres)).slice(0, 3)
genres: Array.from(new Set(allGenres))
};
}
);

View File

@@ -10,6 +10,7 @@ import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import { icons, sizes } from 'Helpers/Props';
import MovieGenres from 'Movie/MovieGenres';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
@@ -242,12 +243,10 @@ class CollectionOverview extends Component {
size={sizes.MEDIUM}
>
<Icon
name={icons.PROFILE}
name={icons.GENRE}
size={13}
/>
<span className={styles.genres}>
{genres.join(', ')}
</span>
<MovieGenres className={styles.genres} genres={genres} />
</Label>
}

View File

@@ -139,6 +139,8 @@ ProviderFieldFormGroup.propTypes = {
type: PropTypes.string.isRequired,
advanced: PropTypes.bool.isRequired,
hidden: PropTypes.string,
isDisabled: PropTypes.bool,
provider: PropTypes.string,
pending: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,

View File

@@ -1,7 +1,7 @@
import React, { ComponentPropsWithoutRef } from 'react';
import styles from './TableRowCell.css';
export interface TableRowCellProps extends ComponentPropsWithoutRef<'td'> {}
export type TableRowCellProps = ComponentPropsWithoutRef<'td'>;
export default function TableRowCell({
className = styles.cell,

View File

@@ -85,10 +85,16 @@ $hoverScale: 1.05;
flex: 1 0 auto;
}
.overviewContainer {
display: flex;
justify-content: space-between;
flex: 0 1 1000px;
flex-direction: column;
}
.overview {
composes: link;
flex: 0 1 1000px;
overflow: hidden;
min-height: 0;
}

View File

@@ -11,6 +11,7 @@ interface CssExports {
'link': string;
'lists': string;
'overview': string;
'overviewContainer': string;
'poster': string;
'posterContainer': string;
'title': string;

View File

@@ -133,14 +133,20 @@ class DiscoverMovieOverview extends Component {
/>
</div>
<MoviePoster
className={styles.poster}
<Link
className={styles.link}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
/>
{...linkProps}
>
<MoviePoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
/>
</Link>
</div>
</div>
@@ -242,11 +248,13 @@ class DiscoverMovieOverview extends Component {
</div>
<div className={styles.details}>
<div className={styles.overview}>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={overview}
/>
<div className={styles.overviewContainer}>
<Link className={styles.overview} {...linkProps}>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={overview}
/>
</Link>
</div>
<DiscoverMovieOverviewInfo
@@ -255,7 +263,6 @@ class DiscoverMovieOverview extends Component {
{...overviewOptions}
{...otherProps}
/>
</div>
</div>
</div>

View File

@@ -49,7 +49,7 @@ function isVisible(row, props) {
valueProp
} = row;
return _.has(props, valueProp) && (_.get(props, showProp) || props.sortKey === name);
return _.has(props, valueProp) && _.get(props, valueProp) !== null && (props[showProp] || props.sortKey === name);
}
function getInfoRowProps(row, props) {

View File

@@ -28,6 +28,7 @@ import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import MovieHistoryModal from 'Movie/History/MovieHistoryModal';
import MovieCollectionLabelConnector from 'Movie/MovieCollectionLabelConnector';
import MovieGenres from 'Movie/MovieGenres';
import MoviePoster from 'Movie/MoviePoster';
import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal';
import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
@@ -651,9 +652,7 @@ class MovieDetails extends Component {
name={translate('Genres')}
size={sizes.LARGE}
>
<span className={styles.genres}>
{genres.join(', ')}
</span>
<MovieGenres className={styles.genres} genres={genres} />
</InfoLabel> :
null
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import Label from 'Components/Label';
import Tooltip from 'Components/Tooltip/Tooltip';
import { kinds, sizes, tooltipPositions } from 'Helpers/Props';
interface MovieGenresProps {
className?: string;
genres: string[];
}
function MovieGenres({ className, genres }: MovieGenresProps) {
const firstGenres = genres.slice(0, 3);
const otherGenres = genres.slice(3);
if (otherGenres.length) {
return (
<Tooltip
anchor={<span className={className}>{firstGenres.join(', ')}</span>}
tooltip={
<div>
{otherGenres.map((tag) => {
return (
<Label key={tag} kind={kinds.INFO} size={sizes.LARGE}>
{tag}
</Label>
);
})}
</div>
}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
);
}
return <span className={className}>{firstGenres.join(', ')}</span>;
}
export default MovieGenres;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import MediaInfoProps from 'typings/MediaInfo';
import getEntries from 'Utilities/Object/getEntries';
function MediaInfo(props: MediaInfoProps) {
return (
<DescriptionList>
{getEntries(props).map(([key, value]) => {
const title = key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase());
if (!value) {
return null;
}
return (
<DescriptionListItem key={key} title={title} data={props[key]} />
);
})}
</DescriptionList>
);
}
export default MediaInfo;

View File

@@ -1,33 +0,0 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
function MediaInfoPopover(props) {
return (
<DescriptionList>
{
Object.keys(props).map((key) => {
const title = key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase());
const value = props[key];
if (!value) {
return null;
}
return (
<DescriptionListItem
key={key}
title={title}
data={props[key]}
/>
);
})
}
</DescriptionList>
);
}
export default MediaInfoPopover;

View File

@@ -14,7 +14,7 @@ import MovieFormats from 'Movie/MovieFormats';
import MovieLanguages from 'Movie/MovieLanguages';
import MovieQuality from 'Movie/MovieQuality';
import FileEditModal from 'MovieFile/Edit/FileEditModal';
import MediaInfoConnector from 'MovieFile/MediaInfoConnector';
import MediaInfo from 'MovieFile/MediaInfo';
import * as mediaInfoTypes from 'MovieFile/mediaInfoTypes';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
@@ -224,7 +224,7 @@ class MovieFileEditorRow extends Component {
key={name}
className={styles.audio}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.AUDIO}
movieFileId={id}
/>
@@ -238,7 +238,7 @@ class MovieFileEditorRow extends Component {
key={name}
className={styles.audioLanguages}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.AUDIO_LANGUAGES}
movieFileId={id}
/>
@@ -252,7 +252,7 @@ class MovieFileEditorRow extends Component {
key={name}
className={styles.subtitles}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.SUBTITLES}
movieFileId={id}
/>
@@ -266,7 +266,7 @@ class MovieFileEditorRow extends Component {
key={name}
className={styles.video}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.VIDEO}
movieFileId={id}
/>
@@ -280,7 +280,7 @@ class MovieFileEditorRow extends Component {
key={name}
className={styles.videoDynamicRangeType}
>
<MediaInfoConnector
<MediaInfo
type={mediaInfoTypes.VIDEO_DYNAMIC_RANGE_TYPE}
movieFileId={id}
/>

View File

@@ -8,7 +8,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import MediaInfoPopover from './Editor/MediaInfoPopover';
import MediaInfo from './Editor/MediaInfo';
function FileDetailsModal(props) {
const {
@@ -31,7 +31,7 @@ function FileDetailsModal(props) {
</ModalHeader>
<ModalBody>
<MediaInfoPopover {...mediaInfo} />
<MediaInfo {...mediaInfo} />
</ModalBody>
<ModalFooter>

View File

@@ -1,104 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import getLanguageName from 'Utilities/String/getLanguageName';
import translate from 'Utilities/String/translate';
import * as mediaInfoTypes from './mediaInfoTypes';
function formatLanguages(languages) {
if (!languages) {
return null;
}
const splitLanguages = _.uniq(languages.split('/')).map((l) => {
const simpleLanguage = l.split('_')[0];
if (simpleLanguage === 'und') {
return translate('Unknown');
}
return getLanguageName(simpleLanguage);
});
if (splitLanguages.length > 3) {
return (
<span title={splitLanguages.join(', ')}>
{splitLanguages.slice(0, 2).join(', ')}, {splitLanguages.length - 2} more
</span>
);
}
return (
<span>
{splitLanguages.join(', ')}
</span>
);
}
function MediaInfo(props) {
const {
type,
audioChannels,
audioCodec,
audioLanguages,
subtitles,
videoCodec,
videoDynamicRangeType
} = props;
if (type === mediaInfoTypes.AUDIO) {
return (
<span>
{
audioCodec ? audioCodec : ''
}
{
audioCodec && audioChannels ? ' - ' : ''
}
{
audioChannels ? audioChannels.toFixed(1) : ''
}
</span>
);
}
if (type === mediaInfoTypes.AUDIO_LANGUAGES) {
return formatLanguages(audioLanguages);
}
if (type === mediaInfoTypes.SUBTITLES) {
return formatLanguages(subtitles);
}
if (type === mediaInfoTypes.VIDEO) {
return (
<span>
{videoCodec}
</span>
);
}
if (type === mediaInfoTypes.VIDEO_DYNAMIC_RANGE_TYPE) {
return (
<span>
{videoDynamicRangeType}
</span>
);
}
return null;
}
MediaInfo.propTypes = {
type: PropTypes.string.isRequired,
audioChannels: PropTypes.number,
audioCodec: PropTypes.string,
audioLanguages: PropTypes.string,
subtitles: PropTypes.string,
videoCodec: PropTypes.string,
videoDynamicRangeType: PropTypes.string
};
export default MediaInfo;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import getLanguageName from 'Utilities/String/getLanguageName';
import translate from 'Utilities/String/translate';
import useMovieFile from './useMovieFile';
function formatLanguages(languages: string | undefined) {
if (!languages) {
return null;
}
const splitLanguages = [...new Set(languages.split('/'))].map((l) => {
const simpleLanguage = l.split('_')[0];
if (simpleLanguage === 'und') {
return translate('Unknown');
}
return getLanguageName(simpleLanguage);
});
if (splitLanguages.length > 3) {
return (
<span title={splitLanguages.join(', ')}>
{splitLanguages.slice(0, 2).join(', ')}, {splitLanguages.length - 2}{' '}
more
</span>
);
}
return <span>{splitLanguages.join(', ')}</span>;
}
export type MediaInfoType =
| 'audio'
| 'audioLanguages'
| 'subtitles'
| 'video'
| 'videoDynamicRangeType';
interface MediaInfoProps {
movieFileId?: number;
type: MediaInfoType;
}
function MediaInfo({ movieFileId, type }: MediaInfoProps) {
const movieFile = useMovieFile(movieFileId);
if (!movieFile?.mediaInfo) {
return null;
}
const {
audioChannels,
audioCodec,
audioLanguages,
subtitles,
videoCodec,
videoDynamicRangeType,
} = movieFile.mediaInfo;
if (type === 'audio') {
return (
<span>
{audioCodec ? audioCodec : ''}
{audioCodec && audioChannels ? ' - ' : ''}
{audioChannels ? audioChannels.toFixed(1) : ''}
</span>
);
}
if (type === 'audioLanguages') {
return formatLanguages(audioLanguages);
}
if (type === 'subtitles') {
return formatLanguages(subtitles);
}
if (type === 'video') {
return <span>{videoCodec}</span>;
}
if (type === 'videoDynamicRangeType') {
return <span>{videoDynamicRangeType}</span>;
}
return null;
}
export default MediaInfo;

View File

@@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
import MediaInfo from './MediaInfo';
function createMapStateToProps() {
return createSelector(
createMovieFileSelector(),
(movieFile) => {
if (movieFile) {
return {
...movieFile.mediaInfo
};
}
return {};
}
);
}
export default connect(createMapStateToProps)(MediaInfo);

View File

@@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import MovieLanguages from 'Movie/MovieLanguages';
import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
function createMapStateToProps() {
return createSelector(
createMovieFileSelector(),
(movieFile) => {
return {
languages: movieFile ? movieFile.languages : undefined
};
}
);
}
export default connect(createMapStateToProps)(MovieLanguages);

View File

@@ -0,0 +1,15 @@
import React from 'react';
import MovieLanguages from 'Movie/MovieLanguages';
import useMovieFile from './useMovieFile';
interface MovieFileLanguagesProps {
movieFileId: number;
}
function MovieFileLanguages({ movieFileId }: MovieFileLanguagesProps) {
const movieFile = useMovieFile(movieFileId);
return <MovieLanguages languages={movieFile?.languages ?? []} />;
}
export default MovieFileLanguages;

View File

@@ -0,0 +1,18 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createMovieFileSelector(movieFileId?: number) {
return createSelector(
(state: AppState) => state.movieFiles.items,
(movieFiles) => {
return movieFiles.find(({ id }) => id === movieFileId);
}
);
}
function useMovieFile(movieFileId: number | undefined) {
return useSelector(createMovieFileSelector(movieFileId));
}
export default useMovieFile;

View File

@@ -55,10 +55,10 @@ function EditSpecificationModalContent(props) {
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
</div>
</Alert>
}

View File

@@ -40,7 +40,7 @@ function createImportListExclusionSelector(id?: number) {
importListExclusions;
const mapping = id
? items.find((i) => i.id === id)
? items.find((i) => i.id === id)!
: newImportListExclusion;
const settings = selectSettings(mapping, pendingChanges, saveError);

View File

@@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import EditMetadataModalContentConnector from './EditMetadataModalContentConnector';
function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditMetadataModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditMetadataModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditMetadataModal;

View File

@@ -0,0 +1,43 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditMetadataModalContent, {
EditMetadataModalContentProps,
} from './EditMetadataModalContent';
interface EditMetadataModalProps
extends Omit<EditMetadataModalContentProps, 'advancedSettings'> {
isOpen: boolean;
}
function EditMetadataModal({
isOpen,
onModalClose,
...otherProps
}: EditMetadataModalProps) {
const dispatch = useDispatch();
const advancedSettings = useSelector(
(state: AppState) => state.settings.advancedSettings
);
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'metadata' }));
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
<EditMetadataModalContent
{...otherProps}
advancedSettings={advancedSettings}
onModalClose={handleModalClose}
/>
</Modal>
);
}
export default EditMetadataModal;

View File

@@ -1,44 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditMetadataModal from './EditMetadataModal';
function createMapDispatchToProps(dispatch, props) {
const section = 'settings.metadata';
return {
dispatchClearPendingChanges() {
dispatch(clearPendingChanges({ section }));
}
};
}
class EditMetadataModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.dispatchClearPendingChanges({ section: 'metadata' });
this.props.onModalClose();
};
//
// Render
render() {
return (
<EditMetadataModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditMetadataModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector);

View File

@@ -0,0 +1,5 @@
.message {
composes: alert from '~Components/Alert.css';
margin-bottom: 30px;
}

View File

@@ -0,0 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'message': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,105 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function EditMetadataModalContent(props) {
const {
advancedSettings,
isSaving,
saveError,
item,
onInputChange,
onFieldChange,
onModalClose,
onSavePress,
...otherProps
} = props;
const {
name,
enable,
fields
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('EditMetadata', { metadataType: name.value })}
</ModalHeader>
<ModalBody>
<Form {...otherProps}>
<FormGroup>
<FormLabel>{translate('Enable')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enable"
helpText={translate('EnableMetadataHelpText')}
{...enable}
onChange={onInputChange}
/>
</FormGroup>
{
fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="metadata"
{...field}
isDisabled={!enable.value}
onChange={onFieldChange}
/>
);
})
}
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditMetadataModalContent.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onFieldChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onDeleteMetadataPress: PropTypes.func
};
export default EditMetadataModalContent;

View File

@@ -0,0 +1,128 @@
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import {
saveMetadata,
setMetadataFieldValue,
setMetadataValue,
} from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './EditMetadataModalContent.css';
export interface EditMetadataModalContentProps {
id: number;
advancedSettings: boolean;
onModalClose: () => void;
}
function EditMetadataModalContent({
id,
advancedSettings,
onModalClose,
}: EditMetadataModalContentProps) {
const dispatch = useDispatch();
const { isSaving, saveError, pendingChanges, items } = useSelector(
(state: AppState) => state.settings.metadata
);
const { settings, ...otherSettings } = useMemo(() => {
const item = items.find((item) => item.id === id)!;
return selectSettings(item, pendingChanges, saveError);
}, [id, items, pendingChanges, saveError]);
const { name, enable, fields, message } = settings;
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error not typed
dispatch(setMetadataValue({ name, value }));
},
[dispatch]
);
const handleFieldChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error not typed
dispatch(setMetadataFieldValue({ name, value }));
},
[dispatch]
);
const handleSavePress = useCallback(() => {
dispatch(saveMetadata({ id }));
}, [id, dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('EditMetadata', { metadataType: name.value })}
</ModalHeader>
<ModalBody>
<Form {...otherSettings}>
{message ? (
<Alert className={styles.message} kind={message.value.type}>
{message.value.message}
</Alert>
) : null}
<FormGroup>
<FormLabel>{translate('Enable')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enable"
helpText={translate('EnableMetadataHelpText')}
{...enable}
onChange={handleInputChange}
/>
</FormGroup>
{fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="metadata"
{...field}
isDisabled={!enable.value}
onChange={handleFieldChange}
/>
);
})}
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
export default EditMetadataModalContent;

View File

@@ -1,95 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveMetadata, setMetadataFieldValue, setMetadataValue } from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import EditMetadataModalContent from './EditMetadataModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
(state, { id }) => id,
(state) => state.settings.metadata,
(advancedSettings, id, metadata) => {
const {
isSaving,
saveError,
pendingChanges,
items
} = metadata;
const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError);
return {
advancedSettings,
id,
isSaving,
saveError,
item: settings.settings,
...settings
};
}
);
}
const mapDispatchToProps = {
setMetadataValue,
setMetadataFieldValue,
saveMetadata
};
class EditMetadataModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setMetadataValue({ name, value });
};
onFieldChange = ({ name, value }) => {
this.props.setMetadataFieldValue({ name, value });
};
onSavePress = () => {
this.props.saveMetadata({ id: this.props.id });
};
//
// Render
render() {
return (
<EditMetadataModalContent
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
EditMetadataModalContentConnector.propTypes = {
id: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setMetadataValue: PropTypes.func.isRequired,
setMetadataFieldValue: PropTypes.func.isRequired,
saveMetadata: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector);

View File

@@ -1,150 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditMetadataModalConnector from './EditMetadataModalConnector';
import styles from './Metadata.css';
class Metadata extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditMetadataModalOpen: false
};
}
//
// Listeners
onEditMetadataPress = () => {
this.setState({ isEditMetadataModalOpen: true });
};
onEditMetadataModalClose = () => {
this.setState({ isEditMetadataModalOpen: false });
};
//
// Render
render() {
const {
id,
name,
enable,
fields
} = this.props;
const metadataFields = [];
const imageFields = [];
fields.forEach((field) => {
if (field.section === 'metadata') {
metadataFields.push(field);
} else {
imageFields.push(field);
}
});
return (
<Card
className={styles.metadata}
overlayContent={true}
onPress={this.onEditMetadataPress}
>
<div className={styles.name}>
{name}
</div>
<div>
{
enable ?
<Label kind={kinds.SUCCESS}>
{translate('Enabled')}
</Label> :
<Label
kind={kinds.DISABLED}
outline={true}
>
{translate('Disabled')}
</Label>
}
</div>
{
enable && !!metadataFields.length &&
<div>
<div className={styles.section}>
{translate('Metadata')}
</div>
{
metadataFields.map((field) => {
if (!field.value) {
return null;
}
return (
<Label
key={field.label}
kind={kinds.SUCCESS}
>
{field.label}
</Label>
);
})
}
</div>
}
{
enable && !!imageFields.length &&
<div>
<div className={styles.section}>
{translate('Images')}
</div>
{
imageFields.map((field) => {
if (!field.value) {
return null;
}
return (
<Label
key={field.label}
kind={kinds.SUCCESS}
>
{field.label}
</Label>
);
})
}
</div>
}
<EditMetadataModalConnector
id={id}
isOpen={this.state.isEditMetadataModalOpen}
onModalClose={this.onEditMetadataModalClose}
/>
</Card>
);
}
}
Metadata.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
enable: PropTypes.bool.isRequired,
fields: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default Metadata;

View File

@@ -0,0 +1,106 @@
import React, { useCallback, useMemo, useState } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import Field from 'typings/Field';
import translate from 'Utilities/String/translate';
import EditMetadataModal from './EditMetadataModal';
import styles from './Metadata.css';
interface MetadataProps {
id: number;
name: string;
enable: boolean;
fields: Field[];
}
function Metadata({ id, name, enable, fields }: MetadataProps) {
const [isEditMetadataModalOpen, setIsEditMetadataModalOpen] = useState(false);
const { metadataFields, imageFields } = useMemo(() => {
return fields.reduce<{ metadataFields: Field[]; imageFields: Field[] }>(
(acc, field) => {
if (field.section === 'metadata') {
acc.metadataFields.push(field);
} else {
acc.imageFields.push(field);
}
return acc;
},
{ metadataFields: [], imageFields: [] }
);
}, [fields]);
const handleOpenPress = useCallback(() => {
setIsEditMetadataModalOpen(true);
}, []);
const handleModalClose = useCallback(() => {
setIsEditMetadataModalOpen(false);
}, []);
return (
<Card
className={styles.metadata}
overlayContent={true}
onPress={handleOpenPress}
>
<div className={styles.name}>{name}</div>
<div>
{enable ? (
<Label kind={kinds.SUCCESS}>{translate('Enabled')}</Label>
) : (
<Label kind={kinds.DISABLED} outline={true}>
{translate('Disabled')}
</Label>
)}
</div>
{enable && metadataFields.length ? (
<div>
<div className={styles.section}>{translate('Metadata')}</div>
{metadataFields.map((field) => {
if (!field.value) {
return null;
}
return (
<Label key={field.label} kind={kinds.SUCCESS}>
{field.label}
</Label>
);
})}
</div>
) : null}
{enable && imageFields.length ? (
<div>
<div className={styles.section}>{translate('Images')}</div>
{imageFields.map((field) => {
if (!field.value) {
return null;
}
return (
<Label key={field.label} kind={kinds.SUCCESS}>
{field.label}
</Label>
);
})}
</div>
) : null}
<EditMetadataModal
id={id}
isOpen={isEditMetadataModalOpen}
onModalClose={handleModalClose}
/>
</Card>
);
}
export default Metadata;

View File

@@ -1,44 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
import translate from 'Utilities/String/translate';
import Metadata from './Metadata';
import styles from './Metadatas.css';
function Metadatas(props) {
const {
items,
...otherProps
} = props;
return (
<FieldSet legend={translate('Metadata')}>
<PageSectionContent
errorMessage={translate('MetadataLoadError')}
{...otherProps}
>
<div className={styles.metadatas}>
{
items.map((item) => {
return (
<Metadata
key={item.id}
{...item}
/>
);
})
}
</div>
</PageSectionContent>
</FieldSet>
);
}
Metadatas.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default Metadatas;

View File

@@ -0,0 +1,52 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import MetadataAppState from 'App/State/MetadataAppState';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { fetchMetadata } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import MetadataType from 'typings/Metadata';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import Metadata from './Metadata';
import styles from './Metadatas.css';
function createMetadatasSelector() {
return createSelector(
createSortedSectionSelector<MetadataType>(
'settings.metadata',
sortByProp('name')
),
(metadata: MetadataAppState) => metadata
);
}
function Metadatas() {
const dispatch = useDispatch();
const { isFetching, error, items, ...otherProps } = useSelector(
createMetadatasSelector()
);
useEffect(() => {
dispatch(fetchMetadata());
}, [dispatch]);
return (
<FieldSet legend={translate('Metadata')}>
<PageSectionContent
isFetching={isFetching}
errorMessage={translate('MetadataLoadError')}
{...otherProps}
>
<div className={styles.metadatas}>
{items.map((item) => {
return <Metadata key={item.id} {...item} />;
})}
</div>
</PageSectionContent>
</FieldSet>
);
}
export default Metadatas;

View File

@@ -1,47 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchMetadata } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import Metadatas from './Metadatas';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.metadata', sortByProp('name')),
(metadata) => metadata
);
}
const mapDispatchToProps = {
fetchMetadata
};
class MetadatasConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchMetadata();
}
//
// Render
render() {
return (
<Metadatas
{...this.props}
onConfirmDeleteMetadata={this.onConfirmDeleteMetadata}
/>
);
}
}
MetadatasConnector.propTypes = {
fetchMetadata: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);

View File

@@ -3,7 +3,7 @@ import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import MetadatasConnector from './Metadata/MetadatasConnector';
import Metadatas from './Metadata/Metadatas';
import MetadataOptionsConnector from './Options/MetadataOptionsConnector';
class MetadataSettings extends Component {
@@ -62,7 +62,7 @@ class MetadataSettings extends Component {
onChildStateChange={this.onChildStateChange}
/>
<MetadatasConnector />
<Metadatas />
</PageContentBody>
</PageContent>
);

View File

@@ -19,14 +19,15 @@ import {
setReleaseProfileValue,
} from 'Store/Actions/Settings/releaseProfiles';
import selectSettings from 'Store/Selectors/selectSettings';
import { PendingSection } from 'typings/pending';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import translate from 'Utilities/String/translate';
import styles from './EditReleaseProfileModalContent.css';
const tagInputDelimiters = ['Tab', 'Enter'];
const newReleaseProfile = {
const newReleaseProfile: ReleaseProfile = {
id: 0,
name: '',
enabled: true,
required: [],
ignored: [],
@@ -41,8 +42,12 @@ function createReleaseProfileSelector(id?: number) {
const { items, isFetching, error, isSaving, saveError, pendingChanges } =
releaseProfiles;
const mapping = id ? items.find((i) => i.id === id) : newReleaseProfile;
const settings = selectSettings(mapping, pendingChanges, saveError);
const mapping = id ? items.find((i) => i.id === id)! : newReleaseProfile;
const settings = selectSettings<ReleaseProfile>(
mapping,
pendingChanges,
saveError
);
return {
id,
@@ -50,7 +55,7 @@ function createReleaseProfileSelector(id?: number) {
error,
isSaving,
saveError,
item: settings.settings as PendingSection<ReleaseProfile>,
item: settings.settings,
...settings,
};
}

View File

@@ -86,10 +86,10 @@ function EditSpecificationModalContent(props) {
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
</div>
</Alert>
}

View File

@@ -91,12 +91,8 @@ export const defaultState = {
genres: function(item, filterValue, type) {
const predicate = filterTypePredicates[type];
let allGenres = [];
item.movies.forEach((movie) => {
allGenres = allGenres.concat(movie.genres);
});
const genres = Array.from(new Set(allGenres)).slice(0, 3);
const allGenres = item.movies.flatMap(({ genres }) => genres);
const genres = Array.from(new Set(allGenres));
return predicate(genres, filterValue);
},
@@ -138,12 +134,8 @@ export const defaultState = {
type: filterBuilderTypes.ARRAY,
optionsSelector: function(items) {
const genreList = items.reduce((acc, collection) => {
let collectionGenres = [];
collection.movies.forEach((movie) => {
collectionGenres = collectionGenres.concat(movie.genres);
});
const genres = Array.from(new Set(collectionGenres)).slice(0, 3);
const collectionGenres = collection.movies.flatMap(({ genres }) => genres);
const genres = Array.from(new Set(collectionGenres));
genres.forEach((genre) => {
acc.push({

View File

@@ -1,13 +1,14 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Movie from 'Movie/Movie';
import QualityProfile from 'typings/QualityProfile';
import { createMovieSelectorForHook } from './createMovieSelector';
function createMovieQualityProfileSelector(movieId: number) {
return createSelector(
(state: AppState) => state.settings.qualityProfiles.items,
createMovieSelectorForHook(movieId),
(qualityProfiles, movie = {} as Movie) => {
(qualityProfiles: QualityProfile[], movie = {} as Movie) => {
return qualityProfiles.find(
(profile) => profile.id === movie.qualityProfileId
);

View File

@@ -1,104 +0,0 @@
import _ from 'lodash';
function getValidationFailures(saveError) {
if (!saveError || saveError.status !== 400) {
return [];
}
return _.cloneDeep(saveError.responseJSON);
}
function mapFailure(failure) {
return {
message: failure.errorMessage,
link: failure.infoLink,
detailedMessage: failure.detailedDescription
};
}
function selectSettings(item, pendingChanges, saveError) {
const validationFailures = getValidationFailures(saveError);
// Merge all settings from the item along with pending
// changes to ensure any settings that were not included
// with the item are included.
const allSettings = Object.assign({}, item, pendingChanges);
const settings = _.reduce(allSettings, (result, value, key) => {
if (key === 'fields') {
return result;
}
// Return a flattened value
if (key === 'implementationName') {
result.implementationName = item[key];
return result;
}
const setting = {
value: item[key],
errors: _.map(_.remove(validationFailures, (failure) => {
return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning;
}), mapFailure),
warnings: _.map(_.remove(validationFailures, (failure) => {
return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning;
}), mapFailure)
};
if (pendingChanges.hasOwnProperty(key)) {
setting.previousValue = setting.value;
setting.value = pendingChanges[key];
setting.pending = true;
}
result[key] = setting;
return result;
}, {});
const fields = _.reduce(item.fields, (result, f) => {
const field = Object.assign({ pending: false }, f);
const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name);
if (hasPendingFieldChange) {
field.previousValue = field.value;
field.value = pendingChanges.fields[field.name];
field.pending = true;
}
field.errors = _.map(_.remove(validationFailures, (failure) => {
return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning;
}), mapFailure);
field.warnings = _.map(_.remove(validationFailures, (failure) => {
return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning;
}), mapFailure);
result.push(field);
return result;
}, []);
if (fields.length) {
settings.fields = fields;
}
const validationErrors = _.filter(validationFailures, (failure) => {
return !failure.isWarning;
});
const validationWarnings = _.filter(validationFailures, (failure) => {
return failure.isWarning;
});
return {
settings,
validationErrors,
validationWarnings,
hasPendingChanges: !_.isEmpty(pendingChanges),
hasSettings: !_.isEmpty(settings),
pendingChanges
};
}
export default selectSettings;

View File

@@ -0,0 +1,168 @@
import { cloneDeep, isEmpty } from 'lodash';
import { Error } from 'App/State/AppSectionState';
import Field from 'typings/Field';
import {
Failure,
Pending,
PendingField,
PendingSection,
ValidationError,
ValidationFailure,
ValidationWarning,
} from 'typings/pending';
interface ValidationFailures {
errors: ValidationError[];
warnings: ValidationWarning[];
}
function getValidationFailures(saveError?: Error): ValidationFailures {
if (!saveError || saveError.status !== 400) {
return {
errors: [],
warnings: [],
};
}
return cloneDeep(saveError.responseJSON as ValidationFailure[]).reduce(
(acc: ValidationFailures, failure: ValidationFailure) => {
if (failure.isWarning) {
acc.warnings.push(failure as ValidationWarning);
} else {
acc.errors.push(failure as ValidationError);
}
return acc;
},
{
errors: [],
warnings: [],
}
);
}
function getFailures(failures: ValidationFailure[], key: string) {
const result = [];
for (let i = failures.length - 1; i >= 0; i--) {
if (failures[i].propertyName.toLowerCase() === key.toLowerCase()) {
result.unshift(mapFailure(failures[i]));
failures.splice(i, 1);
}
}
return result;
}
function mapFailure(failure: ValidationFailure): Failure {
return {
errorMessage: failure.errorMessage,
infoLink: failure.infoLink,
detailedDescription: failure.detailedDescription,
// TODO: Remove these renamed properties
message: failure.errorMessage,
link: failure.infoLink,
detailedMessage: failure.detailedDescription,
};
}
interface ModelBaseSetting {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[id: string]: any;
}
function selectSettings<T extends ModelBaseSetting>(
item: T,
pendingChanges: Partial<ModelBaseSetting>,
saveError?: Error
) {
const { errors, warnings } = getValidationFailures(saveError);
// Merge all settings from the item along with pending
// changes to ensure any settings that were not included
// with the item are included.
const allSettings = Object.assign({}, item, pendingChanges);
const settings = Object.keys(allSettings).reduce(
(acc: PendingSection<T>, key) => {
if (key === 'fields') {
return acc;
}
// Return a flattened value
if (key === 'implementationName') {
acc.implementationName = item[key];
return acc;
}
const setting: Pending<T> = {
value: item[key],
pending: false,
errors: getFailures(errors, key),
warnings: getFailures(warnings, key),
};
if (pendingChanges.hasOwnProperty(key)) {
setting.previousValue = setting.value;
setting.value = pendingChanges[key];
setting.pending = true;
}
// @ts-expect-error - This is a valid key
acc[key] = setting;
return acc;
},
{} as PendingSection<T>
);
if ('fields' in item) {
const fields =
(item.fields as Field[]).reduce((acc: PendingField<T>[], f) => {
const field: PendingField<T> = Object.assign(
{ pending: false, errors: [], warnings: [] },
f
);
if ('fields' in pendingChanges) {
const pendingChangesFields = pendingChanges.fields as Record<
string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any
>;
if (pendingChangesFields.hasOwnProperty(field.name)) {
field.previousValue = field.value;
field.value = pendingChangesFields[field.name];
field.pending = true;
}
}
field.errors = getFailures(errors, field.name);
field.warnings = getFailures(warnings, field.name);
acc.push(field);
return acc;
}, []) ?? [];
if (fields.length) {
settings.fields = fields;
}
}
const validationErrors = errors;
const validationWarnings = warnings;
return {
settings,
validationErrors,
validationWarnings,
hasPendingChanges: !isEmpty(pendingChanges),
hasSettings: !isEmpty(settings),
pendingChanges,
};
}
export default selectSettings;

View File

@@ -0,0 +1,9 @@
export type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];
function getEntries<T extends object>(obj: T): Entries<T> {
return Object.entries(obj) as Entries<T>;
}
export default getEntries;

View File

@@ -35,7 +35,7 @@ export default function getLanguageName(code: string) {
try {
return languageNames.of(code) ?? code;
} catch (error) {
} catch {
return code;
}
}

View File

@@ -17,7 +17,7 @@ export async function fetchTranslations(): Promise<boolean> {
translations = data.Strings;
resolve(true);
} catch (error) {
} catch {
resolve(false);
}
});
@@ -27,6 +27,12 @@ export default function translate(
key: string,
tokens: Record<string, string | number | boolean> = {}
) {
const { isProduction = true } = window.Radarr;
if (!isProduction && !(key in translations)) {
console.warn(`Missing translation for key: ${key}`);
}
const translation = translations[key] || key;
tokens.appName = 'Radarr';

View File

@@ -153,12 +153,15 @@ class CutoffUnmet extends Component {
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
label={itemsSelected ? translate('SearchSelected') : translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForCutoffUnmetMovies}
onPress={this.onSearchSelectedPress}
isDisabled={isSearchingForCutoffUnmetMovies}
isSpinning={isSearchingForCutoffUnmetMovies}
onPress={itemsSelected ? this.onSearchSelectedPress : this.onSearchAllCutoffUnmetPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
@@ -166,18 +169,6 @@ class CutoffUnmet extends Component {
isSpinning={isSaving}
onPress={this.onToggleSelectedPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForCutoffUnmetMovies}
onPress={this.onSearchAllCutoffUnmetPress}
/>
<PageToolbarSeparator />
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>

View File

@@ -18,9 +18,10 @@ function createMapStateToProps() {
return createSelector(
(state) => state.wanted.cutoffUnmet,
createCommandExecutingSelector(commandNames.CUTOFF_UNMET_MOVIES_SEARCH),
(cutoffUnmet, isSearchingForCutoffUnmetMovies) => {
createCommandExecutingSelector(commandNames.MOVIE_SEARCH),
(cutoffUnmet, isSearchingForCutoffUnmetMovies, isSearchingForSelectedCutoffUnmetMovies) => {
return {
isSearchingForCutoffUnmetMovies,
isSearchingForCutoffUnmetMovies: isSearchingForCutoffUnmetMovies || isSearchingForSelectedCutoffUnmetMovies,
isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1,
...cutoffUnmet
};

View File

@@ -8,7 +8,7 @@ import movieEntities from 'Movie/movieEntities';
import MovieSearchCell from 'Movie/MovieSearchCell';
import MovieStatusConnector from 'Movie/MovieStatusConnector';
import MovieTitleLink from 'Movie/MovieTitleLink';
import MovieFileLanguageConnector from 'MovieFile/MovieFileLanguageConnector';
import MovieFileLanguages from 'MovieFile/MovieFileLanguages';
import styles from './CutoffUnmetRow.css';
function CutoffUnmetRow(props) {
@@ -104,7 +104,7 @@ function CutoffUnmetRow(props) {
key={name}
className={styles.languages}
>
<MovieFileLanguageConnector
<MovieFileLanguages
movieFileId={movieFileId}
/>
</TableRowCell>

View File

@@ -159,12 +159,15 @@ class Missing extends Component {
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
label={itemsSelected ? translate('SearchSelected') : translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForMissingMovies}
onPress={this.onSearchSelectedPress}
isSpinning={isSearchingForMissingMovies}
isDisabled={isSearchingForMissingMovies}
onPress={itemsSelected ? this.onSearchSelectedPress : this.onSearchAllMissingPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
@@ -175,16 +178,6 @@ class Missing extends Component {
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForMissingMovies}
onPress={this.onSearchAllMissingPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('ManualImport')}
iconName={icons.INTERACTIVE}

View File

@@ -17,9 +17,10 @@ function createMapStateToProps() {
return createSelector(
(state) => state.wanted.missing,
createCommandExecutingSelector(commandNames.MISSING_MOVIES_SEARCH),
(missing, isSearchingForMissingMovies) => {
createCommandExecutingSelector(commandNames.MOVIE_SEARCH),
(missing, isSearchingForMissingMovies, isSearchingForSelectedMissingMovies) => {
return {
isSearchingForMissingMovies,
isSearchingForMissingMovies: isSearchingForMissingMovies || isSearchingForSelectedMissingMovies,
isSaving: missing.items.filter((m) => m.isSaving).length > 1,
...missing
};

View File

@@ -1,6 +1,6 @@
import { createBrowserHistory } from 'history';
import React from 'react';
import { render } from 'react-dom';
import { createRoot } from 'react-dom/client';
import createAppStore from 'Store/createAppStore';
import App from './App/App';
@@ -9,9 +9,8 @@ import 'Diag/ConsoleApi';
export async function bootstrap() {
const history = createBrowserHistory();
const store = createAppStore(history);
const container = document.getElementById('root');
render(
<App store={store} history={history} />,
document.getElementById('root')
);
const root = createRoot(container!); // createRoot(container!) if you use TypeScript
root.render(<App store={store} history={history} />);
}

View File

@@ -14,6 +14,31 @@ window.Radarr = await response.json();
__webpack_public_path__ = `${window.Radarr.urlBase}/`;
/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */
const error = console.error;
// Monkey patch console.error to filter out some warnings from React
// TODO: Remove this after the great TypeScript migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function logError(...parameters: any[]) {
const filter = parameters.find((parameter) => {
return (
parameter.includes(
'Support for defaultProps will be removed from function components in a future major release'
) ||
parameter.includes(
'findDOMNode is deprecated and will be removed in the next major release'
)
);
});
if (!filter) {
error(...parameters);
}
}
console.error = logError;
const { bootstrap } = await import('./bootstrap');
await bootstrap();

View File

@@ -1,27 +1,13 @@
import ModelBase from 'App/ModelBase';
import Provider from './Provider';
export interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string;
type: string;
advanced: boolean;
privacy: string;
}
export type Protocol = 'torrent' | 'usenet' | 'unknown';
interface DownloadClient extends ModelBase {
interface DownloadClient extends Provider {
enable: boolean;
protocol: string;
protocol: Protocol;
priority: number;
removeCompletedDownloads: boolean;
removeFailedDownloads: boolean;
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}

View File

@@ -0,0 +1,23 @@
export interface FieldSelectOption<T> {
value: T;
name: string;
order: number;
hint?: string;
parentValue?: T;
isDisabled?: boolean;
additionalProperties?: Record<string, unknown>;
}
interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string | number[];
section: string;
hidden: 'hidden' | 'hiddenIfNotSet' | 'visible';
type: string;
advanced: boolean;
privacy: string;
}
export default Field;

View File

@@ -1,28 +1,12 @@
import ModelBase from 'App/ModelBase';
import Provider from './Provider';
export interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string;
type: string;
advanced: boolean;
privacy: string;
}
interface ImportList extends ModelBase {
interface ImportList extends Provider {
enable: boolean;
enabled: boolean;
enableAuto: boolean;
qualityProfileId: number;
minimumAvailability: string;
rootFolderPath: string;
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}

View File

@@ -1,27 +1,11 @@
import ModelBase from 'App/ModelBase';
import Provider from './Provider';
export interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string;
type: string;
advanced: boolean;
privacy: string;
}
interface Indexer extends ModelBase {
interface Indexer extends Provider {
enableRss: boolean;
enableAutomaticSearch: boolean;
enableInteractiveSearch: boolean;
protocol: string;
priority: number;
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}

View File

@@ -0,0 +1,7 @@
import Provider from './Provider';
interface Metadata extends Provider {
enable: boolean;
}
export default Metadata;

View File

@@ -1,23 +1,7 @@
import ModelBase from 'App/ModelBase';
import Provider from './Provider';
export interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string;
type: string;
advanced: boolean;
privacy: string;
}
interface Notification extends ModelBase {
interface Notification extends Provider {
enable: boolean;
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}

View File

@@ -0,0 +1,20 @@
import ModelBase from 'App/ModelBase';
import { Kind } from 'Helpers/Props/kinds';
import Field from './Field';
export interface ProviderMessage {
message: string;
type: Extract<Kind, 'info' | 'error' | 'warning'>;
}
interface Provider extends ModelBase {
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
message: ProviderMessage;
}
export default Provider;

View File

@@ -1,6 +1,11 @@
import Field from './Field';
export interface ValidationFailure {
isWarning: boolean;
propertyName: string;
errorMessage: string;
infoLink?: string;
detailedDescription?: string;
severity: 'error' | 'warning';
}
@@ -12,12 +17,47 @@ export interface ValidationWarning extends ValidationFailure {
isWarning: true;
}
export interface Pending<T> {
value: T;
errors: ValidationError[];
warnings: ValidationWarning[];
export interface Failure {
errorMessage: ValidationFailure['errorMessage'];
infoLink: ValidationFailure['infoLink'];
detailedDescription: ValidationFailure['detailedDescription'];
// TODO: Remove these renamed properties
message: ValidationFailure['errorMessage'];
link: ValidationFailure['infoLink'];
detailedMessage: ValidationFailure['detailedDescription'];
}
export type PendingSection<T> = {
[K in keyof T]: Pending<T[K]>;
export interface Pending<T> {
value: T;
errors: Failure[];
warnings: Failure[];
pending: boolean;
previousValue?: T;
}
export interface PendingField<T>
extends Field,
Omit<Pending<T>, 'previousValue' | 'value'> {
previousValue?: Field['value'];
}
// export type PendingSection<T> = {
// [K in keyof T]: Pending<T[K]>;
// };
type Mapped<T> = {
[Prop in keyof T]: {
value: T[Prop];
errors: Failure[];
warnings: Failure[];
pending?: boolean;
previousValue?: T[Prop];
};
};
export type PendingSection<T> = Mapped<T> & {
implementationName?: string;
fields?: PendingField<T>[];
};

View File

@@ -7,5 +7,6 @@ interface Window {
theme: string;
urlBase: string;
version: string;
isProduction: boolean;
};
}

View File

@@ -22,19 +22,19 @@
"defaults"
],
"dependencies": {
"@fortawesome/fontawesome-free": "6.6.0",
"@fortawesome/fontawesome-svg-core": "6.6.0",
"@fortawesome/free-brands-svg-icons": "6.6.0",
"@fortawesome/free-regular-svg-icons": "6.6.0",
"@fortawesome/free-solid-svg-icons": "6.6.0",
"@fortawesome/fontawesome-free": "6.7.1",
"@fortawesome/fontawesome-svg-core": "6.7.1",
"@fortawesome/free-brands-svg-icons": "6.7.1",
"@fortawesome/free-regular-svg-icons": "6.7.1",
"@fortawesome/free-solid-svg-icons": "6.7.1",
"@fortawesome/react-fontawesome": "0.2.2",
"@juggle/resize-observer": "3.4.0",
"@microsoft/signalr": "6.0.25",
"@sentry/browser": "7.119.1",
"@sentry/integrations": "7.119.1",
"@types/node": "20.16.11",
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"classnames": "2.5.1",
"connected-react-router": "6.9.3",
"copy-to-clipboard": "3.3.3",
@@ -51,7 +51,7 @@
"normalize.css": "8.0.1",
"prop-types": "15.8.1",
"qs": "6.13.0",
"react": "17.0.2",
"react": "18.3.1",
"react-addons-shallow-compare": "15.6.3",
"react-async-script": "1.2.0",
"react-autosuggest": "10.1.0",
@@ -61,7 +61,7 @@
"react-dnd-multi-backend": "6.0.2",
"react-dnd-touch-backend": "14.1.1",
"react-document-title": "2.0.3",
"react-dom": "17.0.2",
"react-dom": "18.3.1",
"react-focus-lock": "2.9.4",
"react-google-recaptcha": "2.1.0",
"react-lazyload": "3.2.0",
@@ -84,16 +84,16 @@
"reselect": "4.1.8",
"stacktrace-js": "2.0.2",
"swiper": "8.3.2",
"typescript": "5.1.6"
"typescript": "5.7.2"
},
"devDependencies": {
"@babel/core": "7.25.8",
"@babel/eslint-parser": "7.25.8",
"@babel/plugin-proposal-export-default-from": "7.25.8",
"@babel/core": "7.26.0",
"@babel/eslint-parser": "7.25.9",
"@babel/plugin-proposal-export-default-from": "7.25.9",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.25.8",
"@babel/preset-react": "7.25.7",
"@babel/preset-typescript": "7.25.7",
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3",
"@babel/preset-typescript": "7.26.0",
"@types/lodash": "4.14.195",
"@types/react-document-title": "2.0.10",
"@types/react-lazyload": "3.2.3",
@@ -102,13 +102,13 @@
"@types/react-window": "1.8.8",
"@types/redux-actions": "2.6.5",
"@types/webpack-livereload-plugin": "2.3.6",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@typescript-eslint/eslint-plugin": "8.18.1",
"@typescript-eslint/parser": "8.18.1",
"autoprefixer": "10.4.20",
"babel-loader": "9.2.1",
"babel-plugin-inline-classnames": "2.0.1",
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
"core-js": "3.38.1",
"core-js": "3.39.0",
"css-loader": "6.7.3",
"css-modules-typescript-loader": "4.0.1",
"eslint": "8.57.1",

View File

@@ -21,9 +21,28 @@ namespace NzbDrone.Common.Test.ExtensionTests
[TestCase("1.2.3.4")]
[TestCase("172.55.0.1")]
[TestCase("192.55.0.1")]
[TestCase("100.64.0.1")]
[TestCase("100.127.255.254")]
public void should_return_false_for_public_ip_address(string ipAddress)
{
IPAddress.Parse(ipAddress).IsLocalAddress().Should().BeFalse();
}
[TestCase("100.64.0.1")]
[TestCase("100.127.255.254")]
[TestCase("100.100.100.100")]
public void should_return_true_for_cgnat_ip_address(string ipAddress)
{
IPAddress.Parse(ipAddress).IsCgnatIpAddress().Should().BeTrue();
}
[TestCase("1.2.3.4")]
[TestCase("192.168.5.1")]
[TestCase("100.63.255.255")]
[TestCase("100.128.0.0")]
public void should_return_false_for_non_cgnat_ip_address(string ipAddress)
{
IPAddress.Parse(ipAddress).IsCgnatIpAddress().Should().BeFalse();
}
}
}

View File

@@ -42,17 +42,18 @@ namespace NzbDrone.Common
public void CreateZip(string path, IEnumerable<string> files)
{
using (var zipFile = ZipFile.Create(path))
_logger.Debug("Creating archive {0}", path);
using var zipFile = ZipFile.Create(path);
zipFile.BeginUpdate();
foreach (var file in files)
{
zipFile.BeginUpdate();
foreach (var file in files)
{
zipFile.Add(file, Path.GetFileName(file));
}
zipFile.CommitUpdate();
zipFile.Add(file, Path.GetFileName(file));
}
zipFile.CommitUpdate();
}
private void ExtractZip(string compressedFile, string destination)

View File

@@ -190,16 +190,23 @@ namespace NzbDrone.Common.Disk
var fi = new FileInfo(path);
// If the file is a symlink, resolve the target path and get the size of the target file.
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
try
{
var targetPath = fi.ResolveLinkTarget(true)?.FullName;
if (targetPath != null)
// If the file is a symlink, resolve the target path and get the size of the target file.
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
{
fi = new FileInfo(targetPath);
var targetPath = fi.ResolveLinkTarget(true)?.FullName;
if (targetPath != null)
{
fi = new FileInfo(targetPath);
}
}
}
catch (IOException ex)
{
Logger.Trace(ex, "Unable to resolve symlink target for {0}", path);
}
return fi.Length;
}

View File

@@ -148,10 +148,5 @@ namespace NzbDrone.Common.Extensions
{
return string.Join(separator, source.Select(predicate));
}
public static HashSet<T> ToHashSet<T>(this IEnumerable<T> source, IEqualityComparer<T> comparer = null)
{
return new HashSet<T>(source, comparer);
}
}
}

View File

@@ -39,18 +39,24 @@ namespace NzbDrone.Common.Extensions
private static bool IsLocalIPv4(byte[] ipv4Bytes)
{
// Link local (no IP assigned by DHCP): 169.254.0.0 to 169.254.255.255 (169.254.0.0/16)
bool IsLinkLocal() => ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254;
var isLinkLocal = ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254;
// Class A private range: 10.0.0.0 10.255.255.255 (10.0.0.0/8)
bool IsClassA() => ipv4Bytes[0] == 10;
var isClassA = ipv4Bytes[0] == 10;
// Class B private range: 172.16.0.0 172.31.255.255 (172.16.0.0/12)
bool IsClassB() => ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31;
var isClassB = ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31;
// Class C private range: 192.168.0.0 192.168.255.255 (192.168.0.0/16)
bool IsClassC() => ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168;
var isClassC = ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168;
return IsLinkLocal() || IsClassA() || IsClassC() || IsClassB();
return isLinkLocal || isClassA || isClassC || isClassB;
}
public static bool IsCgnatIpAddress(this IPAddress ipAddress)
{
var bytes = ipAddress.GetAddressBytes();
return bytes.Length == 4 && bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127;
}
}
}

View File

@@ -119,7 +119,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
o.Environment = BuildInfo.Branch;
// Crash free run statistics (sends a ping for healthy and for crashes sessions)
o.AutoSessionTracking = true;
o.AutoSessionTracking = false;
// Caches files in the event device is offline
// Sentry creates a 'sentry' sub directory, no need to concat here

View File

@@ -6,4 +6,5 @@ public class AuthOptions
public bool? Enabled { get; set; }
public string Method { get; set; }
public string Required { get; set; }
public bool? TrustCgnatIpAddresses { get; set; }
}

View File

@@ -313,7 +313,7 @@ namespace NzbDrone.Common.Processes
processInfo = new ProcessInfo();
processInfo.Id = process.Id;
processInfo.Name = process.ProcessName;
processInfo.StartPath = process.MainModule.FileName;
processInfo.StartPath = process.MainModule?.FileName;
if (process.Id != GetCurrentProcessId() && process.HasExited)
{

View File

@@ -5,14 +5,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="IPAddressRange" Version="6.0.0" />
<PackageReference Include="IPAddressRange" Version="6.1.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.3.3" />
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.0" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.12" />
<PackageReference Include="Npgsql" Version="7.0.8" />
<PackageReference Include="NLog" Version="5.3.4" />
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.2" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.15" />
<PackageReference Include="Npgsql" Version="7.0.9" />
<PackageReference Include="Sentry" Version="4.0.2" />
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />

View File

@@ -0,0 +1,43 @@
using System;
using System.Data.SQLite;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore.Converters;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Converters;
[TestFixture]
public class TimeSpanConverterFixture : CoreTest<TimeSpanConverter>
{
private SQLiteParameter _param;
[SetUp]
public void Setup()
{
_param = new SQLiteParameter();
}
[Test]
public void should_return_string_when_saving_timespan_to_db()
{
var span = TimeSpan.FromMilliseconds(10);
Subject.SetValue(_param, span);
_param.Value.Should().Be(span.ToString());
}
[Test]
public void should_return_timespan_when_getting_string_from_db()
{
var span = TimeSpan.FromMilliseconds(10);
Subject.Parse(span.ToString()).Should().Be(span);
}
[Test]
public void should_return_zero_timespan_for_db_null_value_when_getting_from_db()
{
Subject.Parse(null).Should().Be(TimeSpan.Zero);
}
}

View File

@@ -0,0 +1,38 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.Test.Datastore;
[TestFixture]
public class DatabaseVersionParserFixture
{
[TestCase("3.44.2", 3, 44, 2)]
public void should_parse_sqlite_database_version(string serverVersion, int majorVersion, int minorVersion, int buildVersion)
{
var version = DatabaseVersionParser.ParseServerVersion(serverVersion);
version.Should().NotBeNull();
version.Major.Should().Be(majorVersion);
version.Minor.Should().Be(minorVersion);
version.Build.Should().Be(buildVersion);
}
[TestCase("14.8 (Debian 14.8-1.pgdg110+1)", 14, 8, null)]
[TestCase("16.3 (Debian 16.3-1.pgdg110+1)", 16, 3, null)]
[TestCase("16.3 - Percona Distribution", 16, 3, null)]
[TestCase("17.0 - Percona Server", 17, 0, null)]
public void should_parse_postgres_database_version(string serverVersion, int majorVersion, int minorVersion, int? buildVersion)
{
var version = DatabaseVersionParser.ParseServerVersion(serverVersion);
version.Should().NotBeNull();
version.Major.Should().Be(majorVersion);
version.Minor.Should().Be(minorVersion);
if (buildVersion.HasValue)
{
version.Build.Should().Be(buildVersion.Value);
}
}
}

View File

@@ -5,6 +5,7 @@ using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.MediaFiles;
@@ -337,5 +338,42 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse();
}
[Test]
public void should_return_false_if_quality_profile_does_not_allow_upgrades_but_format_cutoff_is_above_current_score_and_is_revision_upgrade()
{
var customFormat = new CustomFormat("My Format", new ResolutionSpecification { Value = (int)Resolution.R1080p }) { Id = 1 };
Mocker.GetMock<IConfigService>()
.SetupGet(s => s.DownloadPropersAndRepacks)
.Returns(ProperDownloadTypes.DoNotPrefer);
GivenProfile(new QualityProfile
{
Cutoff = Quality.SDTV.Id,
MinFormatScore = 0,
CutoffFormatScore = 10000,
Items = Qualities.QualityFixture.GetDefaultQualities(),
FormatItems = CustomFormatsTestHelpers.GetSampleFormatItems("My Format"),
UpgradeAllowed = false
});
_parseResultSingle.Movie.QualityProfile.FormatItems = new List<ProfileFormatItem>
{
new ProfileFormatItem
{
Format = customFormat,
Score = 50
}
};
GivenFileQuality(new QualityModel(Quality.WEBDL1080p, new Revision(version: 1)));
GivenNewQuality(new QualityModel(Quality.WEBDL1080p, new Revision(version: 2)));
GivenOldCustomFormats(new List<CustomFormat>());
GivenNewCustomFormats(new List<CustomFormat> { customFormat });
Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse();
}
}
}

View File

@@ -107,6 +107,25 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
.Should().Be(UpgradeableRejectReason.None);
}
[Test]
public void should_return_false_if_proper_and_autoDownloadPropers_is_do_not_prefer()
{
GivenAutoDownloadPropers(ProperDownloadTypes.DoNotPrefer);
var profile = new QualityProfile
{
Items = Qualities.QualityFixture.GetDefaultQualities(),
};
Subject.IsUpgradable(
profile,
new QualityModel(Quality.DVD, new Revision(version: 1)),
new List<CustomFormat>(),
new QualityModel(Quality.DVD, new Revision(version: 2)),
new List<CustomFormat>())
.Should().Be(UpgradeableRejectReason.UpgradesNotAllowed);
}
[Test]
public void should_return_false_if_release_and_existing_file_are_the_same()
{
@@ -121,7 +140,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
new List<CustomFormat>(),
new QualityModel(Quality.HDTV720p, new Revision(version: 1)),
new List<CustomFormat>())
.Should().Be(UpgradeableRejectReason.CustomFormatScore);
.Should().Be(UpgradeableRejectReason.UpgradesNotAllowed);
}
[Test]

View File

@@ -74,7 +74,7 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Get(1))
.Setup(v => v.Find(1))
.Returns(indexerDefinition);
_remoteMovie.ParsedMovieInfo = GetParsedMovieInfo(new List<Language> { }, releaseTitle);
@@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
_remoteMovie.Release.Title = releaseTitle;
Subject.Aggregate(_remoteMovie).Languages.Should().BeEquivalentTo(new List<Language> { _movie.MovieMetadata.Value.OriginalLanguage, Language.French });
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Get(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Find(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
@@ -104,7 +104,7 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Get(1))
.Setup(v => v.Find(1))
.Returns(indexerDefinition1);
Mocker.GetMock<IIndexerFactory>()
@@ -117,7 +117,7 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
_remoteMovie.Release.Title = releaseTitle;
Subject.Aggregate(_remoteMovie).Languages.Should().BeEquivalentTo(new List<Language> { _movie.MovieMetadata.Value.OriginalLanguage, Language.French });
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Get(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Find(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
@@ -155,7 +155,7 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Get(1))
.Setup(v => v.Find(1))
.Returns(indexerDefinition);
_remoteMovie.ParsedMovieInfo = GetParsedMovieInfo(new List<Language> { Language.Unknown }, releaseTitle);
@@ -163,7 +163,51 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
_remoteMovie.Release.Title = releaseTitle;
Subject.Aggregate(_remoteMovie).Languages.Should().BeEquivalentTo(new List<Language> { _movie.MovieMetadata.Value.OriginalLanguage, Language.French });
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Get(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Find(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[Test]
public void should_return_multi_languages_when_release_as_specified_language_and_indexer_has_multi_languages_configuration()
{
var releaseTitle = "Some.Movie.2024.MULTi.VFF.VFQ.1080p.BluRay.DTS.HDMA.x264-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Id = 1,
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Find(1))
.Returns(indexerDefinition);
_remoteMovie.ParsedMovieInfo = GetParsedMovieInfo(new List<Language> { Language.French }, releaseTitle);
_remoteMovie.Release.IndexerId = 1;
_remoteMovie.Release.Title = releaseTitle;
Subject.Aggregate(_remoteMovie).Languages.Should().BeEquivalentTo(new List<Language> { _movie.MovieMetadata.Value.OriginalLanguage, Language.French });
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Find(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[Test]
public void should_return_multi_languages_when_release_as_other_language_and_indexer_has_multi_languages_configuration()
{
var releaseTitle = "Some.Movie.2024.MULTi.GERMAN.1080p.BluRay.DTS.HDMA.x264-RlsGroup";
var indexerDefinition = new IndexerDefinition
{
Id = 1,
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Find(1))
.Returns(indexerDefinition);
_remoteMovie.ParsedMovieInfo = GetParsedMovieInfo(new List<Language> { Language.German }, releaseTitle);
_remoteMovie.Release.IndexerId = 1;
_remoteMovie.Release.Title = releaseTitle;
Subject.Aggregate(_remoteMovie).Languages.Should().BeEquivalentTo(new List<Language> { _movie.MovieMetadata.Value.OriginalLanguage, Language.French, Language.German });
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Find(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
@@ -177,7 +221,7 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Settings = new TorrentRssIndexerSettings { }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Get(1))
.Setup(v => v.Find(1))
.Returns(indexerDefinition);
_remoteMovie.ParsedMovieInfo = GetParsedMovieInfo(new List<Language> { }, releaseTitle);
@@ -185,7 +229,7 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
_remoteMovie.Release.Title = releaseTitle;
Subject.Aggregate(_remoteMovie).Languages.Should().BeEquivalentTo(new List<Language> { _movie.MovieMetadata.Value.OriginalLanguage });
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Get(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Find(1), Times.Once());
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
@@ -248,5 +292,85 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
Subject.Aggregate(_remoteMovie).Languages.Should().Equal(Language.Greek);
}
[Test]
public void should_return_multi_languages_from_indexer_with_name_when_indexer_id_does_not_exist()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition1 = new IndexerDefinition
{
Id = 1,
Name = "MyIndexer1",
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
};
var indexerDefinition2 = new IndexerDefinition
{
Id = 2,
Name = "MyIndexer2",
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.German.Id } }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Find(1))
.Returns(null as IndexerDefinition);
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.FindByName("MyIndexer1"))
.Returns(indexerDefinition1);
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.All())
.Returns(new List<IndexerDefinition>() { indexerDefinition1, indexerDefinition2 });
_remoteMovie.ParsedMovieInfo = GetParsedMovieInfo(new List<Language> { }, releaseTitle);
_remoteMovie.Release.IndexerId = 10;
_remoteMovie.Release.Indexer = "MyIndexer1";
_remoteMovie.Release.Title = releaseTitle;
Subject.Aggregate(_remoteMovie).Languages.Should().BeEquivalentTo(new List<Language> { _movie.MovieMetadata.Value.OriginalLanguage, Language.French });
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Find(10), Times.Once());
Mocker.GetMock<IIndexerFactory>().Verify(c => c.FindByName("MyIndexer1"), Times.Once());
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
[Test]
public void should_return_multi_languages_from_indexer_with_name_when_indexer_id_not_available()
{
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
var indexerDefinition1 = new IndexerDefinition
{
Id = 1,
Name = "MyIndexer1",
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
};
var indexerDefinition2 = new IndexerDefinition
{
Id = 2,
Name = "MyIndexer2",
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.German.Id } }
};
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Find(1))
.Returns(null as IndexerDefinition);
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.FindByName("MyIndexer1"))
.Returns(indexerDefinition1);
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.All())
.Returns(new List<IndexerDefinition>() { indexerDefinition1, indexerDefinition2 });
_remoteMovie.ParsedMovieInfo = GetParsedMovieInfo(new List<Language> { }, releaseTitle);
_remoteMovie.Release.IndexerId = 0;
_remoteMovie.Release.Indexer = "MyIndexer1";
_remoteMovie.Release.Title = releaseTitle;
Subject.Aggregate(_remoteMovie).Languages.Should().BeEquivalentTo(new List<Language> { _movie.MovieMetadata.Value.OriginalLanguage, Language.French });
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Find(10), Times.Never());
Mocker.GetMock<IIndexerFactory>().Verify(c => c.FindByName("MyIndexer1"), Times.Once());
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
}
}
}

View File

@@ -711,6 +711,30 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
item.CanMoveFiles.Should().BeTrue();
}
[TestCase("pausedUP")]
[TestCase("stoppedUP")]
public void should_be_removable_and_should_allow_move_files_if_max_ratio_reached_after_rounding_and_paused(string state)
{
GivenGlobalSeedLimits(1.0f);
GivenCompletedTorrent(state, ratio: 1.1006066990976857f);
var item = Subject.GetItems().Single();
item.CanBeRemoved.Should().BeTrue();
item.CanMoveFiles.Should().BeTrue();
}
[TestCase("pausedUP")]
[TestCase("stoppedUP")]
public void should_be_removable_and_should_allow_move_files_if_just_under_max_ratio_reached_after_rounding_and_paused(string state)
{
GivenGlobalSeedLimits(1.0f);
GivenCompletedTorrent(state, ratio: 0.9999f);
var item = Subject.GetItems().Single();
item.CanBeRemoved.Should().BeTrue();
item.CanMoveFiles.Should().BeTrue();
}
[TestCase("pausedUP")]
[TestCase("stoppedUP")]
public void should_be_removable_and_should_allow_move_files_if_overridden_max_ratio_reached_and_paused(string state)
@@ -723,6 +747,30 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
item.CanMoveFiles.Should().BeTrue();
}
[TestCase("pausedUP")]
[TestCase("stoppedUP")]
public void should_be_removable_and_should_allow_move_files_if_overridden_max_ratio_reached_after_rounding_and_paused(string state)
{
GivenGlobalSeedLimits(2.0f);
GivenCompletedTorrent(state, ratio: 1.1006066990976857f, ratioLimit: 1.1f);
var item = Subject.GetItems().Single();
item.CanBeRemoved.Should().BeTrue();
item.CanMoveFiles.Should().BeTrue();
}
[TestCase("pausedUP")]
[TestCase("stoppedUP")]
public void should_be_removable_and_should_allow_move_files_if_just_under_overridden_max_ratio_reached_after_rounding_and_paused(string state)
{
GivenGlobalSeedLimits(2.0f);
GivenCompletedTorrent(state, ratio: 0.9999f, ratioLimit: 1.0f);
var item = Subject.GetItems().Single();
item.CanBeRemoved.Should().BeTrue();
item.CanMoveFiles.Should().BeTrue();
}
[TestCase("pausedUP")]
[TestCase("stoppedUP")]
public void should_not_be_removable_if_overridden_max_ratio_not_reached_and_paused(string state)

View File

@@ -478,6 +478,37 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests
downloadClientInfo.RemovesCompletedDownloads.Should().BeTrue();
}
[TestCase("all", 0)]
[TestCase("days-archive", 15)]
[TestCase("days-delete", 15)]
public void should_set_history_removes_completed_downloads_false_for_separate_properties(string option, int number)
{
_config.Misc.history_retention_option = option;
_config.Misc.history_retention_number = number;
var downloadClientInfo = Subject.GetStatus();
downloadClientInfo.RemovesCompletedDownloads.Should().BeFalse();
}
[TestCase("number-archive", 10)]
[TestCase("number-delete", 10)]
[TestCase("number-archive", 0)]
[TestCase("number-delete", 0)]
[TestCase("days-archive", 3)]
[TestCase("days-delete", 3)]
[TestCase("all-archive", 0)]
[TestCase("all-delete", 0)]
public void should_set_history_removes_completed_downloads_true_for_separate_properties(string option, int number)
{
_config.Misc.history_retention_option = option;
_config.Misc.history_retention_number = number;
var downloadClientInfo = Subject.GetStatus();
downloadClientInfo.RemovesCompletedDownloads.Should().BeTrue();
}
[TestCase(@"Y:\sabnzbd\root", @"completed\downloads", @"vv", @"Y:\sabnzbd\root\completed\downloads", @"Y:\sabnzbd\root\completed\downloads\vv")]
[TestCase(@"Y:\sabnzbd\root", @"completed", @"vv", @"Y:\sabnzbd\root\completed", @"Y:\sabnzbd\root\completed\vv")]
[TestCase(@"/sabnzbd/root", @"completed/downloads", @"vv", @"/sabnzbd/root/completed/downloads", @"/sabnzbd/root/completed/downloads/vv")]

View File

@@ -13,6 +13,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
[TestFixture]
public class TransmissionFixture : TransmissionFixtureBase<Transmission>
{
[SetUp]
public void Setup_Transmission()
{
Mocker.GetMock<ITransmissionProxy>()
.Setup(v => v.GetClientVersion(It.IsAny<TransmissionSettings>(), It.IsAny<bool>()))
.Returns("4.0.6");
}
[Test]
public void queued_item_should_have_required_properties()
{
@@ -272,7 +280,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
public void should_only_check_version_number(string version)
{
Mocker.GetMock<ITransmissionProxy>()
.Setup(s => s.GetClientVersion(It.IsAny<TransmissionSettings>()))
.Setup(s => s.GetClientVersion(It.IsAny<TransmissionSettings>(), true))
.Returns(version);
Subject.Test().IsValid.Should().BeTrue();

View File

@@ -29,7 +29,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
Host = "127.0.0.1",
Port = 2222,
Username = "admin",
Password = "pass"
Password = "pass",
MovieCategory = ""
};
Subject.Definition = new DownloadClientDefinition();
@@ -152,7 +153,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
}
Mocker.GetMock<ITransmissionProxy>()
.Setup(s => s.GetTorrents(It.IsAny<TransmissionSettings>()))
.Setup(s => s.GetTorrents(null, It.IsAny<TransmissionSettings>()))
.Returns(torrents);
}

View File

@@ -63,7 +63,9 @@ namespace NzbDrone.Core.Test.Languages
new object[] { 48, Language.Malayalam },
new object[] { 49, Language.Kannada },
new object[] { 50, Language.Albanian },
new object[] { 51, Language.Afrikaans }
new object[] { 51, Language.Afrikaans },
new object[] { 52, Language.Marathi },
new object[] { 53, Language.Tagalog },
};
public static object[] ToIntCases =
@@ -121,7 +123,9 @@ namespace NzbDrone.Core.Test.Languages
new object[] { Language.Malayalam, 48 },
new object[] { Language.Kannada, 49 },
new object[] { Language.Albanian, 50 },
new object[] { Language.Afrikaans, 51 }
new object[] { Language.Afrikaans, 51 },
new object[] { Language.Marathi, 52 },
new object[] { Language.Tagalog, 53 },
};
[Test]

View File

@@ -62,5 +62,23 @@ namespace NzbDrone.Core.Test.ParserTests
var result = IsoLanguages.Find(isoCode);
result.Language.Should().Be(Language.Afrikaans);
}
[TestCase("mr")]
[TestCase("mar")]
[TestCase("mr-IN")]
public void should_return_marathi(string isoCode)
{
var result = IsoLanguages.Find(isoCode);
result.Language.Should().Be(Language.Marathi);
}
[TestCase("tl")]
[TestCase("tgl")]
[TestCase("tl-PH")]
public void should_return_tagalog(string isoCode)
{
var result = IsoLanguages.Find(isoCode);
result.Language.Should().Be(Language.Tagalog);
}
}
}

View File

@@ -460,6 +460,22 @@ namespace NzbDrone.Core.Test.ParserTests
result.Should().Contain(Language.Afrikaans);
}
[TestCase("Movie Title 2015 Marathi 1080p WebRip x264 AC3 5.1 ESubs [TMB]")]
[TestCase("Movie.Title.(2018).720p.CensorRip.Marathi.x264.AAC.-.LHDm@Telly")]
public void should_parse_language_marathi(string postTitle)
{
var result = LanguageParser.ParseLanguages(postTitle);
result.Should().Contain(Language.Marathi);
}
[TestCase("Movie Title 2024 1080p Tagalog WEB-DL HEVC x265 BONE")]
[TestCase("Movie.Title.2022.720p.Tagalog.WEB-DL.AAC.x264-Mkvking")]
public void should_parse_language_tagalog(string postTitle)
{
var result = LanguageParser.ParseLanguages(postTitle);
result.Should().Contain(Language.Tagalog);
}
[TestCase("Movie.Title.en.sub")]
[TestCase("Movie Title.eng.sub")]
[TestCase("Movie.Title.eng.forced.sub")]

View File

@@ -23,7 +23,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Genre";
[FieldDefinition(1, Label = "Genre(s)", Type = FieldType.Tag)]
[FieldDefinition(1, Label = "AutoTaggingSpecificationGenre", Type = FieldType.Tag)]
public IEnumerable<string> Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Movie movie)

View File

@@ -21,7 +21,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Original Language";
[FieldDefinition(1, Label = "Language", Type = FieldType.Select, SelectOptions = typeof(OriginalLanguageFieldConverter))]
[FieldDefinition(1, Label = "AutoTaggingSpecificationOriginalLanguage", Type = FieldType.Select, SelectOptions = typeof(OriginalLanguageFieldConverter))]
public int Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Movie movie)

View File

@@ -20,7 +20,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Quality Profile";
[FieldDefinition(1, Label = "Quality Profile", Type = FieldType.QualityProfile)]
[FieldDefinition(1, Label = "AutoTaggingSpecificationQualityProfile", Type = FieldType.QualityProfile)]
public int Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Movie movie)

View File

@@ -22,7 +22,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Root Folder";
[FieldDefinition(1, Label = "Root Folder", Type = FieldType.RootFolder)]
[FieldDefinition(1, Label = "AutoTaggingSpecificationRootFolder", Type = FieldType.RootFolder)]
public string Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Movie movie)

View File

@@ -24,10 +24,10 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Runtime";
[FieldDefinition(1, Label = "Minimum Runtime", Type = FieldType.Number)]
[FieldDefinition(1, Label = "AutoTaggingSpecificationMinimumRuntime", Type = FieldType.Number, Unit = "minutes")]
public int Min { get; set; }
[FieldDefinition(2, Label = "Maximum Runtime", Type = FieldType.Number)]
[FieldDefinition(2, Label = "AutoTaggingSpecificationMaximumRuntime", Type = FieldType.Number, Unit = "minutes")]
public int Max { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Movie movie)

View File

@@ -0,0 +1,43 @@
using System;
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public class StatusSpecificationValidator : AbstractValidator<StatusSpecification>
{
public StatusSpecificationValidator()
{
RuleFor(c => c.Status).Custom((statusType, context) =>
{
if (!Enum.IsDefined(typeof(MovieStatusType), statusType))
{
context.AddFailure($"Invalid status type condition value: {statusType}");
}
});
}
}
public class StatusSpecification : AutoTaggingSpecificationBase
{
private static readonly StatusSpecificationValidator Validator = new ();
public override int Order => 1;
public override string ImplementationName => "Status";
[FieldDefinition(1, Label = "AutoTaggingSpecificationStatus", Type = FieldType.Select, SelectOptions = typeof(MovieStatusType))]
public int Status { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Movie movie)
{
return movie?.MovieMetadata?.Value?.Status == (MovieStatusType)Status;
}
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@@ -23,10 +23,10 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Year";
[FieldDefinition(1, Label = "Minimum Year", Type = FieldType.Number)]
[FieldDefinition(1, Label = "AutoTaggingSpecificationMinimumYear", Type = FieldType.Number)]
public int Min { get; set; }
[FieldDefinition(2, Label = "Maximum Year", Type = FieldType.Number)]
[FieldDefinition(2, Label = "AutoTaggingSpecificationMaximumYear", Type = FieldType.Number)]
public int Max { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Movie movie)

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