Compare commits

..

No commits in common. "develop" and "v1.24.1.4740" have entirely different histories.

280 changed files with 4838 additions and 7372 deletions

33
Logo/dottrace.svg Normal file
View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-1.3318" y1="43.7371" x2="67.0419" y2="26.0967">
<stop offset="0.1237" style="stop-color:#7866FF"/>
<stop offset="0.5376" style="stop-color:#FE2EB6"/>
<stop offset="0.8548" style="stop-color:#FD0486"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="67.3,16 43.7,0 0,31.1 11.1,70 58.9,60.3 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="45.9148" y1="38.9098" x2="67.6577" y2="9.0989">
<stop offset="0.1237" style="stop-color:#FF0080"/>
<stop offset="0.2587" style="stop-color:#FE0385"/>
<stop offset="0.4109" style="stop-color:#FA0C92"/>
<stop offset="0.5713" style="stop-color:#F41BA9"/>
<stop offset="0.7363" style="stop-color:#EB2FC8"/>
<stop offset="0.8656" style="stop-color:#E343E6"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="67.3,16 43.7,0 38,15.7 38,47.8 70,47.8 "/>
</g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.4" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<g>
<path style="fill:#FFFFFF;" d="M17.4,19.1h6.9c5.6,0,9.5,3.8,9.5,8.9V28c0,5-3.9,8.9-9.5,8.9h-6.9V19.1z M21.4,22.7v10.7h3
c3.2,0,5.4-2.2,5.4-5.3V28c0-3.2-2.2-5.4-5.4-5.4H21.4z"/>
<polygon style="fill:#FFFFFF;" points="40.3,22.7 34.9,22.7 34.9,19.1 49.6,19.1 49.6,22.7 44.2,22.7 44.2,37 40.3,37 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

66
Logo/jetbrains.svg Normal file
View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="120.1px" height="130.2px" viewBox="0 0 120.1 130.2" style="enable-background:new 0 0 120.1 130.2;" xml:space="preserve"
>
<g>
<linearGradient id="XMLID_2_" gradientUnits="userSpaceOnUse" x1="31.8412" y1="120.5578" x2="110.2402" y2="73.24">
<stop offset="0" style="stop-color:#FCEE39"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3041_" style="fill:url(#XMLID_2_);" d="M118.6,71.8c0.9-0.8,1.4-1.9,1.5-3.2c0.1-2.6-1.8-4.7-4.4-4.9
c-1.2-0.1-2.4,0.4-3.3,1.1l0,0l-83.8,45.9c-1.9,0.8-3.6,2.2-4.7,4.1c-2.9,4.8-1.3,11,3.6,13.9c3.4,2,7.5,1.8,10.7-0.2l0,0l0,0
c0.2-0.2,0.5-0.3,0.7-0.5l78-54.8C117.3,72.9,118.4,72.1,118.6,71.8L118.6,71.8L118.6,71.8z"/>
<linearGradient id="XMLID_3_" gradientUnits="userSpaceOnUse" x1="48.3607" y1="6.9083" x2="119.9179" y2="69.5546">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.57" style="stop-color:#F26F4E"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3049_" style="fill:url(#XMLID_3_);" d="M118.8,65.1L118.8,65.1L55,2.5C53.6,1,51.6,0,49.3,0
c-4.3,0-7.7,3.5-7.7,7.7v0c0,2.1,0.8,3.9,2.1,5.3l0,0l0,0c0.4,0.4,0.8,0.7,1.2,1l67.4,57.7l0,0c0.8,0.7,1.8,1.2,3,1.3
c2.6,0.1,4.7-1.8,4.9-4.4C120.2,67.3,119.7,66,118.8,65.1z"/>
<linearGradient id="XMLID_4_" gradientUnits="userSpaceOnUse" x1="52.9467" y1="63.6407" x2="10.5379" y2="37.1562">
<stop offset="0" style="stop-color:#7C59A4"/>
<stop offset="0.3852" style="stop-color:#AF4C92"/>
<stop offset="0.7654" style="stop-color:#DC4183"/>
<stop offset="0.957" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3042_" style="fill:url(#XMLID_4_);" d="M57.1,59.5C57,59.5,17.7,28.5,16.9,28l0,0l0,0c-0.6-0.3-1.2-0.6-1.8-0.9
c-5.8-2.2-12.2,0.8-14.4,6.6c-1.9,5.1,0.2,10.7,4.6,13.4l0,0l0,0C6,47.5,6.6,47.8,7.3,48c0.4,0.2,45.4,18.8,45.4,18.8l0,0
c1.8,0.8,3.9,0.3,5.1-1.2C59.3,63.7,59,61,57.1,59.5z"/>
<linearGradient id="XMLID_5_" gradientUnits="userSpaceOnUse" x1="52.1736" y1="3.7019" x2="10.7706" y2="37.8971">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.364" style="stop-color:#EE4E72"/>
<stop offset="1" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3057_" style="fill:url(#XMLID_5_);" d="M49.3,0c-1.7,0-3.3,0.6-4.6,1.5L4.9,28.3c-0.1,0.1-0.2,0.1-0.2,0.2l-0.1,0
l0,0c-1.7,1.2-3.1,3-3.9,5.1C-1.5,39.4,1.5,45.9,7.3,48c3.6,1.4,7.5,0.7,10.4-1.4l0,0l0,0c0.7-0.5,1.3-1,1.8-1.6l34.6-31.2l0,0
c1.8-1.4,3-3.6,3-6.1v0C57.1,3.5,53.6,0,49.3,0z"/>
<g id="XMLID_3008_">
<rect id="XMLID_3033_" x="34.6" y="37.4" style="fill:#000000;" width="51" height="51"/>
<rect id="XMLID_3032_" x="39" y="78.8" style="fill:#FFFFFF;" width="19.1" height="3.2"/>
<g id="XMLID_3009_">
<path id="XMLID_3030_" style="fill:#FFFFFF;" d="M38.8,50.8l1.5-1.4c0.4,0.5,0.8,0.8,1.3,0.8c0.6,0,0.9-0.4,0.9-1.2l0-5.3l2.3,0
l0,5.3c0,1-0.3,1.8-0.8,2.3c-0.5,0.5-1.3,0.8-2.3,0.8C40.2,52.2,39.4,51.6,38.8,50.8z"/>
<path id="XMLID_3028_" style="fill:#FFFFFF;" d="M45.3,43.8l6.7,0v1.9l-4.4,0V47l4,0l0,1.8l-4,0l0,1.3l4.5,0l0,2l-6.7,0
L45.3,43.8z"/>
<path id="XMLID_3026_" style="fill:#FFFFFF;" d="M55,45.8l-2.5,0l0-2l7.3,0l0,2l-2.5,0l0,6.3l-2.3,0L55,45.8z"/>
<path id="XMLID_3022_" style="fill:#FFFFFF;" d="M39,54l4.3,0c1,0,1.8,0.3,2.3,0.7c0.3,0.3,0.5,0.8,0.5,1.4v0
c0,1-0.5,1.5-1.3,1.9c1,0.3,1.6,0.9,1.6,2v0c0,1.4-1.2,2.3-3.1,2.3l-4.3,0L39,54z M43.8,56.6c0-0.5-0.4-0.7-1-0.7l-1.5,0l0,1.5
l1.4,0C43.4,57.3,43.8,57.1,43.8,56.6L43.8,56.6z M43,59l-1.8,0l0,1.5H43c0.7,0,1.1-0.3,1.1-0.8v0C44.1,59.2,43.7,59,43,59z"/>
<path id="XMLID_3019_" style="fill:#FFFFFF;" d="M46.8,54l3.9,0c1.3,0,2.1,0.3,2.7,0.9c0.5,0.5,0.7,1.1,0.7,1.9v0
c0,1.3-0.7,2.1-1.7,2.6l2,2.9l-2.6,0l-1.7-2.5h-1l0,2.5l-2.3,0L46.8,54z M50.6,58c0.8,0,1.2-0.4,1.2-1v0c0-0.7-0.5-1-1.2-1
l-1.5,0v2H50.6z"/>
<path id="XMLID_3016_" style="fill:#FFFFFF;" d="M56.8,54l2.2,0l3.5,8.4l-2.5,0l-0.6-1.5l-3.2,0l-0.6,1.5l-2.4,0L56.8,54z
M58.8,59l-0.9-2.3L57,59L58.8,59z"/>
<path id="XMLID_3014_" style="fill:#FFFFFF;" d="M62.8,54l2.3,0l0,8.3l-2.3,0L62.8,54z"/>
<path id="XMLID_3012_" style="fill:#FFFFFF;" d="M65.7,54l2.1,0l3.4,4.4l0-4.4l2.3,0l0,8.3l-2,0L68,57.8l0,4.6l-2.3,0L65.7,54z"
/>
<path id="XMLID_3010_" style="fill:#FFFFFF;" d="M73.7,61.1l1.3-1.5c0.8,0.7,1.7,1,2.7,1c0.6,0,1-0.2,1-0.6v0
c0-0.4-0.3-0.5-1.4-0.8c-1.8-0.4-3.1-0.9-3.1-2.6v0c0-1.5,1.2-2.7,3.2-2.7c1.4,0,2.5,0.4,3.4,1.1l-1.2,1.6
c-0.8-0.5-1.6-0.8-2.3-0.8c-0.6,0-0.8,0.2-0.8,0.5v0c0,0.4,0.3,0.5,1.4,0.8c1.9,0.4,3.1,1,3.1,2.6v0c0,1.7-1.3,2.7-3.4,2.7
C76.1,62.5,74.7,62,73.7,61.1z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

50
Logo/resharper.svg Normal file
View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="22.9451" y1="75.7869" x2="74.7868" y2="20.6415">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.6505" style="stop-color:#EB8523"/>
<stop offset="0.9516" style="stop-color:#FEBD11"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="49.8,15.2 36,36.7 58.4,70 70,23.1 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="17.7187" y1="73.2922" x2="69.5556" y2="18.1519">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.7043" style="stop-color:#EB8523"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="51.1,15.7 49,0 18.8,33.6 27.6,42.3 20.8,70 58.4,70 "/>
</g>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="1.8281" y1="53.4275" x2="48.8245" y2="9.2255">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.6613" style="stop-color:#C41E57"/>
</linearGradient>
<polygon style="fill:url(#SVGID_3_);" points="49,0 11.6,0 0,47.1 55.6,47.1 "/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="49.8935" y1="-11.5569" x2="48.8588" y2="24.0352">
<stop offset="0.5" style="stop-color:#C41E57"/>
<stop offset="0.6668" style="stop-color:#D13F48"/>
<stop offset="0.7952" style="stop-color:#D94F39"/>
<stop offset="0.8656" style="stop-color:#DD5433"/>
</linearGradient>
<polygon style="fill:url(#SVGID_4_);" points="55.3,47.1 51.1,15.7 49,0 41.7,23 "/>
</g>
<g>
<rect x="13.4" y="13.5" transform="matrix(-1 2.577289e-003 -2.577289e-003 -1 70.0288 70.081)" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.6" y="48.6" transform="matrix(1 -2.577289e-003 2.577289e-003 1 -0.1287 6.634109e-002)" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<path style="fill:#FFFFFF;" d="M17.4,19.1l8.2,0c2.3,0,4,0.6,5.2,1.8c1,1,1.5,2.4,1.5,4.1l0,0.1c0,1.5-0.3,2.6-1.1,3.5
c-0.7,0.9-1.6,1.6-2.8,2l4.4,6.4l-4.6,0l-3.7-5.5l-3.3,0l0,5.5l-3.9,0L17.4,19.1z M25.3,27.8c1,0,1.7-0.2,2.2-0.7
c0.5-0.5,0.8-1.1,0.8-1.8l0-0.1c0-0.9-0.3-1.5-0.8-1.9c-0.5-0.4-1.3-0.6-2.3-0.6l-3.9,0l0,5.1L25.3,27.8z"/>
<path style="fill:#FFFFFF;" d="M36,33.2l-1.9,0l0-3.3l2.5,0l0.6-3.8l-2.3,0l0-3.3l2.8,0l0.6-3.7l3.4,0l-0.6,3.7l3.7,0l0.6-3.7
l3.4,0l-0.6,3.7l1.9,0l0,3.3l-2.5,0L47,29.9l2.3,0l0,3.3l-2.8,0L45.8,37l-3.4,0l0.7-3.8l-3.7,0L38.7,37l-3.4,0L36,33.2z
M43.7,29.9l0.6-3.8l-3.7,0L40,29.9L43.7,29.9z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

42
Logo/rider.svg Normal file
View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<defs>
<linearGradient id="linear-gradient" x1="70.22612" y1="27.79912" x2="-5.13024" y2="63.12242" gradientTransform="matrix(1, 0, 0, -1, 0, 71.27997)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#c90f5e"/>
<stop offset="0.22111" stop-color="#c90f5e"/>
<stop offset="0.2356" stop-color="#c90f5e"/>
<stop offset="0.35559" stop-color="#ca135c"/>
<stop offset="0.46633" stop-color="#ce1e57"/>
<stop offset="0.5735" stop-color="#d4314e"/>
<stop offset="0.67844" stop-color="#dc4b41"/>
<stop offset="0.78179" stop-color="#e66d31"/>
<stop offset="0.88253" stop-color="#f3961d"/>
<stop offset="0.94241" stop-color="#fcb20f"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="24.65904" y1="61.99608" x2="46.04762" y2="2.93445" gradientTransform="matrix(1, 0, 0, -1, 0, 71.27997)" gradientUnits="userSpaceOnUse">
<stop offset="0.04188" stop-color="#077cfb"/>
<stop offset="0.44503" stop-color="#c90f5e"/>
<stop offset="0.95812" stop-color="#077cfb"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="17.39552" y1="63.34592" x2="33.19389" y2="7.20092" gradientTransform="matrix(1, 0, 0, -1, 0, 71.27997)" gradientUnits="userSpaceOnUse">
<stop offset="0.27749" stop-color="#c90f5e"/>
<stop offset="0.97382" stop-color="#fcb20f"/>
</linearGradient>
</defs>
<title>rider</title>
<g>
<polygon points="70 27.237 63.391 23.75 20.926 0 3.827 17.921 21.619 41.068 60.537 44.397 70 27.237" fill="url(#linear-gradient)"/>
<polygon points="50.423 16.132 44.271 1.107 27.643 17.471 11.768 50.194 49.411 70 70 57.98 50.423 16.132" fill="url(#linear-gradient-2)"/>
<polygon points="20.926 0 0 14.095 7.779 62.172 27.848 69.889 53.78 48.823 20.926 0" fill="url(#linear-gradient-3)"/>
</g>
<g>
<rect x="13.30219" y="13.19311" width="43.61371" height="43.61371"/>
<g>
<path d="M17.22741,18.86293h8.39564a7.38416,7.38416,0,0,1,5.34268,1.85358,5.86989,5.86989,0,0,1,1.52648,4.1433h0A5.74339,5.74339,0,0,1,28.567,30.5296l4.47041,6.54206H28.34891L24.42368,31.1838h-3.162v5.88785H17.22741V18.86293h0ZM25.296,27.69471c1.96262,0,3.053-1.09034,3.053-2.61682h0c0-1.74455-1.19938-2.61682-3.162-2.61682H21.15265v5.23365H25.296Z" fill="#fff"/>
<path d="M36.09034,18.86293H43.2866c5.77882,0,9.70405,3.92523,9.70405,9.15888h0c0,5.12461-3.92523,9.15888-9.70405,9.15888H36.09034V18.86293Zm4.03427,3.59813V33.47352h3.162a5.23727,5.23727,0,0,0,5.56075-5.45171h0a5.26493,5.26493,0,0,0-5.56075-5.56075h-3.162Z" fill="#fff"/>
</g>
<rect x="17.22741" y="48.62925" width="16.35514" height="2.72586" fill="#fff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

36
Logo/webstorm.svg Normal file
View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="25.0676" y1="1.4599" x2="43.1829" y2="66.675">
<stop offset="0.2849" style="stop-color:#00CDD7"/>
<stop offset="0.9409" style="stop-color:#2086D7"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="9.4,63.3 0,7.3 17.5,0.1 28.6,6.7 38.8,1.2 60.1,9.4 48.1,70 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="30.7199" y1="9.7343" x2="61.365" y2="54.6713">
<stop offset="0.1398" style="stop-color:#FFF045"/>
<stop offset="0.3656" style="stop-color:#00CDD7"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="70,23.7 61,1.4 44.6,0 19.3,24.3 26.1,55.6 38.8,64.6 70,46 62.3,31.7 "/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="61.0819" y1="15.2899" x2="65.1065" y2="29.5436">
<stop offset="0.2849" style="stop-color:#00CDD7"/>
<stop offset="0.9409" style="stop-color:#2086D7"/>
</linearGradient>
<polygon style="fill:url(#SVGID_3_);" points="56,20.4 62.3,31.7 70,23.7 64.4,9.8 "/>
</g>
<g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.5" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<path style="fill:#FFFFFF;" d="M38.7,34.3l2.3-2.8c1.6,1.3,3.3,2.2,5.3,2.2c1.6,0,2.5-0.6,2.5-1.7v-0.1c0-1-0.6-1.5-3.6-2.3
c-3.6-0.9-5.8-1.9-5.8-5.5v-0.1c0-3.3,2.6-5.4,6.2-5.4c2.6,0,4.8,0.8,6.6,2.3l-2,3c-1.6-1.1-3.1-1.8-4.6-1.8
c-1.5,0-2.3,0.7-2.3,1.6v0.1c0,1.2,0.8,1.6,3.8,2.4c3.6,1,5.6,2.3,5.6,5.4v0.1c0,3.6-2.7,5.6-6.5,5.6
C43.5,37.2,40.8,36.2,38.7,34.3"/>
</g>
<polygon style="fill:#FFFFFF;" points="35.2,19 32.5,29.4 29.5,19 26.5,19 23.4,29.4 20.7,19 16.6,19 21.7,36.9 25,36.9 28,26.5
30.9,36.9 34.3,36.9 39.4,19 "/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -68,16 +68,16 @@ Support this project by becoming a sponsor. Your logo will show up here with a l
## JetBrains
Thank you to [<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.png" alt="JetBrains" width="96">](http://www.jetbrains.com/) for providing us with free licenses to their great tools.
Thank you to [<img src="/Logo/jetbrains.svg" alt="JetBrains" width="32"> JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools.
* [<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/ReSharper_icon.png" alt="ReSharper" width="32"> ReSharper](http://www.jetbrains.com/resharper/)
* [<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/WebStorm_icon.png" alt="WebStorm" width="32"> WebStorm](http://www.jetbrains.com/webstorm/)
* [<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/Rider_icon.png" alt="Rider" width="32"> Rider](http://www.jetbrains.com/rider/)
* [<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/dotTrace_icon.png" alt="dotTrace" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
- [<img src="/Logo/resharper.svg" alt="ReSharper" width="32"> ReSharper](http://www.jetbrains.com/resharper/)
- [<img src="/Logo/webstorm.svg" alt="WebStorm" width="32"> WebStorm](http://www.jetbrains.com/webstorm/)
- [<img src="/Logo/rider.svg" alt="Rider" width="32"> Rider](http://www.jetbrains.com/rider/)
- [<img src="/Logo/dottrace.svg" alt="dotTrace" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
### License
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2025
- Copyright 2010-2022
Icon Credit - [Box vector created by freepik - www.freepik.com](https://www.freepik.com/vectors/box)

View file

@ -9,18 +9,18 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '1.35.0'
majorVersion: '1.24.1'
minorVersion: $[counter('minorVersion', 1)]
prowlarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
sentryOrg: 'servarr'
sentryUrl: 'https://sentry.servarr.com'
dotnetVersion: '6.0.427'
dotnetVersion: '6.0.424'
nodeVersion: '20.X'
innoVersion: '6.2.2'
windowsImage: 'windows-2022'
linuxImage: 'ubuntu-22.04'
macImage: 'macOS-13'
linuxImage: 'ubuntu-20.04'
macImage: 'macOS-12'
trigger:
branches:
@ -1169,12 +1169,12 @@ stages:
submodules: true
- powershell: Set-Service SCardSvr -StartupType Manual
displayName: Enable Windows Test Service
- task: SonarCloudPrepare@3
- task: SonarCloudPrepare@2
condition: eq(variables['System.PullRequest.IsFork'], 'False')
inputs:
SonarCloud: 'SonarCloud'
organization: 'prowlarr'
scannerMode: 'dotnet'
scannerMode: 'MSBuild'
projectKey: 'Prowlarr_Prowlarr'
projectName: 'Prowlarr'
projectVersion: '$(prowlarrVersion)'
@ -1187,10 +1187,10 @@ 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@3
- task: SonarCloudAnalyze@2
condition: eq(variables['System.PullRequest.IsFork'], 'False')
displayName: Publish SonarCloud Results
- task: reportgenerator@5.3.11
- task: reportgenerator@5
displayName: Generate Coverage Report
inputs:
reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml'

11
docs.sh
View file

@ -3,16 +3,15 @@ set -e
FRAMEWORK="net6.0"
PLATFORM=$1
ARCHITECTURE="${2:-x64}"
if [ "$PLATFORM" = "Windows" ]; then
RUNTIME="win-$ARCHITECTURE"
RUNTIME="win-x64"
elif [ "$PLATFORM" = "Linux" ]; then
RUNTIME="linux-$ARCHITECTURE"
RUNTIME="linux-x64"
elif [ "$PLATFORM" = "Mac" ]; then
RUNTIME="osx-$ARCHITECTURE"
RUNTIME="osx-x64"
else
echo "Platform must be provided as first argument: Windows, Linux or Mac"
echo "Platform must be provided as first arguement: Windows, Linux or Mac"
exit 1
fi
@ -38,7 +37,7 @@ dotnet clean $slnFile -c Release
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
dotnet new tool-manifest
dotnet tool install --version 7.3.2 Swashbuckle.AspNetCore.Cli
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
dotnet tool run swagger tofile --output ./src/Prowlarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 &

View file

@ -25,7 +25,6 @@ module.exports = (env) => {
const config = {
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map',
target: 'web',
stats: {
children: false
@ -170,7 +169,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: '3.39'
corejs: 3
}
]
]

View file

@ -20,7 +20,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
function RedirectWithUrlBase() {
@ -99,7 +99,7 @@ function AppRoutes() {
<Route path="/system/backup" component={BackupsConnector} />
<Route path="/system/updates" component={Updates} />
<Route path="/system/updates" component={UpdatesConnector} />
<Route path="/system/events" component={LogsTableConnector} />

View file

@ -7,8 +7,7 @@ import { IndexerCategory } from 'Indexer/Indexer';
import Application from 'typings/Application';
import DownloadClient from 'typings/DownloadClient';
import Notification from 'typings/Notification';
import General from 'typings/Settings/General';
import UiSettings from 'typings/Settings/UiSettings';
import { UiSettings } from 'typings/UiSettings';
export interface AppProfileAppState
extends AppSectionState<Application>,
@ -29,10 +28,6 @@ export interface DownloadClientAppState
isTestingAll: boolean;
}
export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
export interface IndexerCategoryAppState
extends AppSectionState<IndexerCategory>,
AppSectionDeleteState,
@ -48,7 +43,6 @@ interface SettingsAppState {
appProfiles: AppProfileAppState;
applications: ApplicationAppState;
downloadClients: DownloadClientAppState;
general: GeneralAppState;
indexerCategories: IndexerCategoryAppState;
notifications: NotificationAppState;
ui: UiSettingsAppState;

View file

@ -20,8 +20,6 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 10;
function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@ -139,9 +137,18 @@ class EnhancedSelectInput extends Component {
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom
} = data.offsets.reference;
const windowHeight = window.innerHeight;
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
return data;
};
@ -453,10 +460,6 @@ class EnhancedSelectInput extends Component {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport'
}
}}
>

View file

@ -0,0 +1,54 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css';
function FormInputButton(props) {
const {
className,
canSpin,
isLastButton,
...otherProps
} = props;
if (canSpin) {
return (
<SpinnerButton
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
FormInputButton.propTypes = {
className: PropTypes.string.isRequired,
isLastButton: PropTypes.bool.isRequired,
canSpin: PropTypes.bool.isRequired
};
FormInputButton.defaultProps = {
className: styles.button,
isLastButton: true,
canSpin: false
};
export default FormInputButton;

View file

@ -1,38 +0,0 @@
import classNames from 'classnames';
import React from 'react';
import Button, { ButtonProps } from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css';
export interface FormInputButtonProps extends ButtonProps {
canSpin?: boolean;
isLastButton?: boolean;
}
function FormInputButton({
className = styles.button,
canSpin = false,
isLastButton = true,
...otherProps
}: FormInputButtonProps) {
if (canSpin) {
return (
<SpinnerButton
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
export default FormInputButton;

View file

@ -0,0 +1,48 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { kinds, sizes } from 'Helpers/Props';
import styles from './Label.css';
function Label(props) {
const {
className,
kind,
size,
outline,
children,
...otherProps
} = props;
return (
<span
className={classNames(
className,
styles[kind],
styles[size],
outline && styles.outline
)}
{...otherProps}
>
{children}
</span>
);
}
Label.propTypes = {
className: PropTypes.string.isRequired,
title: PropTypes.string,
kind: PropTypes.oneOf(kinds.all).isRequired,
size: PropTypes.oneOf(sizes.all).isRequired,
outline: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired
};
Label.defaultProps = {
className: styles.label,
kind: kinds.DEFAULT,
size: sizes.SMALL,
outline: false
};
export default Label;

View file

@ -1,33 +0,0 @@
import classNames from 'classnames';
import React, { ComponentProps, ReactNode } from 'react';
import { kinds, sizes } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import styles from './Label.css';
export interface LabelProps extends ComponentProps<'span'> {
kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<Size, keyof typeof styles>;
outline?: boolean;
children: ReactNode;
}
export default function Label({
className = styles.label,
kind = kinds.DEFAULT,
size = sizes.SMALL,
outline = false,
...otherProps
}: LabelProps) {
return (
<span
className={classNames(
className,
styles[kind],
styles[size],
outline && styles.outline
)}
{...otherProps}
/>
);
}

View file

@ -0,0 +1,54 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { align, kinds, sizes } from 'Helpers/Props';
import Link from './Link';
import styles from './Button.css';
class Button extends Component {
//
// Render
render() {
const {
className,
buttonGroupPosition,
kind,
size,
children,
...otherProps
} = this.props;
return (
<Link
className={classNames(
className,
styles[kind],
styles[size],
buttonGroupPosition && styles[buttonGroupPosition]
)}
{...otherProps}
>
{children}
</Link>
);
}
}
Button.propTypes = {
className: PropTypes.string.isRequired,
buttonGroupPosition: PropTypes.oneOf(align.all),
kind: PropTypes.oneOf(kinds.all),
size: PropTypes.oneOf(sizes.all),
children: PropTypes.node
};
Button.defaultProps = {
className: styles.button,
kind: kinds.DEFAULT,
size: sizes.MEDIUM
};
export default Button;

View file

@ -1,37 +0,0 @@
import classNames from 'classnames';
import React from 'react';
import { align, kinds, sizes } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import Link, { LinkProps } from './Link';
import styles from './Button.css';
export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
buttonGroupPosition?: Extract<
(typeof align.all)[number],
keyof typeof styles
>;
kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<Size, keyof typeof styles>;
children: Required<LinkProps['children']>;
}
export default function Button({
className = styles.button,
buttonGroupPosition,
kind = kinds.DEFAULT,
size = sizes.MEDIUM,
...otherProps
}: ButtonProps) {
return (
<Link
className={classNames(
className,
styles[kind],
styles[size],
buttonGroupPosition && styles[buttonGroupPosition]
)}
{...otherProps}
/>
);
}

View file

@ -0,0 +1,139 @@
import Clipboard from 'clipboard';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormInputButton from 'Components/Form/FormInputButton';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import styles from './ClipboardButton.css';
class ClipboardButton extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._id = getUniqueElememtId();
this._successTimeout = null;
this._testResultTimeout = null;
this.state = {
showSuccess: false,
showError: false
};
}
componentDidMount() {
this._clipboard = new Clipboard(`#${this._id}`, {
text: () => this.props.value,
container: document.getElementById(this._id)
});
this._clipboard.on('success', this.onSuccess);
}
componentDidUpdate() {
const {
showSuccess,
showError
} = this.state;
if (showSuccess || showError) {
this._testResultTimeout = setTimeout(this.resetState, 3000);
}
}
componentWillUnmount() {
if (this._clipboard) {
this._clipboard.destroy();
}
if (this._testResultTimeout) {
clearTimeout(this._testResultTimeout);
}
}
//
// Control
resetState = () => {
this.setState({
showSuccess: false,
showError: false
});
};
//
// Listeners
onSuccess = () => {
this.setState({
showSuccess: true
});
};
onError = () => {
this.setState({
showError: true
});
};
//
// Render
render() {
const {
value,
className,
...otherProps
} = this.props;
const {
showSuccess,
showError
} = this.state;
const showStateIcon = showSuccess || showError;
const iconName = showError ? icons.DANGER : icons.CHECK;
const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
return (
<FormInputButton
id={this._id}
className={className}
{...otherProps}
>
<span className={showStateIcon ? styles.showStateIcon : undefined}>
{
showSuccess &&
<span className={styles.stateIconContainer}>
<Icon
name={iconName}
kind={iconKind}
/>
</span>
}
{
<span className={styles.clipboardIconContainer}>
<Icon name={icons.CLIPBOARD} />
</span>
}
</span>
</FormInputButton>
);
}
}
ClipboardButton.propTypes = {
className: PropTypes.string.isRequired,
value: PropTypes.string.isRequired
};
ClipboardButton.defaultProps = {
className: styles.button
};
export default ClipboardButton;

View file

@ -1,76 +0,0 @@
import copy from 'copy-to-clipboard';
import React, { useCallback, useEffect, useState } from 'react';
import FormInputButton from 'Components/Form/FormInputButton';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import { ButtonProps } from './Button';
import styles from './ClipboardButton.css';
export interface ClipboardButtonProps extends Omit<ButtonProps, 'children'> {
value: string;
}
export type ClipboardState = 'success' | 'error' | null;
export default function ClipboardButton({
id,
value,
className = styles.button,
...otherProps
}: ClipboardButtonProps) {
const [state, setState] = useState<ClipboardState>(null);
useEffect(() => {
if (!state) {
return;
}
const timeoutId = setTimeout(() => {
setState(null);
}, 3000);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [state]);
const handleClick = useCallback(async () => {
try {
if ('clipboard' in navigator) {
await navigator.clipboard.writeText(value);
} else {
copy(value);
}
setState('success');
} catch (e) {
setState('error');
console.error(`Failed to copy to clipboard`, e);
}
}, [value]);
return (
<FormInputButton
className={className}
onClick={handleClick}
{...otherProps}
>
<span className={state ? styles.showStateIcon : undefined}>
{state ? (
<span className={styles.stateIconContainer}>
<Icon
name={state === 'error' ? icons.DANGER : icons.CHECK}
kind={state === 'error' ? kinds.DANGER : kinds.SUCCESS}
/>
</span>
) : null}
<span className={styles.clipboardIconContainer}>
<Icon name={icons.CLIPBOARD} />
</span>
</span>
</FormInputButton>
);
}

View file

@ -1,93 +1,96 @@
import classNames from 'classnames';
import React, {
ComponentPropsWithoutRef,
ElementType,
ComponentClass,
FunctionComponent,
SyntheticEvent,
useCallback,
} from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
export type LinkProps<C extends ElementType = 'button'> =
ComponentPropsWithoutRef<C> & {
component?: C;
to?: string;
target?: string;
isDisabled?: LinkProps<C>['disabled'];
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
};
interface ReactRouterLinkProps {
to?: string;
}
export default function Link<C extends ElementType = 'button'>({
className,
component,
to,
target,
type,
isDisabled,
noRouter,
onPress,
...otherProps
}: LinkProps<C>) {
const Component = component || 'button';
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
className?: string;
component?:
| string
| FunctionComponent<LinkProps>
| ComponentClass<LinkProps, unknown>;
to?: string;
target?: string;
isDisabled?: boolean;
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
}
function Link(props: LinkProps) {
const {
className,
component = 'button',
to,
target,
type,
isDisabled,
noRouter = false,
onPress,
...otherProps
} = props;
const onClick = useCallback(
(event: SyntheticEvent) => {
if (isDisabled) {
return;
if (!isDisabled && onPress) {
onPress(event);
}
onPress?.(event);
},
[isDisabled, onPress]
);
const linkClass = classNames(
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
target,
};
let el = component;
if (to) {
if (/\w+?:\/\//.test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
el = RouterLink;
linkProps.to = `${window.Prowlarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
if (to) {
const toLink = /\w+?:\/\//.test(to);
const elementProps = {
...otherProps,
type,
...linkProps,
};
if (toLink || noRouter) {
return (
<a
href={to}
target={target || (toLink ? '_blank' : '_self')}
rel={toLink ? 'noreferrer' : undefined}
className={linkClass}
onClick={onClick}
{...otherProps}
/>
);
}
elementProps.onClick = onClick;
return (
<RouterLink
to={`${window.Prowlarr.urlBase}/${to.replace(/^\//, '')}`}
target={target}
className={linkClass}
onClick={onClick}
{...otherProps}
/>
);
}
return (
<Component
type={
component === 'button' || component === 'input'
? type || 'button'
: type
}
target={target}
className={linkClass}
disabled={isDisabled}
onClick={onClick}
{...otherProps}
/>
);
return React.createElement(el, elementProps);
}
export default Link;

View file

@ -24,7 +24,6 @@
composes: link;
padding: 10px 24px;
padding-left: 35px;
}
.isActiveLink {
@ -42,6 +41,10 @@
text-align: center;
}
.noIcon {
margin-left: 25px;
}
.status {
float: right;
}

View file

@ -8,6 +8,7 @@ interface CssExports {
'isActiveParentLink': string;
'item': string;
'link': string;
'noIcon': string;
'status': string;
}
export const cssExports: CssExports;

View file

@ -63,7 +63,9 @@ class PageSidebarItem extends Component {
</span>
}
{typeof title === 'function' ? title() : title}
<span className={isChildItem ? styles.noIcon : null}>
{typeof title === 'function' ? title() : title}
</span>
{
!!StatusComponent &&

View file

@ -22,14 +22,11 @@
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
height: 24px;
}
.label {
padding: 0 3px;
max-width: 100%;
max-height: 100%;
color: var(--toolbarLabelColor);
font-size: $extraSmallFontSize;
line-height: calc($extraSmallFontSize + 1px);

View file

@ -23,7 +23,6 @@ function PageToolbarButton(props) {
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled || isSpinning}
title={label}
{...otherProps}
>
<Icon

View file

@ -141,16 +141,6 @@ class SignalRConnector extends Component {
console.error(`signalR: Unable to find handler for ${name}`);
};
handleApplications = ({ action, resource }) => {
const section = 'settings.applications';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleCommand = (body) => {
if (body.action === 'sync') {
this.props.dispatchFetchCommands();
@ -160,8 +150,8 @@ class SignalRConnector extends Component {
const resource = body.resource;
const status = resource.status;
// Both successful and failed commands need to be
// completed, otherwise they spin until they time out.
// Both sucessful and failed commands need to be
// completed, otherwise they spin until they timeout.
if (status === 'completed' || status === 'failed') {
this.props.dispatchFinishCommand(resource);
@ -170,16 +160,6 @@ class SignalRConnector extends Component {
}
};
handleDownloadclient = ({ action, resource }) => {
const section = 'settings.downloadClients';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleHealth = () => {
this.props.dispatchFetchHealth();
};
@ -188,33 +168,14 @@ class SignalRConnector extends Component {
this.props.dispatchFetchIndexerStatus();
};
handleIndexer = ({ action, resource }) => {
handleIndexer = (body) => {
const action = body.action;
const section = 'indexers';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
if (action === 'updated') {
this.props.dispatchUpdateItem({ section, ...body.resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleIndexerproxy = ({ action, resource }) => {
const section = 'settings.indexerProxies';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleNotification = ({ action, resource }) => {
const section = 'settings.notifications';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
this.props.dispatchRemoveItem({ section, id: body.resource.id });
}
};

View file

@ -7,6 +7,7 @@ export const PRIMARY = 'primary';
export const PURPLE = 'purple';
export const SUCCESS = 'success';
export const WARNING = 'warning';
export const QUEUE = 'queue';
export const all = [
DANGER,
@ -18,15 +19,5 @@ export const all = [
PURPLE,
SUCCESS,
WARNING,
] as const;
export type Kind =
| 'danger'
| 'default'
| 'disabled'
| 'info'
| 'inverse'
| 'primary'
| 'purple'
| 'success'
| 'warning';
QUEUE
];

View file

@ -4,6 +4,4 @@ export const MEDIUM = 'medium';
export const LARGE = 'large';
export const EXTRA_LARGE = 'extraLarge';
export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE] as const;
export type Size = 'extraSmall' | 'small' | 'medium' | 'large' | 'extraLarge';
export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE];

View file

@ -257,7 +257,6 @@ class HistoryRow extends Component {
key={parameter.key}
title={parameter.title}
value={data[parameter.key]}
queryType={data.queryType}
/>
);
}

View file

@ -1,16 +1,14 @@
import React from 'react';
import Link from 'Components/Link/Link';
import { HistoryQueryType } from 'typings/History';
import styles from './HistoryRowParameter.css';
interface HistoryRowParameterProps {
title: string;
value: string;
queryType: HistoryQueryType;
}
function HistoryRowParameter(props: HistoryRowParameterProps) {
const { title, value, queryType } = props;
const { title, value } = props;
const type = title.toLowerCase();
@ -20,13 +18,7 @@ function HistoryRowParameter(props: HistoryRowParameterProps) {
link = <Link to={`https://imdb.com/title/${value}/`}>{value}</Link>;
} else if (type === 'tmdb') {
link = (
<Link
to={`https://www.themoviedb.org/${
queryType === 'tvsearch' ? 'tv' : 'movie'
}/${value}`}
>
{value}
</Link>
<Link to={`https://www.themoviedb.org/movie/${value}`}>{value}</Link>
);
} else if (type === 'tvdb') {
link = (

View file

@ -19,7 +19,6 @@ interface SavePayload {
seedRatio?: number;
seedTime?: number;
packSeedTime?: number;
preferMagnetUrl?: boolean;
}
interface EditIndexerModalContentProps {
@ -66,9 +65,6 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
const [packSeedTime, setPackSeedTime] = useState<null | string | number>(
null
);
const [preferMagnetUrl, setPreferMagnetUrl] = useState<
null | string | boolean
>(null);
const save = useCallback(() => {
let hasChanges = false;
@ -109,11 +105,6 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
payload.packSeedTime = packSeedTime as number;
}
if (preferMagnetUrl !== null) {
hasChanges = true;
payload.preferMagnetUrl = preferMagnetUrl === 'true';
}
if (hasChanges) {
onSavePress(payload);
}
@ -127,7 +118,6 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
seedRatio,
seedTime,
packSeedTime,
preferMagnetUrl,
onSavePress,
onModalClose,
]);
@ -156,9 +146,6 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
case 'packSeedTime':
setPackSeedTime(value);
break;
case 'preferMagnetUrl':
setPreferMagnetUrl(value);
break;
default:
console.warn(`EditIndexersModalContent Unknown Input: '${name}'`);
}
@ -267,18 +254,6 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('PreferMagnetUrl')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="preferMagnetUrl"
value={preferMagnetUrl}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>

View file

@ -29,8 +29,7 @@
.minimumSeeders,
.seedRatio,
.seedTime,
.packSeedTime,
.preferMagnetUrl {
.packSeedTime {
composes: cell;
flex: 0 0 90px;

View file

@ -11,7 +11,6 @@ interface CssExports {
'id': string;
'minimumSeeders': string;
'packSeedTime': string;
'preferMagnetUrl': string;
'priority': string;
'privacy': string;
'protocol': string;

View file

@ -1,7 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import CheckInput from 'Components/Form/CheckInput';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
@ -75,10 +74,6 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime')
?.value ?? undefined;
const preferMagnetUrl =
fields.find((field) => field.name === 'torrentBaseSettings.preferMagnetUrl')
?.value ?? undefined;
const rssUrl = `${window.location.origin}${
window.Prowlarr.urlBase
}/${id}/api?apikey=${encodeURIComponent(
@ -107,10 +102,6 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
setIsDeleteIndexerModalOpen(false);
}, [setIsDeleteIndexerModalOpen]);
const checkInputCallback = useCallback(() => {
// Mock handler to satisfy `onChange` being required for `CheckInput`.
}, []);
const onSelectedChange = useCallback(
({ id, value, shiftKey }: SelectStateInputProps) => {
selectDispatch({
@ -286,21 +277,6 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
);
}
if (name === 'preferMagnetUrl') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{preferMagnetUrl === undefined ? null : (
<CheckInput
name="preferMagnetUrl"
value={preferMagnetUrl}
isDisabled={true}
onChange={checkInputCallback}
/>
)}
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell

View file

@ -22,8 +22,7 @@
.minimumSeeders,
.seedRatio,
.seedTime,
.packSeedTime,
.preferMagnetUrl {
.packSeedTime {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 90px;

View file

@ -8,7 +8,6 @@ interface CssExports {
'id': string;
'minimumSeeders': string;
'packSeedTime': string;
'preferMagnetUrl': string;
'priority': string;
'privacy': string;
'protocol': string;

View file

@ -68,7 +68,6 @@ function IndexerHistoryRow(props: IndexerHistoryRowProps) {
key={parameter.key}
title={parameter.title}
value={data[parameter.key as keyof HistoryData].toString()}
queryType={data.queryType}
/>
);
})}

View file

@ -1,6 +1,8 @@
import { uniqBy } from 'lodash';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import { createSelector } from 'reselect';
import Alert from 'Components/Alert';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
@ -24,12 +26,23 @@ import DeleteIndexerModal from 'Indexer/Delete/DeleteIndexerModal';
import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
import PrivacyLabel from 'Indexer/Index/Table/PrivacyLabel';
import Indexer, { IndexerCapabilities } from 'Indexer/Indexer';
import useIndexer from 'Indexer/useIndexer';
import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector';
import translate from 'Utilities/String/translate';
import IndexerHistory from './History/IndexerHistory';
import styles from './IndexerInfoModalContent.css';
const TABS = ['details', 'categories', 'history', 'stats'];
function createIndexerInfoItemSelector(indexerId: number) {
return createSelector(
createIndexerSelectorForHook(indexerId),
(indexer?: Indexer) => {
return {
indexer,
};
}
);
}
const tabs = ['details', 'categories', 'history', 'stats'];
interface IndexerInfoModalContentProps {
indexerId: number;
@ -38,7 +51,9 @@ interface IndexerInfoModalContentProps {
}
function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
const { indexerId, onModalClose, onCloneIndexerPress } = props;
const { indexerId, onCloneIndexerPress } = props;
const { indexer } = useSelector(createIndexerInfoItemSelector(indexerId));
const {
id,
@ -52,53 +67,53 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
protocol,
privacy,
capabilities = {} as IndexerCapabilities,
} = useIndexer(indexerId) as Indexer;
} = indexer as Indexer;
const [selectedTab, setSelectedTab] = useState(TABS[0]);
const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false);
const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] =
useState(false);
const handleTabSelect = useCallback(
(selectedIndex: number) => {
const selectedTab = TABS[selectedIndex];
setSelectedTab(selectedTab);
},
[setSelectedTab]
);
const handleEditIndexerPress = useCallback(() => {
setIsEditIndexerModalOpen(true);
}, [setIsEditIndexerModalOpen]);
const handleEditIndexerModalClose = useCallback(() => {
setIsEditIndexerModalOpen(false);
}, [setIsEditIndexerModalOpen]);
const handleDeleteIndexerPress = useCallback(() => {
setIsEditIndexerModalOpen(false);
setIsDeleteIndexerModalOpen(true);
}, [setIsDeleteIndexerModalOpen]);
const handleDeleteIndexerModalClose = useCallback(() => {
setIsDeleteIndexerModalOpen(false);
onModalClose();
}, [setIsDeleteIndexerModalOpen, onModalClose]);
const handleCloneIndexerPressWrapper = useCallback(() => {
onCloneIndexerPress(id);
onModalClose();
}, [id, onCloneIndexerPress, onModalClose]);
const { onModalClose } = props;
const baseUrl =
fields.find((field) => field.name === 'baseUrl')?.value ??
(Array.isArray(indexerUrls) ? indexerUrls[0] : undefined);
const indexerUrl = baseUrl?.replace(/(:\/\/)api\./, '$1');
const vipExpiration =
fields.find((field) => field.name === 'vipExpiration')?.value ?? undefined;
const [selectedTab, setSelectedTab] = useState(tabs[0]);
const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false);
const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] =
useState(false);
const onTabSelect = useCallback(
(index: number) => {
const selectedTab = tabs[index];
setSelectedTab(selectedTab);
},
[setSelectedTab]
);
const onEditIndexerPress = useCallback(() => {
setIsEditIndexerModalOpen(true);
}, [setIsEditIndexerModalOpen]);
const onEditIndexerModalClose = useCallback(() => {
setIsEditIndexerModalOpen(false);
}, [setIsEditIndexerModalOpen]);
const onDeleteIndexerPress = useCallback(() => {
setIsEditIndexerModalOpen(false);
setIsDeleteIndexerModalOpen(true);
}, [setIsDeleteIndexerModalOpen]);
const onDeleteIndexerModalClose = useCallback(() => {
setIsDeleteIndexerModalOpen(false);
onModalClose();
}, [setIsDeleteIndexerModalOpen, onModalClose]);
const onCloneIndexerPressWrapper = useCallback(() => {
onCloneIndexerPress(id);
onModalClose();
}, [id, onCloneIndexerPress, onModalClose]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{`${name}`}</ModalHeader>
@ -106,8 +121,8 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
<ModalBody>
<Tabs
className={styles.tabs}
selectedIndex={TABS.indexOf(selectedTab)}
onSelect={handleTabSelect}
selectedIndex={tabs.indexOf(selectedTab)}
onSelect={onTabSelect}
>
<TabList className={styles.tabList}>
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
@ -163,8 +178,10 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
{translate('IndexerSite')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
{indexerUrl ? (
<Link to={indexerUrl}>{indexerUrl}</Link>
{baseUrl ? (
<Link to={baseUrl}>
{baseUrl.replace(/(:\/\/)api\./, '$1')}
</Link>
) : (
'-'
)}
@ -348,16 +365,16 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={handleDeleteIndexerPress}
onPress={onDeleteIndexerPress}
>
{translate('Delete')}
</Button>
<Button onPress={handleCloneIndexerPressWrapper}>
<Button onPress={onCloneIndexerPressWrapper}>
{translate('Clone')}
</Button>
</div>
<div>
<Button onPress={handleEditIndexerPress}>{translate('Edit')}</Button>
<Button onPress={onEditIndexerPress}>{translate('Edit')}</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</div>
</ModalFooter>
@ -365,14 +382,14 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
<EditIndexerModalConnector
isOpen={isEditIndexerModalOpen}
id={id}
onModalClose={handleEditIndexerModalClose}
onDeleteIndexerPress={handleDeleteIndexerPress}
onModalClose={onEditIndexerModalClose}
onDeleteIndexerPress={onDeleteIndexerPress}
/>
<DeleteIndexerModal
isOpen={isDeleteIndexerModalOpen}
indexerId={id}
onModalClose={handleDeleteIndexerModalClose}
onModalClose={onDeleteIndexerModalClose}
/>
</ModalContent>
);

View file

@ -1,19 +0,0 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
export function createIndexerSelector(indexerId?: number) {
return createSelector(
(state: AppState) => state.indexers.itemMap,
(state: AppState) => state.indexers.items,
(itemMap, allIndexers) => {
return indexerId ? allIndexers[itemMap[indexerId]] : undefined;
}
);
}
function useIndexer(indexerId?: number) {
return useSelector(createIndexerSelector(indexerId));
}
export default useIndexer;

View file

@ -4,13 +4,11 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchApplications, fetchIndexerProxies, fetchNotifications } from 'Store/Actions/settingsActions';
import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import Tags from './Tags';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('tags', sortByProp('label')),
(state) => state.tags,
(tags) => {
const isFetching = tags.isFetching || tags.details.isFetching;
const error = tags.error || tags.details.error;

View file

@ -116,26 +116,6 @@ export const sortPredicates = {
vipExpiration: function({ fields = [] }) {
return fields.find((field) => field.name === 'vipExpiration')?.value ?? '';
},
minimumSeeders: function({ fields = [] }) {
return fields.find((field) => field.name === 'torrentBaseSettings.appMinimumSeeders')?.value ?? undefined;
},
seedRatio: function({ fields = [] }) {
return fields.find((field) => field.name === 'torrentBaseSettings.seedRatio')?.value ?? undefined;
},
seedTime: function({ fields = [] }) {
return fields.find((field) => field.name === 'torrentBaseSettings.seedTime')?.value ?? undefined;
},
packSeedTime: function({ fields = [] }) {
return fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime')?.value ?? undefined;
},
preferMagnetUrl: function({ fields = [] }) {
return fields.find((field) => field.name === 'torrentBaseSettings.preferMagnetUrl')?.value ?? undefined;
}
};

View file

@ -116,12 +116,6 @@ export const defaultState = {
isSortable: true,
isVisible: false
},
{
name: 'preferMagnetUrl',
label: () => translate('PreferMagnetUrl'),
isSortable: true,
isVisible: false
},
{
name: 'tags',
label: () => translate('Tags'),

View file

@ -110,6 +110,7 @@ export const defaultState = {
{
name: 'actions',
columnLabel: () => translate('Actions'),
isSortable: true,
isVisible: true,
isModifiable: false
}

View file

@ -116,7 +116,6 @@ class BackupRow extends Component {
<TableRowCell className={styles.actions}>
<IconButton
title={translate('RestoreBackup')}
name={icons.RESTORE}
onPress={this.onRestorePress}
/>
@ -139,9 +138,7 @@ class BackupRow extends Component {
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteBackup')}
message={translate('DeleteBackupMessageText', {
name
})}
message={translate('DeleteBackupMessageText', { name })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeletePress}
onCancel={this.onConfirmDeleteModalClose}

View file

@ -109,7 +109,7 @@ class Backups extends Component {
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>
{translate('BackupsLoadError')}
{translate('UnableToLoadBackups')}
</Alert>
}

View file

@ -14,7 +14,7 @@ import styles from './RestoreBackupModalContent.css';
function getErrorMessage(error) {
if (!error || !error.responseJSON || !error.responseJSON.message) {
return translate('ErrorRestoringBackup');
return 'Error restoring backup';
}
return error.responseJSON.message;
@ -146,9 +146,7 @@ class RestoreBackupModalContent extends Component {
<ModalBody>
{
!!id && translate('WouldYouLikeToRestoreBackup', {
name
})
!!id && `Would you like to restore the backup '${name}'?`
}
{
@ -205,7 +203,7 @@ class RestoreBackupModalContent extends Component {
<ModalFooter>
<div className={styles.additionalInfo}>
{translate('RestartReloadNote')}
Note: Prowlarr will automatically restart and reload the UI during the restore process.
</div>
<Button onPress={onModalClose}>
@ -218,7 +216,7 @@ class RestoreBackupModalContent extends Component {
isSpinning={isRestoring}
onPress={this.onRestorePress}
>
{translate('Restore')}
Restore
</SpinnerButton>
</ModalFooter>
</ModalContent>

View file

@ -84,7 +84,7 @@ function LogsTable(props) {
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{translate('NoEventsFound')}
No events found
</Alert>
}

View file

@ -28,7 +28,7 @@ function LogsTableDetailsModal(props) {
onModalClose={onModalClose}
>
<ModalHeader>
{translate('Details')}
Details
</ModalHeader>
<ModalBody>

View file

@ -1,8 +1,8 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
@ -77,15 +77,13 @@ class LogFiles extends Component {
<PageContentBody>
<Alert>
<div>
{translate('LogFilesLocation', {
location
})}
Log files are located in: {location}
</div>
{
currentLogView === 'Log Files' &&
<div>
<InlineMarkdown data={translate('TheLogLevelDefault')} />
The log level defaults to 'Info' and can be changed in <Link to="/settings/general">General Settings</Link>
</div>
}
</Alert>

View file

@ -7,7 +7,6 @@ import { executeCommand } from 'Store/Actions/commandActions';
import { fetchLogFiles } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import combinePath from 'Utilities/String/combinePath';
import translate from 'Utilities/String/translate';
import LogFiles from './LogFiles';
function createMapStateToProps() {
@ -30,7 +29,7 @@ function createMapStateToProps() {
isFetching,
items,
deleteFilesExecuting,
currentLogView: translate('LogFiles'),
currentLogView: 'Log Files',
location: combinePath(isWindows, appData, ['logs'])
};
}

View file

@ -4,7 +4,6 @@ import Link from 'Components/Link/Link';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import translate from 'Utilities/String/translate';
import styles from './LogFilesTableRow.css';
class LogFilesTableRow extends Component {
@ -33,7 +32,7 @@ class LogFilesTableRow extends Component {
target="_blank"
noRouter={true}
>
{translate('Download')}
Download
</Link>
</TableRowCell>
</TableRow>

View file

@ -4,7 +4,6 @@ import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import translate from 'Utilities/String/translate';
class LogsNavMenu extends Component {
@ -51,13 +50,13 @@ class LogsNavMenu extends Component {
<MenuItem
to={'/system/logs/files'}
>
{translate('LogFiles')}
Log Files
</MenuItem>
<MenuItem
to={'/system/logs/files/update'}
>
{translate('UpdaterLogFiles')}
Updater Log Files
</MenuItem>
</MenuContent>
</Menu>

View file

@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import styles from './UpdateChanges.css';
class UpdateChanges extends Component {
//
// Render
render() {
const {
title,
changes
} = this.props;
if (changes.length === 0) {
return null;
}
const uniqueChanges = [...new Set(changes)];
return (
<div>
<div className={styles.title}>{title}</div>
<ul>
{
uniqueChanges.map((change, index) => {
const checkChange = change.replace(/#\d{3,5}\b/g, (match, contents) => {
return `[${match}](https://github.com/Prowlarr/Prowlarr/issues/${match.substring(1)})`;
});
return (
<li key={index}>
<InlineMarkdown data={checkChange} />
</li>
);
})
}
</ul>
</div>
);
}
}
UpdateChanges.propTypes = {
title: PropTypes.string.isRequired,
changes: PropTypes.arrayOf(PropTypes.string)
};
export default UpdateChanges;

View file

@ -1,43 +0,0 @@
import React from 'react';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import styles from './UpdateChanges.css';
interface UpdateChangesProps {
title: string;
changes: string[];
}
function UpdateChanges(props: UpdateChangesProps) {
const { title, changes } = props;
if (changes.length === 0) {
return null;
}
const uniqueChanges = [...new Set(changes)];
return (
<div>
<div className={styles.title}>{title}</div>
<ul>
{uniqueChanges.map((change, index) => {
const checkChange = change.replace(
/#\d{3,5}\b/g,
(match) =>
`[${match}](https://github.com/Prowlarr/Prowlarr/issues/${match.substring(
1
)})`
);
return (
<li key={index}>
<InlineMarkdown data={checkChange} />
</li>
);
})}
</ul>
</div>
);
}
export default UpdateChanges;

View file

@ -0,0 +1,252 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons, kinds } from 'Helpers/Props';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import translate from 'Utilities/String/translate';
import UpdateChanges from './UpdateChanges';
import styles from './Updates.css';
class Updates extends Component {
//
// Render
render() {
const {
currentVersion,
isFetching,
isPopulated,
updatesError,
generalSettingsError,
items,
isInstallingUpdate,
updateMechanism,
isDocker,
updateMechanismMessage,
shortDateFormat,
longDateFormat,
timeFormat,
onInstallLatestPress
} = this.props;
const hasError = !!(updatesError || generalSettingsError);
const hasUpdates = isPopulated && !hasError && items.length > 0;
const noUpdates = isPopulated && !hasError && !items.length;
const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true });
const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
const externalUpdaterPrefix = 'Unable to update Prowlarr directly,';
const externalUpdaterMessages = {
external: 'Prowlarr is configured to use an external update mechanism',
apt: 'use apt to install the update',
docker: 'update the docker container to receive the update'
};
return (
<PageContent title={translate('Updates')}>
<PageContentBody>
{
!isPopulated && !hasError &&
<LoadingIndicator />
}
{
noUpdates &&
<Alert kind={kinds.INFO}>
{translate('NoUpdatesAreAvailable')}
</Alert>
}
{
hasUpdateToInstall &&
<div className={styles.messageContainer}>
{
(updateMechanism === 'builtIn' || updateMechanism === 'script') && !isDocker ?
<SpinnerButton
className={styles.updateAvailable}
kind={kinds.PRIMARY}
isSpinning={isInstallingUpdate}
onPress={onInstallLatestPress}
>
Install Latest
</SpinnerButton> :
<Fragment>
<Icon
name={icons.WARNING}
kind={kinds.WARNING}
size={30}
/>
<div className={styles.message}>
{externalUpdaterPrefix} <InlineMarkdown data={updateMechanismMessage || externalUpdaterMessages[updateMechanism] || externalUpdaterMessages.external} />
</div>
</Fragment>
}
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
</div>
}
{
noUpdateToInstall &&
<div className={styles.messageContainer}>
<Icon
className={styles.upToDateIcon}
name={icons.CHECK_CIRCLE}
size={30}
/>
<div className={styles.message}>
{translate('TheLatestVersionIsAlreadyInstalled')}
</div>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
</div>
}
{
hasUpdates &&
<div>
{
items.map((update) => {
const hasChanges = !!update.changes;
return (
<div
key={update.version}
className={styles.update}
>
<div className={styles.info}>
<div className={styles.version}>{update.version}</div>
<div className={styles.space}>&mdash;</div>
<div
className={styles.date}
title={formatDateTime(update.releaseDate, longDateFormat, timeFormat)}
>
{formatDate(update.releaseDate, shortDateFormat)}
</div>
{
update.branch === 'master' ?
null:
<Label
className={styles.label}
>
{update.branch}
</Label>
}
{
update.version === currentVersion ?
<Label
className={styles.label}
kind={kinds.SUCCESS}
title={formatDateTime(update.installedOn, longDateFormat, timeFormat)}
>
Currently Installed
</Label> :
null
}
{
update.version !== currentVersion && update.installedOn ?
<Label
className={styles.label}
kind={kinds.INVERSE}
title={formatDateTime(update.installedOn, longDateFormat, timeFormat)}
>
Previously Installed
</Label> :
null
}
</div>
{
!hasChanges &&
<div>
{translate('MaintenanceRelease')}
</div>
}
{
hasChanges &&
<div className={styles.changes}>
<UpdateChanges
title={translate('New')}
changes={update.changes.new}
/>
<UpdateChanges
title={translate('Fixed')}
changes={update.changes.fixed}
/>
</div>
}
</div>
);
})
}
</div>
}
{
!!updatesError &&
<div>
Failed to fetch updates
</div>
}
{
!!generalSettingsError &&
<div>
Failed to update settings
</div>
}
</PageContentBody>
</PageContent>
);
}
}
Updates.propTypes = {
currentVersion: PropTypes.string.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
updatesError: PropTypes.object,
generalSettingsError: PropTypes.object,
items: PropTypes.array.isRequired,
isInstallingUpdate: PropTypes.bool.isRequired,
isDocker: PropTypes.bool.isRequired,
updateMechanism: PropTypes.string,
updateMechanismMessage: PropTypes.string,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onInstallLatestPress: PropTypes.func.isRequired
};
export default Updates;

View file

@ -1,303 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
import { fetchUpdates } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { UpdateMechanism } from 'typings/Settings/General';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import translate from 'Utilities/String/translate';
import UpdateChanges from './UpdateChanges';
import styles from './Updates.css';
const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i;
function createUpdatesSelector() {
return createSelector(
(state: AppState) => state.system.updates,
(state: AppState) => state.settings.general,
(updates, generalSettings) => {
const { error: updatesError, items } = updates;
const isFetching = updates.isFetching || generalSettings.isFetching;
const isPopulated = updates.isPopulated && generalSettings.isPopulated;
return {
isFetching,
isPopulated,
updatesError,
generalSettingsError: generalSettings.error,
items,
updateMechanism: generalSettings.item.updateMechanism,
};
}
);
}
function Updates() {
const currentVersion = useSelector((state: AppState) => state.app.version);
const { packageUpdateMechanismMessage } = useSelector(
createSystemStatusSelector()
);
const { shortDateFormat, longDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const isInstallingUpdate = useSelector(
createCommandExecutingSelector(commandNames.APPLICATION_UPDATE)
);
const {
isFetching,
isPopulated,
updatesError,
generalSettingsError,
items,
updateMechanism,
} = useSelector(createUpdatesSelector());
const dispatch = useDispatch();
const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false);
const hasError = !!(updatesError || generalSettingsError);
const hasUpdates = isPopulated && !hasError && items.length > 0;
const noUpdates = isPopulated && !hasError && !items.length;
const externalUpdaterPrefix = translate('UpdateAppDirectlyLoadError');
const externalUpdaterMessages: Partial<Record<UpdateMechanism, string>> = {
external: translate('ExternalUpdater'),
apt: translate('AptUpdater'),
docker: translate('DockerUpdater'),
};
const { isMajorUpdate, hasUpdateToInstall } = useMemo(() => {
const majorVersion = parseInt(
currentVersion.match(VERSION_REGEX)?.[0] ?? '0'
);
const latestVersion = items[0]?.version;
const latestMajorVersion = parseInt(
latestVersion?.match(VERSION_REGEX)?.[0] ?? '0'
);
return {
isMajorUpdate: latestMajorVersion > majorVersion,
hasUpdateToInstall: items.some(
(update) => update.installable && update.latest
),
};
}, [currentVersion, items]);
const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
const handleInstallLatestPress = useCallback(() => {
if (isMajorUpdate) {
setIsMajorUpdateModalOpen(true);
} else {
dispatch(executeCommand({ name: commandNames.APPLICATION_UPDATE }));
}
}, [isMajorUpdate, setIsMajorUpdateModalOpen, dispatch]);
const handleInstallLatestMajorVersionPress = useCallback(() => {
setIsMajorUpdateModalOpen(false);
dispatch(
executeCommand({
name: commandNames.APPLICATION_UPDATE,
installMajorUpdate: true,
})
);
}, [setIsMajorUpdateModalOpen, dispatch]);
const handleCancelMajorVersionPress = useCallback(() => {
setIsMajorUpdateModalOpen(false);
}, [setIsMajorUpdateModalOpen]);
useEffect(() => {
dispatch(fetchUpdates());
dispatch(fetchGeneralSettings());
}, [dispatch]);
return (
<PageContent title={translate('Updates')}>
<PageContentBody>
{isPopulated || hasError ? null : <LoadingIndicator />}
{noUpdates ? (
<Alert kind={kinds.INFO}>{translate('NoUpdatesAreAvailable')}</Alert>
) : null}
{hasUpdateToInstall ? (
<div className={styles.messageContainer}>
{updateMechanism === 'builtIn' || updateMechanism === 'script' ? (
<SpinnerButton
kind={kinds.PRIMARY}
isSpinning={isInstallingUpdate}
onPress={handleInstallLatestPress}
>
{translate('InstallLatest')}
</SpinnerButton>
) : (
<>
<Icon name={icons.WARNING} kind={kinds.WARNING} size={30} />
<div className={styles.message}>
{externalUpdaterPrefix}{' '}
<InlineMarkdown
data={
packageUpdateMechanismMessage ||
externalUpdaterMessages[updateMechanism] ||
externalUpdaterMessages.external
}
/>
</div>
</>
)}
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
</div>
) : null}
{noUpdateToInstall && (
<div className={styles.messageContainer}>
<Icon
className={styles.upToDateIcon}
name={icons.CHECK_CIRCLE}
size={30}
/>
<div className={styles.message}>{translate('OnLatestVersion')}</div>
{isFetching && (
<LoadingIndicator className={styles.loading} size={20} />
)}
</div>
)}
{hasUpdates && (
<div>
{items.map((update) => {
return (
<div key={update.version} className={styles.update}>
<div className={styles.info}>
<div className={styles.version}>{update.version}</div>
<div className={styles.space}>&mdash;</div>
<div
className={styles.date}
title={formatDateTime(
update.releaseDate,
longDateFormat,
timeFormat
)}
>
{formatDate(update.releaseDate, shortDateFormat)}
</div>
{update.branch === 'master' ? null : (
<Label className={styles.label}>{update.branch}</Label>
)}
{update.version === currentVersion ? (
<Label
className={styles.label}
kind={kinds.SUCCESS}
title={formatDateTime(
update.installedOn,
longDateFormat,
timeFormat
)}
>
{translate('CurrentlyInstalled')}
</Label>
) : null}
{update.version !== currentVersion && update.installedOn ? (
<Label
className={styles.label}
kind={kinds.INVERSE}
title={formatDateTime(
update.installedOn,
longDateFormat,
timeFormat
)}
>
{translate('PreviouslyInstalled')}
</Label>
) : null}
</div>
{update.changes ? (
<div>
<UpdateChanges
title={translate('New')}
changes={update.changes.new}
/>
<UpdateChanges
title={translate('Fixed')}
changes={update.changes.fixed}
/>
</div>
) : (
<div>{translate('MaintenanceRelease')}</div>
)}
</div>
);
})}
</div>
)}
{updatesError ? (
<Alert kind={kinds.WARNING}>
{translate('FailedToFetchUpdates')}
</Alert>
) : null}
{generalSettingsError ? (
<Alert kind={kinds.DANGER}>
{translate('FailedToFetchSettings')}
</Alert>
) : null}
<ConfirmModal
isOpen={isMajorUpdateModalOpen}
kind={kinds.WARNING}
title={translate('InstallMajorVersionUpdate')}
message={
<div>
<div>{translate('InstallMajorVersionUpdateMessage')}</div>
<div>
<InlineMarkdown
data={translate('InstallMajorVersionUpdateMessageLink', {
domain: 'prowlarr.com',
url: 'https://prowlarr.com/#downloads',
})}
/>
</div>
</div>
}
confirmLabel={translate('Install')}
onConfirm={handleInstallLatestMajorVersionPress}
onCancel={handleCancelMajorVersionPress}
/>
</PageContentBody>
</PageContent>
);
}
export default Updates;

View file

@ -0,0 +1,101 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
import { fetchUpdates } from 'Store/Actions/systemActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import Updates from './Updates';
function createMapStateToProps() {
return createSelector(
(state) => state.app.version,
createSystemStatusSelector(),
(state) => state.system.updates,
(state) => state.settings.general,
createUISettingsSelector(),
createSystemStatusSelector(),
createCommandExecutingSelector(commandNames.APPLICATION_UPDATE),
(
currentVersion,
status,
updates,
generalSettings,
uiSettings,
systemStatus,
isInstallingUpdate
) => {
const {
error: updatesError,
items
} = updates;
const isFetching = updates.isFetching || generalSettings.isFetching;
const isPopulated = updates.isPopulated && generalSettings.isPopulated;
return {
currentVersion,
isFetching,
isPopulated,
updatesError,
generalSettingsError: generalSettings.error,
items,
isInstallingUpdate,
isDocker: systemStatus.isDocker,
updateMechanism: generalSettings.item.updateMechanism,
updateMechanismMessage: status.packageUpdateMechanismMessage,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat
};
}
);
}
const mapDispatchToProps = {
dispatchFetchUpdates: fetchUpdates,
dispatchFetchGeneralSettings: fetchGeneralSettings,
dispatchExecuteCommand: executeCommand
};
class UpdatesConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchUpdates();
this.props.dispatchFetchGeneralSettings();
}
//
// Listeners
onInstallLatestPress = () => {
this.props.dispatchExecuteCommand({ name: commandNames.APPLICATION_UPDATE });
};
//
// Render
render() {
return (
<Updates
onInstallLatestPress={this.onInstallLatestPress}
{...this.props}
/>
);
}
}
UpdatesConnector.propTypes = {
dispatchFetchUpdates: PropTypes.func.isRequired,
dispatchFetchGeneralSettings: PropTypes.func.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector);

View file

@ -17,7 +17,7 @@ export async function fetchTranslations(): Promise<boolean> {
translations = data.Strings;
resolve(true);
} catch {
} catch (error) {
resolve(false);
}
});

View file

@ -1,9 +1,7 @@
let i = 0;
/**
* @deprecated Use React's useId() instead
* @returns An HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022)
*/
// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022)
export default function getUniqueElementId() {
return `id-${i++}`;
}

View file

@ -1,12 +1,5 @@
import ModelBase from 'App/ModelBase';
export type HistoryQueryType =
| 'search'
| 'tvsearch'
| 'movie'
| 'book'
| 'music';
export interface HistoryData {
source: string;
host: string;
@ -14,7 +7,7 @@ export interface HistoryData {
offset: number;
elapsedTime: number;
query: string;
queryType: HistoryQueryType;
queryType: string;
}
interface History extends ModelBase {

View file

@ -1,45 +0,0 @@
export type UpdateMechanism =
| 'builtIn'
| 'script'
| 'external'
| 'apt'
| 'docker';
export default interface General {
bindAddress: string;
port: number;
sslPort: number;
enableSsl: boolean;
launchBrowser: boolean;
authenticationMethod: string;
authenticationRequired: string;
analyticsEnabled: boolean;
username: string;
password: string;
passwordConfirmation: string;
logLevel: string;
consoleLogLevel: string;
branch: string;
apiKey: string;
sslCertPath: string;
sslCertPassword: string;
urlBase: string;
instanceName: string;
applicationUrl: string;
updateAutomatically: boolean;
updateMechanism: UpdateMechanism;
updateScriptPath: string;
proxyEnabled: boolean;
proxyType: string;
proxyHostname: string;
proxyPort: number;
proxyUsername: string;
proxyPassword: string;
proxyBypassFilter: string;
proxyBypassLocalAddresses: boolean;
certificateValidation: string;
backupFolder: string;
backupInterval: number;
backupRetention: number;
id: number;
}

View file

@ -22,7 +22,6 @@ interface SystemStatus {
osVersion: string;
packageAuthor: string;
packageUpdateMechanism: string;
packageUpdateMechanismMessage: string;
packageVersion: string;
runtimeName: string;
runtimeVersion: string;

View file

@ -1,4 +1,4 @@
export default interface UiSettings {
export interface UiSettings {
theme: 'auto' | 'dark' | 'light';
showRelativeDates: boolean;
shortDateFormat: string;

View file

@ -23,34 +23,35 @@
"defaults"
],
"dependencies": {
"@fortawesome/fontawesome-free": "6.7.1",
"@fortawesome/fontawesome-svg-core": "6.7.1",
"@fortawesome/free-regular-svg-icons": "6.7.1",
"@fortawesome/free-solid-svg-icons": "6.7.1",
"@fortawesome/fontawesome-free": "6.6.0",
"@fortawesome/fontawesome-svg-core": "6.6.0",
"@fortawesome/free-regular-svg-icons": "6.6.0",
"@fortawesome/free-solid-svg-icons": "6.6.0",
"@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",
"@sentry/browser": "7.100.0",
"@sentry/integrations": "7.100.0",
"@types/node": "18.19.31",
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",
"chart.js": "4.4.4",
"classnames": "2.5.1",
"chart.js": "4.4.3",
"classnames": "2.3.2",
"clipboard": "2.0.11",
"connected-react-router": "6.9.3",
"copy-to-clipboard": "3.3.3",
"element-class": "0.2.2",
"filesize": "10.1.6",
"filesize": "10.0.7",
"history": "4.10.1",
"https-browserify": "1.0.0",
"jdu": "1.0.0",
"jquery": "3.7.1",
"lodash": "4.17.21",
"mobile-detect": "1.4.5",
"moment": "2.30.1",
"moment": "2.29.4",
"mousetrap": "1.6.5",
"normalize.css": "8.0.1",
"prop-types": "15.8.1",
"qs": "6.13.0",
"qs": "6.11.1",
"react": "17.0.2",
"react-addons-shallow-compare": "15.6.3",
"react-async-script": "1.2.0",
@ -64,6 +65,7 @@
"react-dom": "17.0.2",
"react-focus-lock": "2.9.4",
"react-google-recaptcha": "2.1.0",
"react-lazyload": "3.2.0",
"react-measure": "1.4.7",
"react-popper": "1.3.7",
"react-redux": "7.2.4",
@ -73,55 +75,55 @@
"react-text-truncate": "0.19.0",
"react-use-measure": "2.1.1",
"react-virtualized": "9.21.1",
"react-window": "1.8.10",
"react-window": "1.8.8",
"redux": "4.2.1",
"redux-actions": "2.6.5",
"redux-batched-actions": "0.5.0",
"redux-localstorage": "0.4.1",
"redux-thunk": "2.4.2",
"reselect": "4.1.8",
"reselect": "4.1.7",
"stacktrace-js": "2.0.2",
"typescript": "5.7.2"
"typescript": "5.1.6"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@babel/eslint-parser": "7.25.9",
"@babel/plugin-proposal-export-default-from": "7.25.9",
"@babel/core": "7.25.2",
"@babel/eslint-parser": "7.25.1",
"@babel/plugin-proposal-export-default-from": "7.24.7",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@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",
"@babel/preset-env": "7.25.3",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@types/lodash": "4.14.194",
"@types/react-document-title": "2.0.9",
"@types/react-router-dom": "5.3.3",
"@types/react-text-truncate": "0.19.0",
"@types/react-window": "1.8.8",
"@types/webpack-livereload-plugin": "2.3.6",
"@typescript-eslint/eslint-plugin": "8.18.1",
"@typescript-eslint/parser": "8.18.1",
"@types/react-text-truncate": "0.14.1",
"@types/react-window": "1.8.5",
"@types/webpack-livereload-plugin": "2.3.3",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"are-you-es5": "2.1.2",
"autoprefixer": "10.4.20",
"babel-loader": "9.2.1",
"babel-loader": "9.1.3",
"babel-plugin-inline-classnames": "2.0.1",
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
"core-js": "3.39.0",
"core-js": "3.38.0",
"css-loader": "6.7.3",
"css-modules-typescript-loader": "4.0.1",
"eslint": "8.57.1",
"eslint": "8.57.0",
"eslint-config-prettier": "8.10.0",
"eslint-plugin-filenames": "1.3.2",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.37.1",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-simple-import-sort": "12.1.1",
"eslint-plugin-react": "7.34.1",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-simple-import-sort": "12.1.0",
"file-loader": "6.2.0",
"filemanager-webpack-plugin": "8.0.0",
"fork-ts-checker-webpack-plugin": "8.0.0",
"html-webpack-plugin": "5.6.0",
"html-webpack-plugin": "5.5.1",
"loader-utils": "^3.2.1",
"mini-css-extract-plugin": "2.9.1",
"postcss": "8.4.47",
"mini-css-extract-plugin": "2.7.5",
"postcss": "8.4.41",
"postcss-color-function": "4.1.0",
"postcss-loader": "7.3.0",
"postcss-mixins": "9.0.4",
@ -130,15 +132,17 @@
"postcss-url": "10.1.3",
"prettier": "2.8.8",
"require-nocache": "1.0.0",
"rimraf": "6.0.1",
"rimraf": "4.4.1",
"run-sequence": "2.2.1",
"streamqueue": "1.1.2",
"style-loader": "3.3.2",
"stylelint": "15.6.1",
"stylelint-order": "6.0.4",
"terser-webpack-plugin": "5.3.10",
"ts-loader": "9.5.1",
"stylelint-order": "6.0.3",
"terser-webpack-plugin": "5.3.9",
"ts-loader": "9.4.2",
"typescript-plugin-css-modules": "5.0.1",
"url-loader": "4.1.1",
"webpack": "5.95.0",
"webpack": "5.89.0",
"webpack-cli": "5.1.4",
"webpack-livereload-plugin": "3.0.2"
}

View file

@ -221,7 +221,7 @@
<PropertyGroup Condition="'$(IsOSX)' == 'true' and
'$(RuntimeIdentifier)' == ''">
<_UsingDefaultRuntimeIdentifier>true</_UsingDefaultRuntimeIdentifier>
<RuntimeIdentifier>osx-$(Architecture)</RuntimeIdentifier>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
</PropertyGroup>
</Project>

View file

@ -4,7 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Selenium.Support" Version="4.1.0" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="134.0.6998.16500" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="99.0.4844.5100" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NzbDrone.Test.Common\Prowlarr.Test.Common.csproj" />

View file

@ -21,28 +21,9 @@ 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

@ -29,8 +29,6 @@ namespace NzbDrone.Common.Test.InstrumentationTests
[TestCase(@"https://beyond-hd.me/torrent/download/the-next-365-days-2022-2160p-nf-web-dl-dual-ddp-51-dovi-hdr-hevc-apex.225146.2b51db35e1912ffc138825a12b9933d2")]
[TestCase(@"https://anthelion.me/api.php?api_key=2b51db35e1910123321025a12b9933d2&o=json&t=movie&q=&tmdb=&imdb=&cat=&limit=100&offset=0")]
[TestCase(@"https://avistaz.to/api/v1/jackett/auth: username=mySecret&password=mySecret&pid=mySecret")]
[TestCase(@"https://www.sharewood.tv/api/2b51db35e1910123321025a12b9933d2/last-torrents")]
[TestCase(@"https://example.org/rss/torrents?rsskey=2b51db35e1910123321025a12b9933d2&search=")]
// Indexer and Download Client Responses

View file

@ -133,16 +133,11 @@ namespace NzbDrone.Common.Test
[TestCase(@"C:\test\", @"C:\Test\mydir")]
[TestCase(@"C:\test", @"C:\Test\mydir\")]
public void windows_path_should_be_parent(string parentPath, string childPath)
public void path_should_be_parent_on_windows_only(string parentPath, string childPath)
{
parentPath.IsParentPath(childPath).Should().Be(true);
}
var expectedResult = OsInfo.IsWindows;
[TestCase("/test", "/test/mydir/")]
[TestCase("/test/", "/test/mydir")]
public void posix_path_should_be_parent(string parentPath, string childPath)
{
parentPath.IsParentPath(childPath).Should().Be(true);
parentPath.IsParentPath(childPath).Should().Be(expectedResult);
}
[TestCase(@"C:\Test\mydir", @"C:\Test")]
@ -150,57 +145,20 @@ namespace NzbDrone.Common.Test
[TestCase(@"C:\", null)]
[TestCase(@"\\server\share", null)]
[TestCase(@"\\server\share\test", @"\\server\share")]
public void windows_path_should_return_parent(string path, string parentPath)
public void path_should_return_parent_windows(string path, string parentPath)
{
WindowsOnly();
path.GetParentPath().Should().Be(parentPath);
}
[TestCase(@"/", null)]
[TestCase(@"/test", "/")]
[TestCase(@"/test/tv", "/test")]
public void unix_path_should_return_parent(string path, string parentPath)
public void path_should_return_parent_mono(string path, string parentPath)
{
PosixOnly();
path.GetParentPath().Should().Be(parentPath);
}
[TestCase(@"C:\Test\mydir", "Test")]
[TestCase(@"C:\Test\", @"C:\")]
[TestCase(@"C:\Test", @"C:\")]
[TestCase(@"C:\", null)]
[TestCase(@"\\server\share", null)]
[TestCase(@"\\server\share\test", @"\\server\share")]
public void path_should_return_parent_name_windows(string path, string parentPath)
{
path.GetParentName().Should().Be(parentPath);
}
[TestCase(@"/", null)]
[TestCase(@"/test", "/")]
[TestCase(@"/test/tv", "test")]
public void path_should_return_parent_name_mono(string path, string parentPath)
{
path.GetParentName().Should().Be(parentPath);
}
[TestCase(@"C:\Test\mydir", "mydir")]
[TestCase(@"C:\Test\", "Test")]
[TestCase(@"C:\Test", "Test")]
[TestCase(@"C:\", "C:\\")]
[TestCase(@"\\server\share", @"\\server\share")]
[TestCase(@"\\server\share\test", "test")]
public void path_should_return_directory_name_windows(string path, string parentPath)
{
path.GetDirectoryName().Should().Be(parentPath);
}
[TestCase(@"/", "/")]
[TestCase(@"/test", "test")]
[TestCase(@"/test/tv", "tv")]
public void path_should_return_directory_name_mono(string path, string parentPath)
{
path.GetDirectoryName().Should().Be(parentPath);
}
[Test]
public void path_should_return_parent_for_oversized_path()
{
@ -208,7 +166,7 @@ namespace NzbDrone.Common.Test
// This test will fail on Windows if long path support is not enabled: https://www.howtogeek.com/266621/how-to-make-windows-10-accept-file-paths-over-260-characters/
// It will also fail if the app isn't configured to use long path (such as resharper): https://blogs.msdn.microsoft.com/jeremykuhne/2016/07/30/net-4-6-2-and-long-paths-on-windows-10/
var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories".AsOsAgnostic();
var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories".AsOsAgnostic();
var parentPath = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing".AsOsAgnostic();
path.GetParentPath().Should().Be(parentPath);
@ -392,46 +350,5 @@ namespace NzbDrone.Common.Test
PosixOnly();
path.AsOsAgnostic().IsPathValid(PathValidationType.CurrentOs).Should().BeFalse();
}
[TestCase(@"C:\", @"C:\")]
[TestCase(@"C:\\", @"C:\")]
[TestCase(@"C:\Test", @"C:\Test")]
[TestCase(@"C:\Test\", @"C:\Test")]
[TestCase(@"\\server\share", @"\\server\share")]
[TestCase(@"\\server\share\", @"\\server\share")]
public void windows_path_should_return_clean_path(string path, string cleanPath)
{
path.GetCleanPath().Should().Be(cleanPath);
}
[TestCase("/", "/")]
[TestCase("//", "/")]
[TestCase("/test", "/test")]
[TestCase("/test/", "/test")]
[TestCase("/test//", "/test")]
public void unix_path_should_return_clean_path(string path, string cleanPath)
{
path.GetCleanPath().Should().Be(cleanPath);
}
[TestCase(@"C:\Test\", @"C:\Test\Series Title", "Series Title")]
[TestCase(@"C:\Test\", @"C:\Test\Collection\Series Title", @"Collection\Series Title")]
[TestCase(@"C:\Test\mydir\", @"C:\Test\mydir\Collection\Series Title", @"Collection\Series Title")]
[TestCase(@"\\server\share", @"\\server\share\Series Title", "Series Title")]
[TestCase(@"\\server\share\mydir\", @"\\server\share\mydir\/Collection\Series Title", @"Collection\Series Title")]
public void windows_path_should_return_relative_path(string parentPath, string childPath, string relativePath)
{
parentPath.GetRelativePath(childPath).Should().Be(relativePath);
}
[TestCase(@"/test", "/test/Series Title", "Series Title")]
[TestCase(@"/test/", "/test/Collection/Series Title", "Collection/Series Title")]
[TestCase(@"/test/mydir", "/test/mydir/Series Title", "Series Title")]
[TestCase(@"/test/mydir/", "/test/mydir/Collection/Series Title", "Collection/Series Title")]
[TestCase(@"/test/mydir/", @"/test/mydir/\Collection/Series Title", "Collection/Series Title")]
public void unix_path_should_return_relative_path(string parentPath, string childPath, string relativePath)
{
parentPath.GetRelativePath(childPath).Should().Be(relativePath);
}
}
}

View file

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

View file

@ -189,25 +189,6 @@ namespace NzbDrone.Common.Disk
}
var fi = new FileInfo(path);
try
{
// If the file is a symlink, resolve the target path and get the size of the target file.
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
{
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

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Common.Disk
@ -10,8 +9,6 @@ namespace NzbDrone.Common.Disk
private readonly string _path;
private readonly OsPathKind _kind;
private static readonly Regex UncPathRegex = new Regex(@"(?<unc>^\\\\(?:\?\\UNC\\)?[^\\]+\\[^\\]+)(?:\\|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public OsPath(string path)
{
if (path == null)
@ -99,29 +96,6 @@ namespace NzbDrone.Common.Disk
return path;
}
private static string TrimTrailingSlash(string path, OsPathKind kind)
{
switch (kind)
{
case OsPathKind.Windows when !path.EndsWith(":\\"):
while (!path.EndsWith(":\\") && path.EndsWith('\\'))
{
path = path[..^1];
}
return path;
case OsPathKind.Unix when path != "/":
while (path != "/" && path.EndsWith('/'))
{
path = path[..^1];
}
return path;
}
return path;
}
public OsPathKind Kind => _kind;
public bool IsWindowsPath => _kind == OsPathKind.Windows;
@ -156,19 +130,7 @@ namespace NzbDrone.Common.Disk
if (index == -1)
{
return Null;
}
var rootLength = GetRootLength();
if (rootLength == _path.Length)
{
return Null;
}
if (rootLength > index + 1)
{
return new OsPath(_path.Substring(0, rootLength));
return new OsPath(null);
}
return new OsPath(_path.Substring(0, index), _kind).AsDirectory();
@ -177,8 +139,6 @@ namespace NzbDrone.Common.Disk
public string FullPath => _path;
public string PathWithoutTrailingSlash => TrimTrailingSlash(_path, _kind);
public string FileName
{
get
@ -201,29 +161,6 @@ namespace NzbDrone.Common.Disk
}
}
public string Name
{
// Meant to behave similar to DirectoryInfo.Name
get
{
var index = GetFileNameIndex();
if (index == -1)
{
return PathWithoutTrailingSlash;
}
var rootLength = GetRootLength();
if (rootLength > index + 1)
{
return _path.Substring(0, rootLength);
}
return TrimTrailingSlash(_path.Substring(index).TrimStart('/', '\\'), _kind);
}
}
public bool IsValid => _path.IsPathValid(PathValidationType.CurrentOs);
private int GetFileNameIndex()
@ -253,50 +190,11 @@ namespace NzbDrone.Common.Disk
return index;
}
private int GetRootLength()
{
if (!IsRooted)
{
return 0;
}
if (_kind == OsPathKind.Unix)
{
return 1;
}
if (_kind == OsPathKind.Windows)
{
if (HasWindowsDriveLetter(_path))
{
return 3;
}
var uncMatch = UncPathRegex.Match(_path);
// \\?\UNC\server\share\ or \\server\share
if (uncMatch.Success)
{
return uncMatch.Groups["unc"].Length;
}
// \\?\C:\
if (_path.StartsWith(@"\\?\"))
{
return 7;
}
}
return 0;
}
private string[] GetFragments()
{
return _path.Split(new char[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries);
}
public static OsPath Null => new (null);
public override string ToString()
{
return _path;
@ -369,11 +267,6 @@ namespace NzbDrone.Common.Disk
}
public bool Equals(OsPath other)
{
return Equals(other, false);
}
public bool Equals(OsPath other, bool ignoreTrailingSlash)
{
if (ReferenceEquals(other, null))
{
@ -385,8 +278,8 @@ namespace NzbDrone.Common.Disk
return true;
}
var left = ignoreTrailingSlash ? PathWithoutTrailingSlash : _path;
var right = ignoreTrailingSlash ? other.PathWithoutTrailingSlash : other._path;
var left = _path;
var right = other._path;
if (Kind == OsPathKind.Windows || other.Kind == OsPathKind.Windows)
{

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using NLog;
@ -24,25 +25,22 @@ namespace NzbDrone.Common.EnvironmentInfo
static OsInfo()
{
if (OperatingSystem.IsWindows())
var platform = Environment.OSVersion.Platform;
switch (platform)
{
Os = Os.Windows;
}
else if (OperatingSystem.IsMacOS())
{
Os = Os.Osx;
}
else if (OperatingSystem.IsFreeBSD())
{
Os = Os.Bsd;
}
else
{
#if ISMUSL
Os = Os.LinuxMusl;
#else
Os = Os.Linux;
#endif
case PlatformID.Win32NT:
{
Os = Os.Windows;
break;
}
case PlatformID.MacOSX:
case PlatformID.Unix:
{
Os = GetPosixFlavour();
break;
}
}
}
@ -86,6 +84,59 @@ namespace NzbDrone.Common.EnvironmentInfo
IsDocker = true;
}
}
private static Os GetPosixFlavour()
{
var output = RunAndCapture("uname", "-s");
if (output.StartsWith("Darwin"))
{
return Os.Osx;
}
else if (output.Contains("BSD"))
{
return Os.Bsd;
}
else
{
#if ISMUSL
return Os.LinuxMusl;
#else
return Os.Linux;
#endif
}
}
private static string RunAndCapture(string filename, string args)
{
var processStartInfo = new ProcessStartInfo
{
FileName = filename,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true
};
var output = string.Empty;
try
{
using (var p = Process.Start(processStartInfo))
{
// To avoid deadlocks, always read the output stream first and then wait.
output = p.StandardOutput.ReadToEnd();
p.WaitForExit(1000);
}
}
catch (Exception)
{
output = string.Empty;
}
return output;
}
}
public interface IOsInfo

View file

@ -39,24 +39,18 @@ 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)
var isLinkLocal = ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254;
bool IsLinkLocal() => ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254;
// Class A private range: 10.0.0.0 10.255.255.255 (10.0.0.0/8)
var isClassA = ipv4Bytes[0] == 10;
bool IsClassA() => ipv4Bytes[0] == 10;
// Class B private range: 172.16.0.0 172.31.255.255 (172.16.0.0/12)
var isClassB = ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31;
bool 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)
var isClassC = ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168;
bool IsClassC() => ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168;
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;
return IsLinkLocal() || IsClassA() || IsClassC() || IsClassB();
}
}
}

View file

@ -25,6 +25,8 @@ namespace NzbDrone.Common.Extensions
private static readonly string UPDATE_CLIENT_FOLDER_NAME = "Prowlarr.Update" + Path.DirectorySeparatorChar;
private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar;
private static readonly Regex PARENT_PATH_END_SLASH_REGEX = new Regex(@"(?<!:)\\$", RegexOptions.Compiled);
public static string CleanFilePath(this string path)
{
if (path.IsNotNullOrWhiteSpace())
@ -85,50 +87,55 @@ namespace NzbDrone.Common.Extensions
throw new NotParentException("{0} is not a child of {1}", childPath, parentPath);
}
return childPath.Substring(parentPath.Length).Trim('\\', '/');
return childPath.Substring(parentPath.Length).Trim(Path.DirectorySeparatorChar);
}
public static string GetParentPath(this string childPath)
{
var path = new OsPath(childPath).Directory;
var cleanPath = OsInfo.IsWindows
? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "")
: childPath.TrimEnd(Path.DirectorySeparatorChar);
return path == OsPath.Null ? null : path.PathWithoutTrailingSlash;
}
if (cleanPath.IsNullOrWhiteSpace())
{
return null;
}
public static string GetParentName(this string childPath)
{
var path = new OsPath(childPath).Directory;
return path == OsPath.Null ? null : path.Name;
}
public static string GetDirectoryName(this string childPath)
{
var path = new OsPath(childPath);
return path == OsPath.Null ? null : path.Name;
return Directory.GetParent(cleanPath)?.FullName;
}
public static string GetCleanPath(this string path)
{
var osPath = new OsPath(path);
var cleanPath = OsInfo.IsWindows
? PARENT_PATH_END_SLASH_REGEX.Replace(path, "")
: path.TrimEnd(Path.DirectorySeparatorChar);
return osPath == OsPath.Null ? null : osPath.PathWithoutTrailingSlash;
return cleanPath;
}
public static bool IsParentPath(this string parentPath, string childPath)
{
var parent = new OsPath(parentPath);
var child = new OsPath(childPath);
while (child.Directory != OsPath.Null)
if (parentPath != "/" && !parentPath.EndsWith(":\\"))
{
if (child.Directory.Equals(parent, true))
parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar);
}
if (childPath != "/" && !parentPath.EndsWith(":\\"))
{
childPath = childPath.TrimEnd(Path.DirectorySeparatorChar);
}
var parent = new DirectoryInfo(parentPath);
var child = new DirectoryInfo(childPath);
while (child.Parent != null)
{
if (child.Parent.FullName.Equals(parent.FullName, DiskProviderBase.PathStringComparison))
{
return true;
}
child = child.Directory;
child = child.Parent;
}
return false;
@ -143,14 +150,14 @@ namespace NzbDrone.Common.Extensions
return false;
}
if (path.Trim() != path)
{
return false;
}
// Only check for leading or trailing spaces for path when running on Windows.
if (OsInfo.IsWindows)
{
if (path.Trim() != path)
{
return false;
}
var directoryInfo = new DirectoryInfo(path);
while (directoryInfo != null)

View file

@ -109,7 +109,7 @@ namespace NzbDrone.Common.Http
if (response.HasHttpRedirect && !RuntimeInfo.IsProduction)
{
_logger.Error("Server requested a redirect to [{0}] while in developer mode. Update the request URL to avoid this redirect.", response.RedirectUrl);
_logger.Error("Server requested a redirect to [{0}] while in developer mode. Update the request URL to avoid this redirect.", response.Headers["Location"]);
}
if (!request.SuppressHttpError && response.HasHttpError && (request.SuppressHttpErrorStatusCodes == null || !request.SuppressHttpErrorStatusCodes.Contains(response.StatusCode)))

View file

@ -10,7 +10,7 @@ namespace NzbDrone.Common.Instrumentation
private static readonly Regex[] CleansingRules =
{
// Url
new (@"(?<=[?&: ;])(apikey|api_key|(?:(?:access|api)[-_]?)?token|pass(?:key|wd)?|auth|authkey|rsskey|user|u?id|api|[a-z_]*apikey|account|pid|pwd)=(?<secret>[^&=""]+?)(?=[ ""&=]|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new (@"(?<=[?&: ;])(apikey|api_key|(?:(?:access|api)[-_]?)?token|pass(?:key|wd)?|auth|authkey|user|u?id|api|[a-z_]*apikey|account|pid|pwd)=(?<secret>[^&=""]+?)(?=[ ""&=]|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new (@"(?<=[?& ;])[^=]*?(_?(?<!use|get_)token|username|passwo?rd)=(?<secret>[^&=]+?)(?= |&|$|;)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new (@"rss(24h)?\.torrentleech\.org/(?!rss)(?<secret>[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new (@"torrentleech\.org/rss/download/[0-9]+/(?<secret>[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
@ -21,7 +21,6 @@ namespace NzbDrone.Common.Instrumentation
new (@"(?<=authkey = "")(?<secret>[^&=]+?)(?="")", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new (@"(?<=beyond-hd\.[a-z]+/api/torrents/)(?<secret>[^&=][a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new (@"(?<=beyond-hd\.[a-z]+/torrent/download/[\w\d-]+[.]\d+[.])(?<secret>[a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
new (@"(?:sharewood)\.[a-z]{2,3}/api/(?<secret>[a-z0-9]{16,})/", RegexOptions.Compiled | RegexOptions.IgnoreCase),
// UNIT3D
new (@"(?<=[a-z0-9-]+\.[a-z]+/torrent/download/\d+\.)(?<secret>[^&=][a-z0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),

View file

@ -1,21 +0,0 @@
using System.Text;
using NLog;
using NLog.Layouts.ClefJsonLayout;
using NzbDrone.Common.EnvironmentInfo;
namespace NzbDrone.Common.Instrumentation;
public class CleansingClefLogLayout : CompactJsonLayout
{
protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target)
{
base.RenderFormattedMessage(logEvent, target);
if (RuntimeInfo.IsProduction)
{
var result = CleanseLogMessage.Cleanse(target.ToString());
target.Clear();
target.Append(result);
}
}
}

View file

@ -1,26 +0,0 @@
using System.Text;
using NLog;
using NLog.Layouts;
using NzbDrone.Common.EnvironmentInfo;
namespace NzbDrone.Common.Instrumentation;
public class CleansingConsoleLogLayout : SimpleLayout
{
public CleansingConsoleLogLayout(string format)
: base(format)
{
}
protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target)
{
base.RenderFormattedMessage(logEvent, target);
if (RuntimeInfo.IsProduction)
{
var result = CleanseLogMessage.Cleanse(target.ToString());
target.Clear();
target.Append(result);
}
}
}

View file

@ -4,7 +4,7 @@ using NLog.Targets;
namespace NzbDrone.Common.Instrumentation
{
public class CleansingFileTarget : FileTarget
public class NzbDroneFileTarget : FileTarget
{
protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target)
{

View file

@ -3,6 +3,7 @@ using System.Diagnostics;
using System.IO;
using NLog;
using NLog.Config;
using NLog.Layouts.ClefJsonLayout;
using NLog.Targets;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
@ -12,11 +13,9 @@ namespace NzbDrone.Common.Instrumentation
{
public static class NzbDroneLogger
{
private const string FileLogLayout = @"${date:format=yyyy-MM-dd HH\:mm\:ss.f}|${level}|${logger}|${message}${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}";
private const string ConsoleFormat = "[${level}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}";
private static readonly CleansingConsoleLogLayout CleansingConsoleLayout = new (ConsoleFormat);
private static readonly CleansingClefLogLayout ClefLogLayout = new ();
private const string FILE_LOG_LAYOUT = @"${date:format=yyyy-MM-dd HH\:mm\:ss.f}|${level}|${logger}|${message}${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}";
public const string ConsoleLogLayout = "[${level}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}";
public static CompactJsonLayout ClefLogLayout = new CompactJsonLayout();
private static bool _isConfigured;
@ -120,7 +119,11 @@ namespace NzbDrone.Common.Instrumentation
? formatEnumValue
: ConsoleLogFormat.Standard;
ConfigureConsoleLayout(coloredConsoleTarget, logFormat);
coloredConsoleTarget.Layout = logFormat switch
{
ConsoleLogFormat.Clef => ClefLogLayout,
_ => ConsoleLogLayout
};
var loggingRule = new LoggingRule("*", level, coloredConsoleTarget);
@ -137,7 +140,7 @@ namespace NzbDrone.Common.Instrumentation
private static void RegisterAppFile(IAppFolderInfo appFolderInfo, string name, string fileName, int maxArchiveFiles, LogLevel minLogLevel)
{
var fileTarget = new CleansingFileTarget();
var fileTarget = new NzbDroneFileTarget();
fileTarget.Name = name;
fileTarget.FileName = Path.Combine(appFolderInfo.GetLogFolder(), fileName);
@ -150,7 +153,7 @@ namespace NzbDrone.Common.Instrumentation
fileTarget.MaxArchiveFiles = maxArchiveFiles;
fileTarget.EnableFileDelete = true;
fileTarget.ArchiveNumbering = ArchiveNumberingMode.Rolling;
fileTarget.Layout = FileLogLayout;
fileTarget.Layout = FILE_LOG_LAYOUT;
var loggingRule = new LoggingRule("*", minLogLevel, fileTarget);
@ -169,7 +172,7 @@ namespace NzbDrone.Common.Instrumentation
fileTarget.ConcurrentWrites = false;
fileTarget.ConcurrentWriteAttemptDelay = 50;
fileTarget.ConcurrentWriteAttempts = 100;
fileTarget.Layout = FileLogLayout;
fileTarget.Layout = FILE_LOG_LAYOUT;
var loggingRule = new LoggingRule("*", LogLevel.Trace, fileTarget);
@ -214,15 +217,6 @@ namespace NzbDrone.Common.Instrumentation
{
return GetLogger(obj.GetType());
}
public static void ConfigureConsoleLayout(ColoredConsoleTarget target, ConsoleLogFormat format)
{
target.Layout = format switch
{
ConsoleLogFormat.Clef => NzbDroneLogger.ClefLogLayout,
_ => NzbDroneLogger.CleansingConsoleLayout
};
}
}
public enum ConsoleLogFormat

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 = false;
o.AutoSessionTracking = true;
// Caches files in the event device is offline
// Sentry creates a 'sentry' sub directory, no need to concat here
@ -148,7 +148,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
_debounce = new SentryDebounce();
// initialize to true and reconfigure later
// Otherwise it will default to false and any errors occurring
// Otherwise it will default to false and any errors occuring
// before config file gets read will not be filtered
FilterEvents = true;
SentryEnabled = true;
@ -207,7 +207,9 @@ namespace NzbDrone.Common.Instrumentation.Sentry
private void OnError(Exception ex)
{
if (ex is WebException webException)
var webException = ex as WebException;
if (webException != null)
{
var response = webException.Response as HttpWebResponse;
var statusCode = response?.StatusCode;

View file

@ -6,5 +6,4 @@ 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

@ -6,7 +6,6 @@ using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using NLog;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Model;
@ -118,9 +117,7 @@ namespace NzbDrone.Common.Processes
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
RedirectStandardInput = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
RedirectStandardInput = true
};
if (environmentVariables != null)
@ -316,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,21 +5,20 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="IPAddressRange" Version="6.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.3" />
<PackageReference Include="IPAddressRange" Version="6.0.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.4.0" />
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.3" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.4.0" />
<PackageReference Include="Npgsql" Version="7.0.10" />
<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.7" />
<PackageReference Include="Sentry" Version="4.0.2" />
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="System.Text.Json" Version="6.0.11" />
<PackageReference Include="System.ValueTuple" Version="4.6.1" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.2" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.1" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
<PackageReference Include="System.Runtime.Loader" Version="4.3.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="6.0.1" />

View file

@ -1,43 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -122,7 +122,7 @@ namespace NzbDrone.Core.Test.IndexerTests.AnimeBytesTests
var fifthTorrentInfo = releases.ElementAt(28) as TorrentInfo;
fifthTorrentInfo.Title.Should().Be("[-ZR-] Dr. STONE: STONE WARS 2021 S02 [Web][MKV][h264][1080p][AAC 2.0][Dual Audio][Softsubs (-ZR-)]");
fifthTorrentInfo.Title.Should().Be("[-ZR-] Dr. STONE: STONE WARS S02 [Web][MKV][h264][1080p][AAC 2.0][Dual Audio][Softsubs (-ZR-)]");
fifthTorrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent);
fifthTorrentInfo.DownloadUrl.Should().Be("https://animebytes.tv/torrent/944509/download/somepass");
fifthTorrentInfo.InfoUrl.Should().Be("https://animebytes.tv/torrent/944509/group");

View file

@ -26,15 +26,15 @@ namespace NzbDrone.Core.Test.IndexerTests.HDBitsTests
[SetUp]
public void Setup()
{
Subject.Definition = new IndexerDefinition
Subject.Definition = new IndexerDefinition()
{
Name = "HdBits",
Settings = new HDBitsSettings { ApiKey = "fakekey" }
Settings = new HDBitsSettings() { ApiKey = "fakekey" }
};
_movieSearchCriteria = new MovieSearchCriteria
{
Categories = new[] { 2000, 2010 },
Categories = new int[] { 2000, 2010 },
ImdbId = "0076759"
};
}
@ -52,7 +52,7 @@ namespace NzbDrone.Core.Test.IndexerTests.HDBitsTests
var torrents = (await Subject.Fetch(_movieSearchCriteria)).Releases;
torrents.Should().HaveCount(2);
torrents.First().Should().BeOfType<TorrentInfo>();
torrents.First().Should().BeOfType<HDBitsInfo>();
var first = torrents.First() as TorrentInfo;

View file

@ -46,8 +46,8 @@ namespace NzbDrone.Core.Test.IndexerTests.RedactedTests
torrentInfo.Title.Should().Be("Red Hot Chili Peppers - Californication (1999) [Album] [US / Reissue 2020] [FLAC 24bit Lossless / Vinyl]");
torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent);
torrentInfo.DownloadUrl.Should().Be("https://redacted.sh/ajax.php?action=download&id=3892313");
torrentInfo.InfoUrl.Should().Be("https://redacted.sh/torrents.php?id=16720&torrentid=3892313");
torrentInfo.DownloadUrl.Should().Be("https://redacted.ch/ajax.php?action=download&id=3892313");
torrentInfo.InfoUrl.Should().Be("https://redacted.ch/torrents.php?id=16720&torrentid=3892313");
torrentInfo.CommentUrl.Should().BeNullOrEmpty();
torrentInfo.Indexer.Should().Be(Subject.Definition.Name);
torrentInfo.PublishDate.Should().Be(DateTime.Parse("2022-12-17 08:02:35"));

View file

@ -35,18 +35,18 @@ namespace NzbDrone.Core.Test.UpdateTests
{
_updatePackage = new UpdatePackage
{
FileName = "NzbDrone.develop.1.0.0.0.tar.gz",
FileName = "NzbDrone.develop.2.0.0.0.tar.gz",
Url = "http://download.sonarr.tv/v2/develop/mono/NzbDrone.develop.tar.gz",
Version = new Version("1.0.0.0")
Version = new Version("2.0.0.0")
};
}
else
{
_updatePackage = new UpdatePackage
{
FileName = "NzbDrone.develop.1.0.0.0.zip",
FileName = "NzbDrone.develop.2.0.0.0.zip",
Url = "http://download.sonarr.tv/v2/develop/windows/NzbDrone.develop.zip",
Version = new Version("1.0.0.0")
Version = new Version("2.0.0.0")
};
}
@ -90,6 +90,17 @@ namespace NzbDrone.Core.Test.UpdateTests
.Returns(true);
}
[Test]
public void should_not_update_if_inside_docker()
{
Mocker.GetMock<IOsInfo>().Setup(x => x.IsDocker).Returns(true);
Subject.Execute(new ApplicationUpdateCommand());
Mocker.GetMock<IProcessProvider>()
.Verify(c => c.Start(It.IsAny<string>(), It.Is<string>(s => s.StartsWith("12")), null, null, null), Times.Never());
}
[Test]
public void should_delete_sandbox_before_update_if_folder_exists()
{
@ -327,28 +338,6 @@ namespace NzbDrone.Core.Test.UpdateTests
.Verify(v => v.SaveConfigDictionary(It.Is<Dictionary<string, object>>(d => d.ContainsKey("Branch") && (string)d["Branch"] == "fake")), Times.Once());
}
[Test]
public void should_not_update_with_built_in_updater_inside_docker_container()
{
Mocker.GetMock<IDeploymentInfoProvider>().Setup(x => x.PackageUpdateMechanism).Returns(UpdateMechanism.Docker);
Subject.Execute(new ApplicationUpdateCommand());
Mocker.GetMock<IProcessProvider>()
.Verify(c => c.Start(It.IsAny<string>(), It.Is<string>(s => s.StartsWith("12")), null, null, null), Times.Never());
}
[Test]
public void should_not_update_with_built_in_updater_when_external_updater_is_configured()
{
Mocker.GetMock<IDeploymentInfoProvider>().Setup(x => x.IsExternalUpdateMechanism).Returns(true);
Subject.Execute(new ApplicationUpdateCommand());
Mocker.GetMock<IProcessProvider>()
.Verify(c => c.Start(It.IsAny<string>(), It.Is<string>(s => s.StartsWith("12")), null, null, null), Times.Never());
}
[TearDown]
public void TearDown()
{

View file

@ -53,7 +53,7 @@ namespace NzbDrone.Core.Applications
foreach (var application in applications)
{
if (blockedApplications.TryGetValue(application.Definition.Id, out var blockedApplicationStatus) && blockedApplicationStatus.DisabledTill.HasValue)
if (blockedApplications.TryGetValue(application.Definition.Id, out var blockedApplicationStatus))
{
_logger.Debug("Temporarily ignoring application {0} till {1} due to recent failures.", application.Definition.Name, blockedApplicationStatus.DisabledTill.Value.ToLocalTime());
continue;

View file

@ -127,8 +127,6 @@ namespace NzbDrone.Core.Applications
private void SyncIndexers(List<IApplication> applications, List<IndexerDefinition> indexers, bool removeRemote = false, bool forceSync = false)
{
var sortedIndexers = indexers.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToList();
foreach (var app in applications)
{
var indexerMappings = _appIndexerMapService.GetMappingsForApp(app.Definition.Id);
@ -159,7 +157,7 @@ namespace NzbDrone.Core.Applications
}
}
foreach (var indexer in sortedIndexers)
foreach (var indexer in indexers)
{
var definition = indexer;

View file

@ -64,7 +64,6 @@ namespace NzbDrone.Core.Applications.Lidarr
failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Lidarr cannot connect to Prowlarr"));
break;
case HttpStatusCode.SeeOther:
case HttpStatusCode.TemporaryRedirect:
_logger.Warn(ex, "Lidarr returned redirect and is invalid");
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Lidarr URL is invalid, Prowlarr cannot connect to Lidarr - are you missing a URL base?"));
break;

View file

@ -166,7 +166,6 @@ namespace NzbDrone.Core.Applications.Lidarr
_logger.Error(ex, "Invalid Request");
break;
case HttpStatusCode.SeeOther:
case HttpStatusCode.TemporaryRedirect:
_logger.Warn(ex, "App returned redirect and is invalid. Check App URL");
break;
case HttpStatusCode.NotFound:

View file

@ -64,7 +64,6 @@ namespace NzbDrone.Core.Applications.Radarr
failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Radarr cannot connect to Prowlarr"));
break;
case HttpStatusCode.SeeOther:
case HttpStatusCode.TemporaryRedirect:
_logger.Warn(ex, "Radarr returned redirect and is invalid");
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Radarr URL is invalid, Prowlarr cannot connect to Radarr - are you missing a URL base?"));
break;

View file

@ -179,7 +179,6 @@ namespace NzbDrone.Core.Applications.Radarr
_logger.Error(ex, "Invalid Request");
break;
case HttpStatusCode.SeeOther:
case HttpStatusCode.TemporaryRedirect:
_logger.Warn(ex, "App returned redirect and is invalid. Check App URL");
break;
case HttpStatusCode.NotFound:

View file

@ -64,7 +64,6 @@ namespace NzbDrone.Core.Applications.Readarr
failures.AddIfNotNull(new ValidationFailure("ProwlarrUrl", "Prowlarr URL is invalid, Readarr cannot connect to Prowlarr"));
break;
case HttpStatusCode.SeeOther:
case HttpStatusCode.TemporaryRedirect:
_logger.Warn(ex, "Readarr returned redirect and is invalid");
failures.AddIfNotNull(new ValidationFailure("BaseUrl", "Readarr URL is invalid, Prowlarr cannot connect to Readarr - are you missing a URL base?"));
break;

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