Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b94406027 | ||
|
|
aac679d06f | ||
|
|
3492277112 | ||
|
|
d8e0bad012 | ||
|
|
897fe36b4a | ||
|
|
40b6a08143 | ||
|
|
5db2a805f8 | ||
|
|
165113b456 | ||
|
|
8e9cd20994 | ||
|
|
9efc8b187a | ||
|
|
c379944898 | ||
|
|
8ae5bec7d4 | ||
|
|
9077ff7c36 | ||
|
|
b8b2b922bd | ||
|
|
c26c5a85be | ||
|
|
2d921906b1 | ||
|
|
12716463c9 | ||
|
|
30cc535ca0 | ||
|
|
e14e468c72 | ||
|
|
3c11780b70 | ||
|
|
4fc7fc5f40 | ||
|
|
40dbea102d | ||
|
|
daf0f8ed7e | ||
|
|
84d255c9e6 | ||
|
|
6f52dc2061 | ||
|
|
b925f149ce | ||
|
|
ef4c0d1052 | ||
|
|
07e1b4e89a | ||
|
|
e72a1ceb30 | ||
|
|
78c89c70b3 | ||
|
|
23bca25c8c | ||
|
|
02d9d78e83 | ||
|
|
1b9c251ee1 | ||
|
|
64017e125a | ||
|
|
40556a6f93 | ||
|
|
07edc26a86 | ||
|
|
c108ebddc6 | ||
|
|
36e2b559f0 | ||
|
|
7de5dcf00d | ||
|
|
b38d3e03c1 | ||
|
|
79c8072318 | ||
|
|
9c836201ea | ||
|
|
7287b7304f | ||
|
|
5fd6870179 | ||
|
|
b9afa0ec9d | ||
|
|
677704206f | ||
|
|
f136d7aa41 | ||
|
|
c5965802d0 | ||
|
|
7d9343ead3 |
No files matched your search
@@ -16,24 +16,21 @@ If OmniArr is useful to you, you can support its development on [Ko-fi](https://
|
||||
|
||||
## What you get
|
||||
|
||||
- It connects with all your Jellyfin users, the login stays consistent.
|
||||
- Integration with Sonarr and Radarr to manage your library.
|
||||
|
||||
- It connects with all your jellyfin users, the log in stays consistent
|
||||
- Integration with sonarr and radarr to manage your library
|
||||
|
||||
<img width="4212" height="2564" alt="Screenshot 2026-10-13 at 09 46 52" src="https://github.com/user-attachments/assets/be2ccf0f-1697-4191-9438-77e2415ef827" />
|
||||
|
||||
<img width="4284" height="2536" alt="Screenshot 2026-10-13 at 10 43 21" src="https://github.com/user-attachments/assets/73d7bd96-3001-4b55-9679-875b644b3577" />
|
||||
|
||||
|
||||
- A calendar tracking your new episodes and events.
|
||||
|
||||
- A calendar tracking all your new episodes and events
|
||||
|
||||
<img width="4214" height="2566" alt="Screenshot 2026-10-13 at 09 46 05" src="https://github.com/user-attachments/assets/c614b617-5ee5-437f-a4f0-30f2ac89a71b" />
|
||||
|
||||
- Every discovery and search you may need.
|
||||
|
||||
- Every discovery and searching you may need
|
||||
|
||||
<img width="4208" height="2562" alt="Screenshot 2026-10-13 at 09 47 49" src="https://github.com/user-attachments/assets/82766b80-fb0d-4ff2-b594-203e6b52828f" />
|
||||
|
||||
- A download manager to see what's cooking.
|
||||
|
||||
- A download manager to see what's cooking
|
||||
|
||||
And many things more!
|
||||
|
||||
## Install with Docker Compose
|
||||
@@ -43,7 +40,7 @@ Create a directory for OmniArr and add this `docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
omniarr:
|
||||
image: wonderalice/omniarr:1.2.4
|
||||
image: wonderalice/omniarr:1.1
|
||||
container_name: omniarr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -90,16 +87,28 @@ the mount loses that state when the container is replaced.
|
||||
The current release is available as:
|
||||
|
||||
```text
|
||||
wonderalice/omniarr:1.2.4
|
||||
wonderalice/omniarr:1.1
|
||||
wonderalice/omniarr:latest
|
||||
```
|
||||
|
||||
Pin `1.2.4` in production for repeatable updates. Use `latest` only when you want
|
||||
Pin `1.1` in production for repeatable updates. Use `latest` only when you want
|
||||
Compose to follow the newest published image.
|
||||
|
||||
## Build locally
|
||||
## Build and publish
|
||||
|
||||
To build and run your own image from source:
|
||||
To publish a multi-architecture image to Docker Hub:
|
||||
|
||||
```bash
|
||||
docker login
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t wonderalice/omniarr:1.1 \
|
||||
-t wonderalice/omniarr:latest \
|
||||
--push \
|
||||
.
|
||||
```
|
||||
|
||||
To build locally without publishing:
|
||||
|
||||
```bash
|
||||
docker build -t omniarr:local .
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
omniarr:
|
||||
image: wonderalice/omniarr:1.2.4
|
||||
image: wonderalice/omniarr:1.1
|
||||
# Para construir desde código fuente en su lugar, descomenta estas líneas:
|
||||
# build:
|
||||
# context: .
|
||||
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,13 @@
|
||||
<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="80px" height="50px" viewBox="0 0 80 50" style="enable-background:new 0 0 80 50;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:currentColor;}
|
||||
</style>
|
||||
<title>PrimeLogo_Blue</title>
|
||||
<path class="st0" d="M1.15,29.2c-0.215,0.01-0.426-0.061-0.59-0.2c-0.14-0.167-0.205-0.384-0.18-0.6V8 C0.355,7.784,0.42,7.567,0.56,7.4c0.166-0.126,0.372-0.186,0.58-0.17h2.22C3.78,7.188,4.158,7.483,4.22,7.9l0.22,0.8 c0.639-0.617,1.394-1.103,2.22-1.43c0.845-0.342,1.748-0.519,2.66-0.52c1.818-0.06,3.556,0.749,4.68,2.18 c1.238,1.706,1.853,3.785,1.74,5.89c0.034,1.528-0.259,3.045-0.86,4.45c-0.496,1.17-1.302,2.183-2.33,2.93 c-0.994,0.678-2.177,1.028-3.38,1c-0.817,0.004-1.629-0.131-2.4-0.4c-0.709-0.238-1.364-0.612-1.93-1.1v6.72 c0.022,0.214-0.038,0.429-0.17,0.6c-0.171,0.132-0.386,0.192-0.6,0.17L1.15,29.2z M7.88,19.84c0.988,0.071,1.944-0.371,2.53-1.17 c0.623-1.118,0.905-2.394,0.81-3.67c0.096-1.288-0.182-2.576-0.8-3.71c-0.588-0.807-1.554-1.25-2.55-1.17 c-1.057,0.001-2.093,0.287-3,0.83V19c0.9,0.56,1.94,0.854,3,0.85L7.88,19.84z"/>
|
||||
<path class="st0" d="M19.54,22.88c-0.358,0.067-0.703-0.169-0.77-0.528c-0.015-0.08-0.015-0.162,0-0.242V8 c-0.025-0.216,0.04-0.433,0.18-0.6c0.166-0.126,0.372-0.186,0.58-0.17h2.21c0.42-0.042,0.798,0.253,0.86,0.67L23,9.54 c0.657-0.769,1.442-1.419,2.32-1.92c0.716-0.375,1.512-0.57,2.32-0.57h0.43c0.217-0.019,0.434,0.041,0.61,0.17 c0.14,0.167,0.205,0.384,0.18,0.6v2.58c0.017,0.208-0.043,0.414-0.17,0.58c-0.167,0.14-0.384,0.205-0.6,0.18h-0.55 c-0.227,0-0.513,0-0.86,0c-0.578,0.012-1.154,0.079-1.72,0.2c-0.59,0.109-1.166,0.28-1.72,0.51v10.25 c0.016,0.208-0.044,0.414-0.17,0.58c-0.167,0.14-0.384,0.205-0.6,0.18L19.54,22.88z"/>
|
||||
<path class="st0" d="M33.29,4.79c-0.682,0.034-1.351-0.195-1.87-0.64c-0.482-0.451-0.741-1.091-0.71-1.75 c-0.034-0.663,0.225-1.307,0.71-1.76c1.096-0.877,2.654-0.877,3.75,0c0.482,0.451,0.741,1.091,0.71,1.75 c0.031,0.659-0.228,1.299-0.71,1.75C34.65,4.591,33.977,4.824,33.29,4.79z M31.82,22.89c-0.358,0.067-0.703-0.169-0.77-0.528 c-0.015-0.08-0.015-0.162,0-0.242V8c-0.025-0.216,0.04-0.433,0.18-0.6c0.166-0.126,0.372-0.186,0.58-0.17h2.95 c0.214-0.022,0.429,0.038,0.6,0.17c0.132,0.171,0.192,0.386,0.17,0.6v14.12c0.017,0.208-0.043,0.414-0.17,0.58 c-0.167,0.14-0.384,0.205-0.6,0.18L31.82,22.89z"/>
|
||||
<path class="st0" d="M40.1,22.88c-0.358,0.067-0.703-0.169-0.77-0.528c-0.015-0.08-0.015-0.162,0-0.242V8 c-0.025-0.216,0.04-0.433,0.18-0.6c0.166-0.126,0.372-0.186,0.58-0.17h2.21c0.42-0.042,0.798,0.253,0.86,0.67l0.25,0.83 c0.91-0.627,1.894-1.137,2.93-1.52c0.855-0.298,1.754-0.453,2.66-0.46c1.576-0.135,3.09,0.642,3.9,2 c0.913-0.628,1.905-1.132,2.95-1.5c0.922-0.307,1.888-0.462,2.86-0.46c1.228-0.074,2.432,0.36,3.33,1.2 c0.828,0.908,1.254,2.113,1.18,3.34v10.79c0.016,0.208-0.044,0.414-0.17,0.58c-0.167,0.14-0.384,0.205-0.6,0.18h-2.91 c-0.358,0.067-0.703-0.169-0.77-0.528c-0.015-0.08-0.015-0.162,0-0.242v-9.84c0-1.393-0.623-2.09-1.87-2.09 c-1.162,0.013-2.307,0.287-3.35,0.8v11.14c0.017,0.208-0.043,0.414-0.17,0.58c-0.167,0.14-0.384,0.205-0.6,0.18h-2.94 c-0.358,0.067-0.703-0.169-0.77-0.528c-0.015-0.08-0.015-0.162,0-0.242v-9.84c0-1.393-0.623-2.09-1.87-2.09 c-1.176,0.007-2.334,0.292-3.38,0.83v11.1c0.016,0.208-0.044,0.414-0.17,0.58c-0.167,0.14-0.384,0.205-0.6,0.18L40.1,22.88z"/>
|
||||
<path class="st0" d="M73.92,23.34c-2.155,0.141-4.272-0.615-5.85-2.09c-1.443-1.652-2.164-3.813-2-6 c-0.144-2.267,0.586-4.504,2.04-6.25c1.515-1.564,3.636-2.393,5.81-2.27c1.608-0.096,3.196,0.395,4.47,1.38 c1.078,0.92,1.672,2.285,1.61,3.7c0.073,1.385-0.588,2.707-1.74,3.48c-1.551,0.886-3.328,1.296-5.11,1.18 c-1.01,0.012-2.018-0.102-3-0.34c0.006,1.106,0.452,2.164,1.24,2.94c0.933,0.662,2.069,0.977,3.21,0.89 c0.558,0,1.116-0.037,1.67-0.11c0.762-0.118,1.516-0.279,2.26-0.48h0.18h0.15c0.347,0,0.52,0.237,0.52,0.71v1.41 c0.019,0.239-0.029,0.478-0.14,0.69c-0.142,0.167-0.33,0.288-0.54,0.35C77.17,23.093,75.55,23.368,73.92,23.34z M72.92,13.71 c0.787,0.057,1.574-0.109,2.27-0.48c0.478-0.327,0.748-0.882,0.71-1.46c0-1.287-0.767-1.93-2.3-1.93c-1.967,0-3.103,1.207-3.41,3.62 c0.893,0.171,1.801,0.255,2.71,0.25H72.92z"/>
|
||||
<path class="st0" d="M72.31,40.11C63.55,46.57,50.85,50,39.92,50c-14.606,0.078-28.716-5.295-39.57-15.07 c-0.82-0.74-0.09-1.75,0.9-1.18c12.058,6.885,25.705,10.501,39.59,10.49c10.361-0.055,20.61-2.152,30.16-6.17 C72.52,37.44,73.76,39,72.31,40.11z"/>
|
||||
<path class="st0" d="M76,36c-1.12-1.43-7.4-0.68-10.23-0.34c-0.86,0.1-1-0.64-0.22-1.18c5-3.52,13.23-2.5,14.18-1.32 s-0.25,9.41-5,13.34c-0.72,0.6-1.41,0.28-1.09-0.52C74.71,43.29,77.08,37.39,76,36z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.046 17.091" width="100%" height="100%"
|
||||
><path
|
||||
d="M9.436,2.742A3.857,3.857,0,0,0,10.316,0a3.769,3.769,0,0,0-2.51,1.311,3.622,3.622,0,0,0-.9,2.631,3.138,3.138,0,0,0,2.53-1.2m.82,1.381c-1.4-.081-2.58.8-3.25.8s-1.69-.756-2.79-.736a4.117,4.117,0,0,0-3.5,2.147c-1.5,2.6-.4,6.473,1.06,8.59.71,1.006,1.56,2.205,2.69,2.166s1.48-.7,2.77-.7,1.67.7,2.79.675,1.9-1.008,2.6-2.1a9.317,9.317,0,0,0,1.17-2.42,3.814,3.814,0,0,1-2.27-3.468,3.9,3.9,0,0,1,1.83-3.256,3.991,3.991,0,0,0-3.1-1.7m8.93-2.016V4.96h2.28V6.845h-2.28V13.6c0,1.008.45,1.522,1.45,1.522a7.482,7.482,0,0,0,.82-.06v1.9a7.823,7.823,0,0,1-1.35.1c-2.36,0-3.27-.917-3.27-3.216V6.89h-1.79V5h1.74V2.107Zm10.25,14.853h-2.5L22.736,5h2.49l2.95,9.608h.06L31.186,5h2.44Zm10.98,0h-2.16v-4.9h-4.64V9.9h4.63V5h2.16V9.9h4.64v2.158h-4.63Z"
|
||||
/></svg
|
||||
>
|
||||
|
After Width: | Height: | Size: 846 B |
@@ -0,0 +1,70 @@
|
||||
<svg
|
||||
width="1041px"
|
||||
height="565px"
|
||||
viewBox="0 0 1041 565"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
>
|
||||
<!-- Generated by Pixelmator Pro 2.2 -->
|
||||
<g id="Layer_1">
|
||||
<g id="group">
|
||||
<g id="group-1">
|
||||
<g id="group-2">
|
||||
<path
|
||||
id="Path"
|
||||
d="M735.8 365.7 C721.4 369 683.5 370.9 683.5 370.9 L678.7 385.9 C678.7 385.9 697.6 384.3 711.4 385.7 711.4 385.7 715.9 385.2 716.4 390.8 716.6 396 716 401.6 716 401.6 716 401.6 715.7 405 710.9 405.8 705.7 406.7 670.1 408 670.1 408 L664.3 427.5 C664.3 427.5 662.2 432 667 430.7 671.5 429.5 708.8 422.5 713.7 423.5 718.9 424.8 724.7 431.7 723 438.1 721 445.9 683.8 469.7 661.1 468 661.1 468 649.2 468.8 639.1 452.7 629.7 437.4 642.7 408.3 642.7 408.3 642.7 408.3 636.8 394.7 641.1 390.2 641.1 390.2 643.7 387.9 651.1 387.3 L660.2 368.4 C660.2 368.4 649.8 369.1 643.6 361.5 637.8 354.2 637.4 350.9 641.8 348.9 646.5 346.6 689.8 338.7 719.6 339.7 719.6 339.7 730 338.7 738.9 356.7 738.8 356.7 743.2 364 735.8 365.7 Z M623.7 438.3 C619.9 447.3 609.8 456.9 597.3 450.9 584.9 444.9 565.2 404.6 565.2 404.6 565.2 404.6 557.7 389.6 556.3 389.9 556.3 389.9 554.7 387 553.7 403.4 552.7 419.8 553.9 451.7 547.4 456.7 541.2 461.7 533.7 459.7 529.8 453.8 526.3 448 524.8 434.2 526.7 410 529 385.8 534.6 360 541.8 351.9 549 343.9 554.8 349.7 557 351.8 557 351.8 566.6 360.5 582.5 386.1 L585.3 390.8 C585.3 390.8 599.7 415 601.2 414.9 601.2 414.9 602.4 416 603.4 415.2 604.9 414.8 604.3 407 604.3 407 604.3 407 601.3 380.7 588.2 336.1 588.2 336.1 586.2 330.5 587.6 325.3 588.9 320 594.2 322.5 594.2 322.5 594.2 322.5 614.6 332.7 624.4 365.9 634.1 399.4 627.5 429.3 623.7 438.3 Z M523.5 353 C521.8 356.4 520.8 361.3 512.2 362.6 512.2 362.6 429.9 368.2 426 374 426 374 423.1 377.4 427.6 378.4 432.1 379.3 450.7 381.8 459.7 382.3 469.3 382.4 501.7 382.7 513.3 397.2 513.3 397.2 520.2 404.1 519.9 419.7 519.6 435.7 516.8 441.3 510.6 447.1 504.1 452.5 448.3 477.5 412.3 439.1 412.3 439.1 395.7 420.6 418 406.6 418 406.6 434.1 396.9 475 408.3 475 408.3 487.4 412.8 486.8 417.3 486.1 422.1 476.6 427.2 462.8 426.9 449.4 426.5 439.6 420.1 441.5 421.1 443.3 421.8 427.1 413.3 422.1 419.1 417.1 424.4 418.3 427.7 423.2 431 435.7 438.1 484 435.6 498.4 419.6 498.4 419.6 504.1 413.1 495.4 407.8 486.7 402.8 461.8 399.8 452.1 399.3 442.8 398.8 408.2 399.4 403.2 390.2 403.2 390.2 398.2 384 403.7 366.4 409.5 348 449.8 340.9 467.2 339.3 467.2 339.3 515.1 337.6 523.9 347.4 523.8 347.4 525 349.7 523.5 353 Z M387.5 460.9 C381.7 465.2 369.4 463.3 365.9 458.5 362.4 454.2 361.2 437.1 361.9 410.3 362.6 383.2 363.2 349.6 369 344.3 375.2 338.9 379 343.6 381.4 347.3 384 350.9 387.1 354.9 387.8 363.4 388.4 371.9 390.4 416.5 390.4 416.5 390.4 416.5 393 456.7 387.5 460.9 Z M400 317.1 C383.1 322.7 371.5 320.8 361.7 316.6 357.4 324.1 354.9 326.4 351.6 326.9 346.8 327.4 342.5 319.7 341.7 317.2 340.9 315.3 338.6 312.1 341.4 304.5 331.8 295.9 331.1 284.3 332.7 276.5 335.1 267.5 351.3 233.3 400.6 229.3 400.6 229.3 424.7 227.5 428.8 240.4 L429.5 240.4 C429.5 240.4 452.9 240.5 452.4 261.3 452.1 282.2 426.4 308.2 400 317.1 Z M354 270.8 C349 278.8 348.8 283.6 351.1 286.9 356.8 278.2 367.2 264.5 382.5 254.1 370.7 255.1 360.8 260.2 354 270.8 Z M422.1 257.4 C406.6 259.7 382.6 280.5 371.2 297.5 388.7 300.7 419.6 299.5 433.3 271.6 433.2 271.6 439.8 254.3 422.1 257.4 Z M842.9 418.5 C833.6 434.7 807.5 468.5 772.7 460.6 761.2 488.5 751.6 516.6 746.1 558.8 746.1 558.8 744.9 567 738.1 564.1 731.4 561.7 720.2 550.5 718 535 715.6 514.6 724.7 480.1 743.2 440.6 737.8 431.8 734.1 419.2 737.3 401.3 737.3 401.3 742 368.1 775.3 338.1 775.3 338.1 779.3 334.6 781.6 335.7 784.2 336.8 783 347.6 780.9 352.8 778.8 358 763.9 383.8 763.9 383.8 763.9 383.8 754.6 401.2 757.2 414.9 774.7 388 814.5 333.7 839.2 350.8 847.5 356.7 851.3 369.6 851.3 383.5 851.2 395.8 848.3 408.8 842.9 418.5 Z M835.7 375.9 C835.7 375.9 834.3 365.2 823.9 377 814.9 386.9 798.7 405.6 785.6 430.9 799.3 429.4 812.5 421.9 816.5 418.1 823 412.3 838.1 396.7 835.7 375.9 Z M350.2 389.5 C348.3 413.7 339 454.4 273.1 474.5 229.6 487.6 188.5 481.3 166.1 475.6 165.6 484.5 164.6 488.3 163.2 489.8 161.3 491.7 147.1 499.9 139.3 488.3 135.8 482.8 134 472.8 133 463.9 82.6 440.7 59.4 407.3 58.5 405.8 57.4 404.7 45.9 392.7 57.4 378 68.2 364.7 103.5 351.4 135.3 346 136.4 318.8 139.6 298.3 143.4 288.9 148 278 153.8 287.8 158.8 295.2 163 300.7 165.5 324.4 165.7 343.3 186.5 342.3 198.8 343.8 222 348 252.2 353.5 272.4 368.9 270.6 386.4 269.3 403.6 253.5 410.7 247.5 411.2 241.2 411.7 231.4 407.2 231.4 407.2 224.7 404 230.9 401.2 239 397.7 247.8 393.4 245.8 389 245.8 389 242.5 379.4 203.3 372.7 164.3 372.7 164.1 394.2 165.2 429.9 165.7 450.7 193 455.9 213.4 454.9 213.4 454.9 213.4 454.9 313 452.1 316 388.5 319.1 324.8 216.7 263.7 141 244.3 65.4 224.5 22.6 238.3 18.9 240.2 14.9 242.2 18.6 242.8 18.6 242.8 18.6 242.8 22.7 243.4 29.8 245.8 37.3 248.2 31.5 252.1 31.5 252.1 18.6 256.2 4.1 253.6 1.3 247.7 -1.5 241.8 3.2 236.5 8.6 228.9 14 220.9 19.9 221.2 19.9 221.2 113.4 188.8 227.3 247.4 227.3 247.4 334 301.5 352.2 364.9 350.2 389.5 Z M68 386.2 C57.4 391.4 64.7 398.9 64.7 398.9 84.6 420.3 109.1 433.7 132.4 442 135.1 405.1 134.7 392.1 135 373.5 98.6 376 77.6 381.8 68 386.2 Z"
|
||||
fill="currentColor"
|
||||
fill-opacity="1"
|
||||
stroke="none"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="group-3">
|
||||
<g id="group-4">
|
||||
<g id="group-5">
|
||||
<path
|
||||
id="Path-1"
|
||||
d="M1040.9 378.6 L1040.9 391.8 C1040.9 394.7 1038.6 397 1035.7 397 L972.8 397 C972.8 400.3 972.9 403.2 972.9 405.9 972.9 425.4 972.1 441.3 970.2 459.2 969.9 461.9 967.7 463.9 965.1 463.9 L951.5 463.9 C950.1 463.9 948.8 463.3 947.9 462.3 947 461.3 946.5 459.9 946.7 458.5 948.6 440.7 949.5 425 949.5 405.9 949.5 403.1 949.5 400.2 949.4 397 L887.2 397 C884.3 397 882 394.7 882 391.8 L882 378.6 C882 375.7 884.3 373.4 887.2 373.4 L948.5 373.4 C947.2 351.9 944.6 331.2 940.4 310.2 940.2 308.9 940.5 307.6 941.3 306.6 942.1 305.6 943.3 305 944.6 305 L959.3 305 C961.6 305 963.5 306.6 964 308.9 968.1 330.6 970.7 351.7 972 373.4 L1035.7 373.4 C1038.5 373.4 1040.9 375.8 1040.9 378.6 Z"
|
||||
fill="currentColor"
|
||||
fill-opacity="1"
|
||||
stroke="none"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="group-6">
|
||||
<g id="group-7">
|
||||
<path
|
||||
id="Path-2"
|
||||
d="M200.2 204.3 L200.1 204.3 M199.4 204.4 C199.1 204.4 198.8 204.3 198.5 204.3 198.8 204.4 199.1 204.4 199.4 204.4 L199.7 204.4 C199.6 204.4 199.5 204.4 199.4 204.4 Z M199.4 204.4 C199.1 204.4 198.8 204.3 198.5 204.3 198.8 204.4 199.1 204.4 199.4 204.4 L199.7 204.4 C199.6 204.4 199.5 204.4 199.4 204.4 Z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="radial-gradient"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="942.524"
|
||||
cy="279.896"
|
||||
r="760.124"
|
||||
fx="942.524"
|
||||
fy="279.896"
|
||||
>
|
||||
<stop offset="0.007" stop-color="currentColor" stop-opacity="1" />
|
||||
<stop offset="0.216" stop-color="currentColor" stop-opacity="1" />
|
||||
<stop offset="1" stop-color="currentColor" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<path
|
||||
id="Path-3"
|
||||
d="M955.3 273.9 C922.8 194 867.9 125.9 796.5 76.9 723.4 26.8 637.7 0.3 548.7 0.3 401.5 0.3 264.9 73.4 183.4 195.9 182.5 197.2 182.3 198.9 182.8 200.4 183.3 202 184.5 203.1 186 203.6 L197.4 207.5 C198.1 207.7 198.8 207.8 199.4 207.8 201.5 207.8 203.5 206.7 204.7 205 242.1 150 292.7 104.3 351.1 72.7 411.4 40.1 479.7 22.8 548.6 22.8 631.9 22.8 712.2 47.4 781 93.8 848.1 139.1 900.2 202.4 931.7 276.7 932.6 278.9 934.8 280.4 937.2 280.4 L950.8 280.4 C952.4 280.4 953.9 279.6 954.7 278.3 955.7 277 955.9 275.4 955.3 273.9 Z M199.4 204.4 C199.1 204.4 198.8 204.3 198.5 204.2 198.8 204.3 199.1 204.4 199.4 204.4 L199.6 204.4 C199.6 204.4 199.5 204.4 199.4 204.4 Z M934.4 278.6 C934.7 278.8 935 279 935.3 279.1 935 278.9 934.7 278.8 934.4 278.6 Z"
|
||||
fill-opacity="1"
|
||||
fill="url(#radial-gradient)"
|
||||
stroke="none"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1,58 @@
|
||||
<svg
|
||||
width="512"
|
||||
height="211"
|
||||
viewBox="0 0 512 211"
|
||||
version="1.1"
|
||||
id="svg200"
|
||||
sodipodi:docname="Hbo logo 1.svg"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
>
|
||||
<defs id="defs204" />
|
||||
<sodipodi:namedview
|
||||
id="namedview202"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#111111"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="2.3543242"
|
||||
inkscape:cx="129.33648"
|
||||
inkscape:cy="119.7796"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="hbo-logo-blk"
|
||||
/>
|
||||
<g
|
||||
id="hbo-logo-blk"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
transform="translate(0,3.0006123e-5)"
|
||||
>
|
||||
<polyline
|
||||
id="Fill-15"
|
||||
fill="currentColor"
|
||||
points="148 206 92.7748343 206 92.7748343 127.236832 56.9262346 127.236832 56.9262346 206 0 206 0 3 56.9262346 3 56.9262346 78.5141926 92.7748343 78.5141926 92.7748343 3 148 3 148 206"
|
||||
transform="scale(1.0150417,1.0183117)"
|
||||
/>
|
||||
<path
|
||||
d="m 407.03751,146.25835 c 22.74616,0 41.18655,-18.48231 41.18655,-41.28778 0,-22.799359 -18.44039,-41.284724 -41.18655,-41.284724 -22.74918,0 -41.18755,18.485365 -41.18755,41.284724 0,22.80547 18.43837,41.28778 41.18755,41.28778 z m -52.39287,-41.28778 c 0,-29.003142 23.45567,-52.51778 52.39287,-52.51778 28.93621,0 52.39593,23.514638 52.39593,52.51778 0,29.00823 -23.45972,52.52185 -52.39593,52.52185 -28.9372,0 -52.39287,-23.51362 -52.39287,-52.52185 z m -72.95472,0 c 6.48297,-0.82297 17.28995,-8.253081 21.09702,-12.995836 -1.33794,5.880691 -1.44032,22.900966 0.12367,28.781656 -4.34529,-6.7057 -14.63332,-14.9598 -21.22069,-15.78582 z M 244.0236,49.370203 c 8.23346,0 14.71745,8.147419 14.71745,17.432774 0,9.285356 -6.48399,17.437855 -14.71745,17.437855 H 215.72395 V 49.370203 Z m -0.10338,77.471097 c 8.23244,0 14.71744,8.15148 14.71744,17.43379 0,9.28637 -6.485,17.43481 -14.71744,17.43481 h -28.29966 v -34.8686 z m 163.08283,83.94921 C 464.66766,210.76815 511.5942,162.74115 511.58103,104.92994 511.56987,45.899497 464.66766,0.04371976 407.00305,5.4946177e-7 349.40535,-0.04162544 319.97554,42.458256 312.70398,59.612644 312.7719,33.860743 287.45121,3.4646403 257.83287,3.4321278 h -95.4262 V 209.76331 l 88.96958,0.0173 c 35.87021,0 61.39463,-31.15609 61.43822,-57.87219 8.18784,16.81403 36.59088,58.90243 94.18858,58.88211 z"
|
||||
id="Fill-16"
|
||||
fill="currentColor"
|
||||
style="stroke-width:1.01668"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,68 @@
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1000"
|
||||
height="329.81302"
|
||||
viewBox="0 0 999.99997 329.81303"
|
||||
version="1.1"
|
||||
id="svg87"
|
||||
inkscape:version="0.92.3 (2405546, 2018-03-11)"
|
||||
sodipodi:docname="Hulu.svg"
|
||||
>
|
||||
<defs id="defs81">
|
||||
<clipPath id="clipPath20" clipPathUnits="userSpaceOnUse">
|
||||
<path inkscape:connector-curvature="0" id="path18" d="M 0,0 H 1920 V 1080 H 0 Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.7"
|
||||
inkscape:cx="1134.4996"
|
||||
inkscape:cy="-132.61488"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1366"
|
||||
inkscape:window-height="705"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
units="px"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
/>
|
||||
<metadata id="metadata84">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(463.37194,-69.476276)"
|
||||
>
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
d="m -241.01194,192.76828 c 16.556,13.46 24.846,34.472 24.846,63.043 v 143.478 h -78.882 v -132.919 c 0,-5.794 -2.174,-10.869 -6.521,-15.217 -4.349,-4.349 -9.425,-6.521 -15.218,-6.521 h -45.963 c -5.803,0 -10.772,2.173 -14.906,6.521 -4.144,4.348 -6.212,9.423 -6.212,15.217 v 132.919 h -79.504 V 69.476276 h 79.503 V 176.92928 c 1.243,-0.408 4.134,-1.242 8.697,-2.485 4.551,-1.241 10.557,-1.862 18.012,-1.862 h 50.931 c 26.912,-0.001 48.652,6.734 65.217,20.186 z M 9.2990586,172.58128 H 88.802059 v 139.13 c 0,26.087 -7.144,47.205 -21.428,63.354 -14.286,16.149 -33.648,24.224 -58.0750004,24.224 H -68.961941 c -27.329,0 -49.174999,-7.55 -65.526999,-22.67 -16.364,-15.111 -24.536,-36.743 -24.536,-64.907 v -139.13 h 79.502999 v 132.919 c 0,5.804 2.069,10.772 6.212,14.907 4.134,4.144 9.104,6.211 14.907,6.211 h 45.964 c 5.7929996,0 10.8679996,-2.067 15.2169996,-6.211 4.349,-4.135 6.521,-9.104 6.521,-14.907 z M 149.05006,69.476276 h 79.503 V 399.28928 h -79.503 z M 536.62804,172.58128 v 139.13 c 0,26.087 -7.144,47.205 -21.429,63.354 -14.285,16.149 -33.646,24.224 -58.074,24.224 h -77.64 c -27.746,0 -49.797,-7.55 -66.149,-22.67 -16.363,-15.111 -24.534,-36.743 -24.534,-64.907 v -139.13 h 79.503 v 132.919 c 0,5.804 2.066,10.772 6.211,14.907 4.135,4.144 9.104,6.211 14.906,6.211 h 46.584 c 5.795,0 10.764,-2.067 14.907,-6.211 4.134,-4.135 6.212,-9.104 6.212,-14.907 v -132.92 z"
|
||||
id="path4"
|
||||
style="fill:currentColor;fill-opacity:1"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="276.742" viewBox="0 0 1024 276.742"
|
||||
><path
|
||||
d="M140.803 258.904c-15.404 2.705-31.079 3.516-47.294 5.676l-49.458-144.856v151.073c-15.404 1.621-29.457 3.783-44.051 5.945v-276.742h41.08l56.212 157.021v-157.021h43.511v258.904zm85.131-157.558c16.757 0 42.431-.811 57.835-.811v43.24c-19.189 0-41.619 0-57.835.811v64.322c25.405-1.621 50.809-3.785 76.482-4.596v41.617l-119.724 9.461v-255.39h119.724v43.241h-76.482v58.105zm237.284-58.104h-44.862v198.908c-14.594 0-29.188 0-43.239.539v-199.447h-44.862v-43.242h132.965l-.002 43.242zm70.266 55.132h59.187v43.24h-59.187v98.104h-42.433v-239.718h120.808v43.241h-78.375v55.133zm148.641 103.507c24.594.539 49.456 2.434 73.51 3.783v42.701c-38.646-2.434-77.293-4.863-116.75-5.676v-242.689h43.24v201.881zm109.994 49.457c13.783.812 28.377 1.623 42.43 3.242v-254.58h-42.43v251.338zm231.881-251.338l-54.863 131.615 54.863 145.127c-16.217-2.162-32.432-5.135-48.648-7.838l-31.078-79.994-31.617 73.51c-15.678-2.705-30.812-3.516-46.484-5.678l55.672-126.75-50.269-129.992h46.482l28.377 72.699 30.27-72.699h47.295z"
|
||||
fill="currentColor"
|
||||
/></svg
|
||||
>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -1,17 +1,11 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
defaultConfig,
|
||||
type AppConfig,
|
||||
type AuthPayload,
|
||||
type BackupFile,
|
||||
type MediaCard,
|
||||
type WishlistEntry,
|
||||
type WishlistTogglePayload,
|
||||
type EpisodeRequestPayload,
|
||||
type SeriesEpisodeRequestPayload,
|
||||
type EpisodeWatchStateRequest,
|
||||
type DiskSpaceStatus,
|
||||
type HubPageResponse,
|
||||
@@ -22,18 +16,7 @@ import {
|
||||
type TitleDetailsRequest
|
||||
} from '../shared/contracts.js';
|
||||
import { decryptJson, deriveEncryptionKey, encryptJson, randomToken, verifyPassword, createPasswordRecord } from './lib/security.js';
|
||||
import {
|
||||
loadState,
|
||||
migrateConfig,
|
||||
readAdmin,
|
||||
readAllWishlists,
|
||||
readEncryptedConfig,
|
||||
readWishlist,
|
||||
restoreBackup,
|
||||
saveAdminAndConfig,
|
||||
updateEncryptedConfig,
|
||||
updateWishlist
|
||||
} from './lib/store.js';
|
||||
import { loadState, migrateConfig, readAdmin, readEncryptedConfig, saveAdminAndConfig, updateEncryptedConfig } from './lib/store.js';
|
||||
import {
|
||||
getCalendar,
|
||||
getConnectionStatuses,
|
||||
@@ -51,7 +34,6 @@ import {
|
||||
proxyJellyfinImage,
|
||||
removeDownload,
|
||||
requestEpisode,
|
||||
requestSeriesEpisode,
|
||||
requestMovie,
|
||||
requestSeason,
|
||||
requestSeries,
|
||||
@@ -197,20 +179,16 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/profiles' && method === 'GET') {
|
||||
let config: AppConfig;
|
||||
try {
|
||||
config = await readConfig();
|
||||
const config = await readConfig();
|
||||
const [jellyfinUsers, connections] = await Promise.all([
|
||||
loadJellyfinUsers(config.jellyfin.baseUrl, config.jellyfin.apiKey),
|
||||
getConnectionStatuses(config)
|
||||
]);
|
||||
sendJson(response, 200, { jellyfinUsers, connections });
|
||||
} catch {
|
||||
sendJson(response, 409, { error: 'An administrator must sign in once to migrate the existing configuration.' });
|
||||
return;
|
||||
}
|
||||
// A Jellyfin outage is reported through the connection status, so the
|
||||
// client can show it instead of an empty profile list.
|
||||
const [jellyfinUsers, connections] = await Promise.all([
|
||||
loadJellyfinUsers(config.jellyfin.baseUrl, config.jellyfin.apiKey).catch(() => []),
|
||||
getConnectionStatuses(config)
|
||||
]);
|
||||
sendJson(response, 200, { jellyfinUsers, connections });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -269,72 +247,6 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/admin/') && session?.role !== 'admin') {
|
||||
sendJson(response, 403, { error: 'Administrator access is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/admin/backup' && method === 'GET') {
|
||||
const backup: BackupFile = {
|
||||
app: 'omniarr',
|
||||
format: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
config: await readConfig(),
|
||||
wishlists: await readAllWishlists()
|
||||
};
|
||||
const fileName = `omniarr-backup-${backup.exportedAt.slice(0, 10)}.json`;
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${fileName}"`,
|
||||
'Cache-Control': 'no-store'
|
||||
});
|
||||
response.end(JSON.stringify(backup, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/admin/backup' && method === 'POST') {
|
||||
let body: Partial<BackupFile>;
|
||||
let config: AppConfig;
|
||||
try {
|
||||
body = await readJson<Partial<BackupFile>>(request);
|
||||
if (body.app !== 'omniarr' || body.format !== 1 || !body.config || typeof body.config !== 'object') {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
config = mergeStoredConfig(body.config);
|
||||
} catch {
|
||||
sendJson(response, 400, { error: 'This file is not an OmniArr backup.' });
|
||||
return;
|
||||
}
|
||||
config = { ...config, jellyfin: { ...config.jellyfin, userId: '' } };
|
||||
await restoreBackup(
|
||||
encryptJson(config, await getConfigEncryptionKey()),
|
||||
sanitizeWishlists(body.wishlists)
|
||||
);
|
||||
sendJson(response, 200, { config, connections: await getConnectionStatuses(config) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/wishlist' && method === 'GET') {
|
||||
sendJson(response, 200, { items: await readWishlist(getWishlistOwner(session!)) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/wishlist' && method === 'POST') {
|
||||
const body = await readJson<WishlistTogglePayload>(request);
|
||||
const card = sanitizeWishlistCard(body.card);
|
||||
if (!card) {
|
||||
sendJson(response, 400, { error: 'A title is required.' });
|
||||
return;
|
||||
}
|
||||
const key = getWishlistKey(card);
|
||||
const items = await updateWishlist(getWishlistOwner(session!), (entries) => {
|
||||
const remaining = entries.filter((entry) => getWishlistKey(entry.card) !== key);
|
||||
return body.wishlisted ? [{ card, addedAt: new Date().toISOString() }, ...remaining] : remaining;
|
||||
});
|
||||
sendJson(response, 200, { items });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/settings' && method === 'GET') {
|
||||
const config = await readConfig(session!.encryptionKey);
|
||||
sendJson(response, 200, session!.role === 'admin' ? config : redactConfig(config));
|
||||
@@ -343,8 +255,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
|
||||
|
||||
if (url.pathname === '/api/settings' && method === 'PUT') {
|
||||
const body = await readJson<AppConfig>(request);
|
||||
const sanitized = sanitizeConfig(body);
|
||||
const config = { ...sanitized, jellyfin: { ...sanitized.jellyfin, userId: '' } };
|
||||
const config = sanitizeConfig(body);
|
||||
await updateEncryptedConfig(encryptJson(config, await getConfigEncryptionKey()));
|
||||
const connections = await getConnectionStatuses(config);
|
||||
sendJson(response, 200, {
|
||||
@@ -509,14 +420,6 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/requests/series-episode' && method === 'POST') {
|
||||
const config = await readConfig(session!.encryptionKey);
|
||||
const body = await readJson<SeriesEpisodeRequestPayload & { user: string | null }>(request);
|
||||
await requestSeriesEpisode(config, body, body.user);
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/seasons/delete' && method === 'POST') {
|
||||
const config = await readConfig(session!.encryptionKey);
|
||||
const body = await readJson<SeasonDeletePayload>(request);
|
||||
@@ -542,7 +445,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
|
||||
const image = await proxyJellyfinImage(config, itemId, kind, maxWidth, tag);
|
||||
if (!image) {
|
||||
response.writeHead(302, {
|
||||
Location: '/placeholder.jpg',
|
||||
Location: '/reiverr/placeholder.jpg',
|
||||
'Cache-Control': 'private, max-age=300'
|
||||
});
|
||||
response.end();
|
||||
@@ -631,20 +534,8 @@ function getSessionToken(request: IncomingMessage): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mark the cookie Secure only when the browser reached us over HTTPS, so plain
|
||||
// HTTP access on a LAN still works.
|
||||
function isSecureRequest(request: IncomingMessage): boolean {
|
||||
if ('encrypted' in request.socket && request.socket.encrypted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const forwardedProto = request.headers['x-forwarded-proto'];
|
||||
const proto = (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(',')[0].trim().toLowerCase();
|
||||
return proto === 'https';
|
||||
}
|
||||
|
||||
function setSessionCookie(request: IncomingMessage, response: ServerResponse, token: string): void {
|
||||
const isSecure = isSecureRequest(request);
|
||||
const isSecure = request.headers['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production';
|
||||
response.setHeader(
|
||||
'Set-Cookie',
|
||||
`${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${isSecure ? '; Secure' : ''}`
|
||||
@@ -652,7 +543,7 @@ function setSessionCookie(request: IncomingMessage, response: ServerResponse, to
|
||||
}
|
||||
|
||||
function clearSessionCookie(request: IncomingMessage, response: ServerResponse): void {
|
||||
const isSecure = isSecureRequest(request);
|
||||
const isSecure = request.headers['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production';
|
||||
response.setHeader(
|
||||
'Set-Cookie',
|
||||
`${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isSecure ? '; Secure' : ''}`
|
||||
@@ -671,17 +562,9 @@ async function readConfig(legacyEncryptionKey?: Buffer): Promise<AppConfig> {
|
||||
throw new Error('The configuration must be unlocked by an administrator.');
|
||||
}
|
||||
|
||||
const config = mergeStoredConfig(decryptJson<Partial<AppConfig>>(encrypted, encryptionKey));
|
||||
// The Jellyfin user always comes from the profile the session signed in
|
||||
// with; admin sessions have none, so a userId saved by older versions is ignored.
|
||||
const userId = legacyEncryptionKey ? sessionUsers.get(legacyEncryptionKey) : undefined;
|
||||
return { ...config, jellyfin: { ...config.jellyfin, userId: userId || '' } };
|
||||
}
|
||||
const stored = decryptJson<Partial<AppConfig>>(encrypted, encryptionKey);
|
||||
|
||||
// Fills in whatever a stored config (or an imported backup) leaves out with
|
||||
// the defaults, so configs saved by older versions keep loading.
|
||||
function mergeStoredConfig(stored: Partial<AppConfig>): AppConfig {
|
||||
return sanitizeConfig({
|
||||
const config = sanitizeConfig({
|
||||
preferences: {
|
||||
...defaultConfig.preferences,
|
||||
...(stored.preferences || {})
|
||||
@@ -707,66 +590,8 @@ function mergeStoredConfig(stored: Partial<AppConfig>): AppConfig {
|
||||
...(stored.tvdb || {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Profiles keep their own wishlist; the administrator has no Jellyfin profile
|
||||
// and shares a single one.
|
||||
function getWishlistOwner(session: SessionRecord): string {
|
||||
return session.role === 'user' && session.userId ? session.userId : 'admin';
|
||||
}
|
||||
|
||||
// The same title can reach the client as a Jellyfin card or a TMDb card, so
|
||||
// titles are matched by their TMDb id when they have one.
|
||||
function getWishlistKey(card: MediaCard): string {
|
||||
return card.tmdbId ? `${card.mediaType === 'movie' ? 'movie' : 'tv'}:${card.tmdbId}` : card.id;
|
||||
}
|
||||
|
||||
const WISHLIST_MEDIA_TYPES = new Set<MediaCard['mediaType']>(['movie', 'tv', 'series']);
|
||||
|
||||
// Only what the wishlist page needs to draw a tile and open the title is kept;
|
||||
// progress and labels belong to the moment the card was saved.
|
||||
function sanitizeWishlistCard(input: unknown): MediaCard | null {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const card = input as Partial<MediaCard>;
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
||||
const id = text(card.id);
|
||||
if (!id || !card.mediaType || !WISHLIST_MEDIA_TYPES.has(card.mediaType)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
tmdbId: Number(card.tmdbId) || undefined,
|
||||
providerId: text(card.providerId) || undefined,
|
||||
title: text(card.title),
|
||||
subtitle: text(card.subtitle),
|
||||
overview: text(card.overview),
|
||||
mediaType: card.mediaType,
|
||||
posterUrl: text(card.posterUrl),
|
||||
backdropUrl: text(card.backdropUrl),
|
||||
logoUrl: text(card.logoUrl) || undefined,
|
||||
backdropLanguageFallback: card.backdropLanguageFallback ? true : undefined,
|
||||
releaseDate: text(card.releaseDate) || undefined,
|
||||
firstAirDate: text(card.firstAirDate) || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeWishlists(input: unknown): Record<string, WishlistEntry[]> {
|
||||
const wishlists: Record<string, WishlistEntry[]> = {};
|
||||
if (!input || typeof input !== 'object') {
|
||||
return wishlists;
|
||||
}
|
||||
for (const [owner, entries] of Object.entries(input)) {
|
||||
if (!Array.isArray(entries)) {
|
||||
continue;
|
||||
}
|
||||
wishlists[owner] = entries.flatMap((entry: Partial<WishlistEntry>) => {
|
||||
const card = sanitizeWishlistCard(entry?.card);
|
||||
return card ? [{ card, addedAt: typeof entry.addedAt === 'string' ? entry.addedAt : new Date().toISOString() }] : [];
|
||||
});
|
||||
}
|
||||
return wishlists;
|
||||
const userId = legacyEncryptionKey ? sessionUsers.get(legacyEncryptionKey) : undefined;
|
||||
return userId ? { ...config, jellyfin: { ...config.jellyfin, userId } } : config;
|
||||
}
|
||||
|
||||
async function getConfigEncryptionKey(): Promise<Buffer> {
|
||||
@@ -817,17 +642,14 @@ async function serveApp(pathname: string, response: ServerResponse): Promise<voi
|
||||
try {
|
||||
const content = await readFile(filePath);
|
||||
response.writeHead(200, {
|
||||
'Content-Type': contentType(filePath),
|
||||
// Hashed bundles never change; the entry page must always be revalidated so a new release reaches every browser.
|
||||
'Cache-Control': relativePath.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'
|
||||
'Content-Type': contentType(filePath)
|
||||
});
|
||||
response.end(content);
|
||||
} catch {
|
||||
try {
|
||||
const indexHtml = await readFile(resolve(process.cwd(), 'dist', 'index.html'));
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-cache'
|
||||
'Content-Type': 'text/html; charset=utf-8'
|
||||
});
|
||||
response.end(indexHtml);
|
||||
} catch {
|
||||
@@ -861,54 +683,8 @@ function contentType(filePath: string): string {
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
const PRIVATE_NETWORKS = new BlockList();
|
||||
PRIVATE_NETWORKS.addSubnet('127.0.0.0', 8, 'ipv4');
|
||||
PRIVATE_NETWORKS.addSubnet('10.0.0.0', 8, 'ipv4');
|
||||
PRIVATE_NETWORKS.addSubnet('172.16.0.0', 12, 'ipv4');
|
||||
PRIVATE_NETWORKS.addSubnet('192.168.0.0', 16, 'ipv4');
|
||||
PRIVATE_NETWORKS.addSubnet('169.254.0.0', 16, 'ipv4');
|
||||
PRIVATE_NETWORKS.addAddress('::1', 'ipv6');
|
||||
PRIVATE_NETWORKS.addSubnet('fc00::', 7, 'ipv6');
|
||||
PRIVATE_NETWORKS.addSubnet('fe80::', 10, 'ipv6');
|
||||
|
||||
function normalizeAddress(address: string): string {
|
||||
const trimmed = address.trim();
|
||||
return trimmed.toLowerCase().startsWith('::ffff:') && isIP(trimmed.slice(7)) === 4 ? trimmed.slice(7) : trimmed;
|
||||
}
|
||||
|
||||
function isPrivateAddress(address: string): boolean {
|
||||
const version = isIP(address);
|
||||
return version !== 0 && PRIVATE_NETWORKS.check(address, version === 4 ? 'ipv4' : 'ipv6');
|
||||
}
|
||||
|
||||
// Behind a reverse proxy every request comes from the proxy's address, so the
|
||||
// forwarded client IP is used instead. It is only trusted when the connection
|
||||
// itself comes from a private network, where the proxy lives; requests from
|
||||
// the internet cannot spoof it.
|
||||
function getClientAddress(request: IncomingMessage): string {
|
||||
const socketAddress = normalizeAddress(request.socket.remoteAddress || '');
|
||||
if (!isPrivateAddress(socketAddress)) {
|
||||
return socketAddress || 'unknown';
|
||||
}
|
||||
|
||||
const forwardedFor = request.headers['x-forwarded-for'];
|
||||
const realIp = request.headers['x-real-ip'];
|
||||
const chain = (Array.isArray(forwardedFor) ? forwardedFor.join(',') : forwardedFor || (Array.isArray(realIp) ? realIp[0] : realIp) || '')
|
||||
.split(',')
|
||||
.map(normalizeAddress)
|
||||
.filter((address) => isIP(address) !== 0);
|
||||
if (chain.length === 0) {
|
||||
return socketAddress;
|
||||
}
|
||||
|
||||
// Each proxy appends the address it saw, so walk from the right and take the
|
||||
// first public address; anything left of it could be forged by the client.
|
||||
for (let index = chain.length - 1; index >= 0; index -= 1) {
|
||||
if (!isPrivateAddress(chain[index])) {
|
||||
return chain[index];
|
||||
}
|
||||
}
|
||||
return chain[0];
|
||||
return request.socket.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
function trackFailedAttempt(address: string): void {
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
ProcessingItem,
|
||||
SeasonDeletePayload,
|
||||
SeasonRequestPayload,
|
||||
SeriesEpisodeRequestPayload,
|
||||
SelectOption,
|
||||
ServiceOptionsResponse,
|
||||
TitleDetailsEpisode,
|
||||
@@ -152,7 +151,6 @@ interface JellyfinItem {
|
||||
interface SonarrQueueRecord {
|
||||
id?: number;
|
||||
seriesId?: number;
|
||||
episodeId?: number;
|
||||
title?: string;
|
||||
downloadId?: string;
|
||||
series?: { title?: string; tmdbId?: number; images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }> };
|
||||
@@ -437,14 +435,12 @@ export async function getServiceOptions(config: AppConfig): Promise<ServiceOptio
|
||||
radarrRootFolders,
|
||||
radarrQualityProfiles
|
||||
] = await Promise.all([
|
||||
// A service that is down only empties its own options; the connection
|
||||
// status reports the failure.
|
||||
getJellyfinUsers(config).catch(() => []),
|
||||
getSonarrRootFolders(config).catch(() => []),
|
||||
getSonarrQualityProfiles(config).catch(() => []),
|
||||
getSonarrLanguageProfiles(config).catch(() => []),
|
||||
getRadarrRootFolders(config).catch(() => []),
|
||||
getRadarrQualityProfiles(config).catch(() => [])
|
||||
getJellyfinUsers(config),
|
||||
getSonarrRootFolders(config),
|
||||
getSonarrQualityProfiles(config),
|
||||
getSonarrLanguageProfiles(config),
|
||||
getRadarrRootFolders(config),
|
||||
getRadarrQualityProfiles(config)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -562,9 +558,7 @@ export async function authenticateJellyfinUser(config: AppConfig, userId: string
|
||||
export async function getDashboard(config: AppConfig): Promise<DashboardResponse> {
|
||||
const [connections, library, downloads, tmdb] = await Promise.all([
|
||||
getConnectionStatuses(config),
|
||||
// If Jellyfin is down the client shows its unavailable screen from the
|
||||
// connection status, so don't fail the whole dashboard here.
|
||||
getLibrarySnapshot(config).catch(() => ({ continueWatching: [], recentlyAdded: [], allItems: [] })),
|
||||
getLibrarySnapshot(config),
|
||||
getDownloads(config),
|
||||
getTmdbSnapshot(config)
|
||||
]);
|
||||
@@ -752,23 +746,8 @@ export async function getCalendar(config: AppConfig, start: Date, end: Date): Pr
|
||||
? radarrResult.value.map((item) => mapRadarrCalendarEntry(item))
|
||||
: []
|
||||
|
||||
// Link each entry to its Jellyfin item, as search does, so the details can
|
||||
// tell which episodes are already in the library.
|
||||
const library = await getLibrarySnapshot(config).catch(() => null);
|
||||
const libraryProviderIds = new Map<string, string>();
|
||||
for (const libraryItem of library?.allItems ?? []) {
|
||||
const libraryCard = jellyfinToCard(libraryItem, libraryItem.Name || 'Library');
|
||||
if (libraryCard?.tmdbId && libraryCard.providerId && (libraryCard.mediaType === 'movie' || libraryCard.mediaType === 'series')) {
|
||||
libraryProviderIds.set(`${libraryCard.mediaType}:${libraryCard.tmdbId}`, libraryCard.providerId);
|
||||
}
|
||||
}
|
||||
|
||||
const allItems = [...sonarrItems, ...radarrItems]
|
||||
.filter((item): item is CalendarEntry => Boolean(item))
|
||||
.map((item) => {
|
||||
const providerId = item.card.tmdbId ? libraryProviderIds.get(`${item.card.mediaType}:${item.card.tmdbId}`) : undefined;
|
||||
return providerId ? { ...item, card: { ...item.card, providerId } } : item;
|
||||
});
|
||||
.filter((item): item is CalendarEntry => Boolean(item));
|
||||
|
||||
const backdropCache = new Map<string, Promise<TmdbBackdropChoice>>();
|
||||
const cards = await applyHighResBackdropsToCards(config, allItems.map(i => i.card), shouldUseHighResBackdropForCard, backdropCache, 'en');
|
||||
@@ -879,8 +858,7 @@ export async function removeDownload(config: AppConfig, id: string): Promise<voi
|
||||
const queueItem = queue.find((item) => String(item.id) === rawId);
|
||||
|
||||
const response = await fetch(
|
||||
// skipRedownload: cancelling must not make Sonarr/Radarr grab another release.
|
||||
`${baseUrl}/api/v3/queue/${encodeURIComponent(rawId)}?removeFromClient=true&blocklist=false&skipRedownload=true`,
|
||||
`${baseUrl}/api/v3/queue/${encodeURIComponent(rawId)}?removeFromClient=true&blocklist=false&skipRedownload=false`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@@ -907,16 +885,6 @@ export async function removeDownload(config: AppConfig, id: string): Promise<voi
|
||||
}
|
||||
|
||||
if (source === 'sonarr' && queueItem) {
|
||||
// A season pack shows up as one queue record per episode sharing a
|
||||
// download; removing one cancels them all, so unmonitor all of them.
|
||||
const sonarrQueue = queue as SonarrQueueRecord[];
|
||||
const downloadId = (queueItem as SonarrQueueRecord).downloadId;
|
||||
const cancelledEpisodeIds = sonarrQueue
|
||||
.filter((item) => item === queueItem || (downloadId && item.downloadId === downloadId))
|
||||
.map((item) => item.episodeId)
|
||||
.filter((episodeId): episodeId is number => typeof episodeId === 'number');
|
||||
await setSonarrEpisodesMonitored(config, [...new Set(cancelledEpisodeIds)], false);
|
||||
|
||||
const seriesId = (queueItem as SonarrQueueRecord).seriesId;
|
||||
const title = (queueItem as SonarrQueueRecord).series?.title || queueItem.title;
|
||||
if (title) {
|
||||
@@ -984,30 +952,11 @@ async function removeSonarrSeriesIfEmpty(
|
||||
}
|
||||
}
|
||||
|
||||
async function withLibraryProviderId(config: AppConfig, card: MediaCard): Promise<MediaCard> {
|
||||
if (!card.tmdbId || (card.mediaType !== 'movie' && card.mediaType !== 'series' && card.mediaType !== 'tv')) {
|
||||
return card;
|
||||
}
|
||||
|
||||
const library = await getLibrarySnapshot(config).catch(() => null);
|
||||
const wantedType = card.mediaType === 'movie' ? 'movie' : 'series';
|
||||
for (const libraryItem of library?.allItems ?? []) {
|
||||
const libraryCard = jellyfinToCard(libraryItem, libraryItem.Name || 'Library');
|
||||
if (libraryCard?.tmdbId === card.tmdbId && libraryCard.mediaType === wantedType) {
|
||||
return { ...card, providerId: libraryCard.providerId };
|
||||
}
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
export async function getTitleDetails(
|
||||
config: AppConfig,
|
||||
request: TitleDetailsRequest
|
||||
): Promise<TitleDetailsResponse> {
|
||||
const { seasonId } = request;
|
||||
// Cards that don't come from Jellyfin (downloads, upcoming) are linked to
|
||||
// their library item by TMDb id.
|
||||
const card = request.card.providerId ? request.card : await withLibraryProviderId(config, request.card);
|
||||
const { card, seasonId } = request;
|
||||
const item = card.providerId ? await getJellyfinItemDetails(config, card.providerId) : null;
|
||||
const seriesItem = await resolveSeriesItem(config, card, item);
|
||||
const primaryItem = seriesItem || item;
|
||||
@@ -1882,76 +1831,6 @@ async function getSonarrSeriesRecord(
|
||||
return response.find((series) => series.id && (!tmdbId || !series.tmdbId || series.tmdbId === tmdbId)) || response[0] || null;
|
||||
}
|
||||
|
||||
// Download and delete are one-click actions, so OmniArr owns the monitoring:
|
||||
// whatever the user downloads is monitored (so Sonarr/Radarr keep looking if
|
||||
// nothing is available yet), and whatever they delete or cancel is not (so it
|
||||
// isn't downloaded again).
|
||||
|
||||
async function setSonarrEpisodesMonitored(config: AppConfig, episodeIds: number[], monitored: boolean): Promise<void> {
|
||||
if (!episodeIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchJson(`${config.sonarr.baseUrl}/api/v3/episode/monitor`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Api-Key': config.sonarr.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ episodeIds, monitored })
|
||||
});
|
||||
}
|
||||
|
||||
// Sonarr cascades a season's monitored flag to its episodes when the series is
|
||||
// saved with that flag changed.
|
||||
async function setSonarrSeasonMonitored(
|
||||
config: AppConfig,
|
||||
series: SonarrSeriesRecord,
|
||||
seasonNumber: number,
|
||||
monitored: boolean
|
||||
): Promise<void> {
|
||||
const seasons = (series.seasons || []).map((season) =>
|
||||
season.seasonNumber === seasonNumber ? { ...season, monitored } : season
|
||||
);
|
||||
|
||||
await fetchJson(`${config.sonarr.baseUrl}/api/v3/series/${encodeURIComponent(String(series.id))}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Api-Key': config.sonarr.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ...series, monitored: monitored || series.monitored, seasons })
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSonarrSeriesMonitored(config: AppConfig, seriesId: number): Promise<void> {
|
||||
const series = await getSonarrSeriesById(config, seriesId);
|
||||
if (!series?.id || series.monitored) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Seasons are sent unchanged, so no episode monitoring changes with it.
|
||||
await fetchJson(`${config.sonarr.baseUrl}/api/v3/series/${encodeURIComponent(String(series.id))}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Api-Key': config.sonarr.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ...series, monitored: true })
|
||||
});
|
||||
}
|
||||
|
||||
async function setRadarrMovieMonitored(config: AppConfig, movieId: number, monitored: boolean): Promise<void> {
|
||||
await fetchJson(`${config.radarr.baseUrl}/api/v3/movie/editor`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Api-Key': config.radarr.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ movieIds: [movieId], monitored })
|
||||
});
|
||||
}
|
||||
|
||||
async function getSonarrSeriesById(config: AppConfig, seriesId: number): Promise<SonarrSeriesRecord | null> {
|
||||
if (!config.sonarr.baseUrl || !config.sonarr.apiKey || !seriesId) {
|
||||
return null;
|
||||
@@ -2443,11 +2322,7 @@ async function buildSeasonEpisodes(
|
||||
availability,
|
||||
downloadStatus,
|
||||
downloadProgress: isDownloading ? getEpisodeQueueProgress(queueEntry) : undefined,
|
||||
// A series that is not in Sonarr yet is added by the request itself,
|
||||
// unless Jellyfin already has the episode.
|
||||
canDownload: Boolean(config.sonarr.baseUrl && config.sonarr.apiKey)
|
||||
&& (isManagedInSonarr || (Boolean(tmdbId) && !jellyfinKnowsAboutIt))
|
||||
&& availability === 'missing',
|
||||
canDownload: isManagedInSonarr && availability === 'missing',
|
||||
canDelete: hasFile,
|
||||
watched: jellyfinEpisode?.watched,
|
||||
progress: jellyfinEpisode?.progress,
|
||||
@@ -2725,10 +2600,8 @@ export async function requestMovie(config: AppConfig, tmdbId: number, user: stri
|
||||
tmdbId: details.id,
|
||||
year: Number(String(details.release_date || '').slice(0, 4)) || 0,
|
||||
minimumAvailability: 'released',
|
||||
// Radarr only reads the top-level flag; addOptions has no `monitored`.
|
||||
monitored: true,
|
||||
addOptions: {
|
||||
monitor: config.radarr.monitor === 'movieAndCollection' ? 'movieAndCollection' : 'movieOnly',
|
||||
monitored: config.radarr.monitor !== 'none',
|
||||
searchForMovie: false
|
||||
}
|
||||
})
|
||||
@@ -2756,12 +2629,6 @@ export async function requestMovie(config: AppConfig, tmdbId: number, user: stri
|
||||
return;
|
||||
}
|
||||
|
||||
// A movie that was already in Radarr may be unmonitored; without this Radarr
|
||||
// would never grab it if the search below finds nothing yet.
|
||||
if (!movie.monitored) {
|
||||
await setRadarrMovieMonitored(config, movie.id, true);
|
||||
}
|
||||
|
||||
// If movie is not downloaded, trigger a search.
|
||||
await fetchJson(`${config.radarr.baseUrl}/api/v3/command`, {
|
||||
method: 'POST',
|
||||
@@ -2812,24 +2679,19 @@ export async function requestSeries(
|
||||
}
|
||||
const sonarrRequestConfig = await resolveSonarrRequestConfig(config);
|
||||
|
||||
// Adding a series runs Sonarr's post-add monitoring after its first scan,
|
||||
// which happens after this request returns. Monitoring a season ourselves
|
||||
// afterwards would race with it, so tell Sonarr up front: send every season
|
||||
// with only the requested one monitored and `skip`, which keeps those flags.
|
||||
let seasons: SonarrSeriesRecord['seasons'];
|
||||
if (typeof seasonNumber === 'number') {
|
||||
const lookup = await fetchJson<SonarrSeriesRecord[]>(
|
||||
`${config.sonarr.baseUrl}/api/v3/series/lookup?term=${encodeURIComponent(`tvdb:${tvdbId}`)}`,
|
||||
{ headers: { 'X-Api-Key': config.sonarr.apiKey } }
|
||||
);
|
||||
const lookupSeasons = lookup[0]?.seasons || [];
|
||||
if (!lookupSeasons.some((season) => season.seasonNumber === seasonNumber)) {
|
||||
throw new Error(`Season ${seasonNumber} was not found in Sonarr.`);
|
||||
}
|
||||
seasons = lookupSeasons.map((season) => ({ seasonNumber: season.seasonNumber, monitored: season.seasonNumber === seasonNumber }));
|
||||
}
|
||||
addProcessingItem({
|
||||
source: 'sonarr',
|
||||
title: String(details.name || details.original_name || 'Series'),
|
||||
subtitle: typeof seasonNumber === 'number' ? `Season ${seasonNumber}` : 'All seasons',
|
||||
tmdbId,
|
||||
posterUrl: typeof details.poster_path === 'string' && details.poster_path
|
||||
? `${TMDB_IMAGE}/w342${details.poster_path}`
|
||||
: undefined,
|
||||
mediaType: 'series',
|
||||
user: user || 'Unknown',
|
||||
});
|
||||
|
||||
await fetchJson<SonarrSeriesRecord>(`${config.sonarr.baseUrl}/api/v3/series`, {
|
||||
const series = await fetchJson<SonarrSeriesRecord>(`${config.sonarr.baseUrl}/api/v3/series`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Api-Key': config.sonarr.apiKey,
|
||||
@@ -2842,32 +2704,55 @@ export async function requestSeries(
|
||||
languageProfileId: sonarrRequestConfig.languageProfileId,
|
||||
rootFolderPath: sonarrRequestConfig.rootFolderPath,
|
||||
seasonFolder: true,
|
||||
...(seasons
|
||||
? {
|
||||
monitored: true,
|
||||
seasons,
|
||||
// Sonarr searches the monitored (requested) season itself once the
|
||||
// series is added and its episodes exist.
|
||||
addOptions: { monitor: 'skip', searchForMissingEpisodes: true, searchForCutoffUnmetEpisodes: false }
|
||||
}
|
||||
: {
|
||||
monitored: config.sonarr.monitor !== 'none',
|
||||
addOptions: { monitor: config.sonarr.monitor, searchForMissingEpisodes: true, searchForCutoffUnmetEpisodes: true }
|
||||
})
|
||||
monitored: config.sonarr.monitor !== 'none',
|
||||
addOptions: {
|
||||
monitor: typeof seasonNumber === 'number' ? 'none' : config.sonarr.monitor,
|
||||
searchForMissingEpisodes: typeof seasonNumber !== 'number',
|
||||
searchForCutoffUnmetEpisodes: typeof seasonNumber !== 'number'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
addProcessingItem({
|
||||
source: 'sonarr',
|
||||
title: String(details.name || details.original_name || 'Series'),
|
||||
subtitle: typeof seasonNumber === 'number' ? `Season ${seasonNumber}` : 'All seasons',
|
||||
tmdbId,
|
||||
posterUrl: typeof details.poster_path === 'string' && details.poster_path
|
||||
? `${TMDB_IMAGE}/w342${details.poster_path}`
|
||||
: undefined,
|
||||
mediaType: 'series',
|
||||
user: user || 'Unknown',
|
||||
});
|
||||
if (typeof seasonNumber !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!series?.id) {
|
||||
throw new Error('Sonarr did not return the added series.')
|
||||
}
|
||||
|
||||
const seasons = (series.seasons || []).map((season) =>
|
||||
season.seasonNumber === seasonNumber
|
||||
? { ...season, monitored: true }
|
||||
: { ...season, monitored: false }
|
||||
)
|
||||
if (!seasons.some((season) => season.seasonNumber === seasonNumber)) {
|
||||
throw new Error(`Season ${seasonNumber} was not found in Sonarr.`)
|
||||
}
|
||||
|
||||
await fetchJson(`${config.sonarr.baseUrl}/api/v3/series/${encodeURIComponent(String(series.id))}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Api-Key': config.sonarr.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ...series, monitored: true, seasons })
|
||||
})
|
||||
|
||||
await fetchJson(`${config.sonarr.baseUrl}/api/v3/command`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Api-Key': config.sonarr.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'SeasonSearch',
|
||||
seriesId: series.id,
|
||||
seasonNumber,
|
||||
qualityProfileId
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
export async function requestSeason(config: AppConfig, payload: SeasonRequestPayload, user: string | null): Promise<void> {
|
||||
@@ -2941,18 +2826,6 @@ export async function requestEpisode(config: AppConfig, payload: EpisodeRequestP
|
||||
throw new Error('Episode id is required.');
|
||||
}
|
||||
|
||||
const episode = await fetchJson<{ seriesId?: number; title?: string; seasonNumber?: number; episodeNumber?: number; series?: { title?: string; tmdbId?: number } }>(
|
||||
`${config.sonarr.baseUrl}/api/v3/episode/${encodeURIComponent(String(payload.episodeId))}`,
|
||||
{ headers: { 'X-Api-Key': config.sonarr.apiKey } }
|
||||
).catch(() => null);
|
||||
|
||||
// Monitor just this episode (and the series, which Sonarr also requires),
|
||||
// not the rest of its season, so it is grabbed later if nothing is out yet.
|
||||
await setSonarrEpisodesMonitored(config, [payload.episodeId], true);
|
||||
if (episode?.seriesId) {
|
||||
await ensureSonarrSeriesMonitored(config, episode.seriesId);
|
||||
}
|
||||
|
||||
await fetchJson(`${config.sonarr.baseUrl}/api/v3/command`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -2966,77 +2839,31 @@ export async function requestEpisode(config: AppConfig, payload: EpisodeRequestP
|
||||
})
|
||||
});
|
||||
|
||||
addProcessingItem({
|
||||
source: 'sonarr',
|
||||
title: episode?.series?.title || 'Series',
|
||||
subtitle: episode?.seasonNumber != null && episode?.episodeNumber != null
|
||||
? `S${String(episode.seasonNumber).padStart(2, '0')}E${String(episode.episodeNumber).padStart(2, '0')} \u00b7 ${episode.title || ''}`
|
||||
: episode?.title || `Episode #${payload.episodeId}`,
|
||||
tmdbId: episode?.series?.tmdbId,
|
||||
mediaType: 'tv',
|
||||
user: user || 'Unknown',
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestSeriesEpisode(config: AppConfig, payload: SeriesEpisodeRequestPayload, user: string | null): Promise<void> {
|
||||
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
|
||||
throw new Error('Sonarr is not configured.');
|
||||
}
|
||||
|
||||
const headers = { 'X-Api-Key': config.sonarr.apiKey };
|
||||
const details = await tmdbFetch(
|
||||
config,
|
||||
`/tv/${payload.tmdbId}?append_to_response=external_ids&language=${encodeURIComponent(config.preferences.language)}`
|
||||
);
|
||||
if (!details) {
|
||||
throw new Error('Failed to load the series from TMDb.');
|
||||
}
|
||||
const tvdbId = Number(asRecord(details?.external_ids).tvdb_id || 0);
|
||||
if (!tvdbId) {
|
||||
throw new Error('This series does not have a TVDB ID for Sonarr.');
|
||||
}
|
||||
|
||||
// Nothing is monitored on add, so Sonarr searches for nothing but the
|
||||
// episode that is monitored and searched below.
|
||||
const sonarrRequestConfig = await resolveSonarrRequestConfig(config);
|
||||
const lookup = await fetchJson<SonarrSeriesRecord[]>(
|
||||
`${config.sonarr.baseUrl}/api/v3/series/lookup?term=${encodeURIComponent(`tvdb:${tvdbId}`)}`,
|
||||
{ headers }
|
||||
);
|
||||
const series = await fetchJson<SonarrSeriesRecord>(`${config.sonarr.baseUrl}/api/v3/series`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: details.name || details.original_name || 'Series',
|
||||
tvdbId,
|
||||
qualityProfileId: payload.qualityProfileId || sonarrRequestConfig.qualityProfileId,
|
||||
languageProfileId: sonarrRequestConfig.languageProfileId,
|
||||
rootFolderPath: sonarrRequestConfig.rootFolderPath,
|
||||
seasonFolder: true,
|
||||
monitored: true,
|
||||
seasons: (lookup[0]?.seasons || []).map((season) => ({ seasonNumber: season.seasonNumber, monitored: false })),
|
||||
addOptions: { monitor: 'none', searchForMissingEpisodes: false, searchForCutoffUnmetEpisodes: false }
|
||||
})
|
||||
});
|
||||
if (!series.id) {
|
||||
throw new Error('Sonarr did not return the added series.');
|
||||
}
|
||||
|
||||
// Sonarr lists the episodes only after its first scan of the new series.
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const episodes = await fetchJson<Array<{ id?: number; seasonNumber?: number; episodeNumber?: number }>>(
|
||||
`${config.sonarr.baseUrl}/api/v3/episode?seriesId=${series.id}`,
|
||||
{ headers }
|
||||
// Try to get episode details for the processing item
|
||||
try {
|
||||
const episode = await fetchJson<{ title?: string; seasonNumber?: number; episodeNumber?: number; series?: { title?: string; tmdbId?: number } }>(
|
||||
`${config.sonarr.baseUrl}/api/v3/episode/${encodeURIComponent(String(payload.episodeId))}`,
|
||||
{ headers: { 'X-Api-Key': config.sonarr.apiKey } }
|
||||
);
|
||||
const episode = episodes.find((entry) => entry.seasonNumber === payload.seasonNumber && entry.episodeNumber === payload.episodeNumber);
|
||||
if (episode?.id) {
|
||||
await requestEpisode(config, { episodeId: episode.id, qualityProfileId: payload.qualityProfileId }, user);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
addProcessingItem({
|
||||
source: 'sonarr',
|
||||
title: episode?.series?.title || 'Series',
|
||||
subtitle: episode?.seasonNumber != null && episode?.episodeNumber != null
|
||||
? `S${String(episode.seasonNumber).padStart(2, '0')}E${String(episode.episodeNumber).padStart(2, '0')} \u00b7 ${episode.title || ''}`
|
||||
: episode?.title || 'Episode',
|
||||
tmdbId: episode?.series?.tmdbId,
|
||||
mediaType: 'tv',
|
||||
user: user || 'Unknown',
|
||||
});
|
||||
} catch {
|
||||
addProcessingItem({
|
||||
source: 'sonarr',
|
||||
title: 'Episode',
|
||||
subtitle: `Episode #${payload.episodeId}`,
|
||||
mediaType: 'tv',
|
||||
user: user || 'Unknown',
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('Sonarr added the series but has not listed its episodes yet. Try the episode again in a moment.');
|
||||
}
|
||||
|
||||
export async function deleteSeasonFiles(config: AppConfig, payload: SeasonDeletePayload): Promise<void> {
|
||||
@@ -3048,17 +2875,10 @@ export async function deleteSeasonFiles(config: AppConfig, payload: SeasonDelete
|
||||
throw new Error('Series id and season number are required.');
|
||||
}
|
||||
|
||||
const [series, episodes, files] = await Promise.all([
|
||||
getSonarrSeriesById(config, payload.seriesId),
|
||||
const [episodes, files] = await Promise.all([
|
||||
getSonarrEpisodesBySeriesId(config, payload.seriesId),
|
||||
getSonarrEpisodeFilesBySeriesId(config, payload.seriesId)
|
||||
]);
|
||||
|
||||
// Unmonitor before deleting, or Sonarr sees the episodes as missing and
|
||||
// downloads them again.
|
||||
if (series?.id) {
|
||||
await setSonarrSeasonMonitored(config, series, payload.seasonNumber, false);
|
||||
}
|
||||
const fileIds = new Set(
|
||||
episodes
|
||||
.filter((episode) => episode.seasonNumber === payload.seasonNumber && episode.episodeFileId)
|
||||
@@ -3937,87 +3757,131 @@ async function getRadarrCalendar(config: AppConfig, start: Date, end: Date): Pro
|
||||
)
|
||||
}
|
||||
|
||||
async function testService(
|
||||
key: ConnectionStatus['key'],
|
||||
label: string,
|
||||
optional: boolean,
|
||||
required: Array<[name: string, value: string]>,
|
||||
check: () => Promise<string | void>
|
||||
): Promise<ConnectionStatus> {
|
||||
const missing = required.filter(([, value]) => !value.trim()).map(([name]) => name);
|
||||
if (missing.length === required.length) {
|
||||
return { key, label, ok: false, message: 'Not configured', configured: false, optional };
|
||||
}
|
||||
if (missing.length) {
|
||||
return { key, label, ok: false, message: `Incomplete: missing ${missing.join(' and ')}`, configured: false, optional, incomplete: true };
|
||||
async function testJellyfin(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey) {
|
||||
return {
|
||||
key: 'jellyfin',
|
||||
label: 'Jellyfin',
|
||||
ok: false,
|
||||
message: 'Not configured'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const note = await check();
|
||||
return { key, label, ok: true, message: note || 'Connected', configured: true, optional };
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
// Node reports network errors as a bare "fetch failed".
|
||||
return { key, label, ok: false, message: message === 'fetch failed' ? `Can't reach ${label}` : message, configured: true, optional };
|
||||
}
|
||||
}
|
||||
|
||||
function testJellyfin(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('jellyfin', 'Jellyfin', false, [['URL', config.jellyfin.baseUrl], ['API key', config.jellyfin.apiKey]], async () => {
|
||||
await fetchJson(`${config.jellyfin.baseUrl}/System/Info`, {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser Token="${config.jellyfin.apiKey}"`
|
||||
}
|
||||
});
|
||||
});
|
||||
return { key: 'jellyfin', label: 'Jellyfin', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'jellyfin',
|
||||
label: 'Jellyfin',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function testSonarr(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('sonarr', 'Sonarr', true, [['URL', config.sonarr.baseUrl], ['API key', config.sonarr.apiKey]], async () => {
|
||||
async function testSonarr(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
|
||||
return {
|
||||
key: 'sonarr',
|
||||
label: 'Sonarr',
|
||||
ok: false,
|
||||
message: 'Not configured'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchJson(`${config.sonarr.baseUrl}/api/v3/health`, {
|
||||
headers: {
|
||||
'X-Api-Key': config.sonarr.apiKey
|
||||
}
|
||||
});
|
||||
return warnIfKeyNotChecked('Sonarr', `${config.sonarr.baseUrl}/api/v3/health`);
|
||||
});
|
||||
return { key: 'sonarr', label: 'Sonarr', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'sonarr',
|
||||
label: 'Sonarr',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function testRadarr(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('radarr', 'Radarr', true, [['URL', config.radarr.baseUrl], ['API key', config.radarr.apiKey]], async () => {
|
||||
async function testRadarr(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
|
||||
return {
|
||||
key: 'radarr',
|
||||
label: 'Radarr',
|
||||
ok: false,
|
||||
message: 'Not configured'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchJson(`${config.radarr.baseUrl}/api/v3/health`, {
|
||||
headers: {
|
||||
'X-Api-Key': config.radarr.apiKey
|
||||
}
|
||||
});
|
||||
return warnIfKeyNotChecked('Radarr', `${config.radarr.baseUrl}/api/v3/health`);
|
||||
});
|
||||
}
|
||||
|
||||
// Sonarr and Radarr can skip authentication for local addresses, and then any
|
||||
// key passes. Retry with a made-up key so a typo doesn't look like a success.
|
||||
async function warnIfKeyNotChecked(label: string, url: string): Promise<string | undefined> {
|
||||
try {
|
||||
await fetchJson(url, { headers: { 'X-Api-Key': 'aliceflix-invalid-key' } });
|
||||
return `Reachable, but ${label} accepts any API key from here, so the key can't be verified`;
|
||||
} catch {
|
||||
return undefined;
|
||||
return { key: 'radarr', label: 'Radarr', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'radarr',
|
||||
label: 'Radarr',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function testTmdb(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('tmdb', 'TMDb', true, [['token', getTmdbToken(config)]], async () => {
|
||||
// tmdbFetch swallows errors and returns null, so treat that as a failure.
|
||||
if (!await tmdbFetch(config, '/configuration')) {
|
||||
throw new Error('TMDb rejected the key or is unreachable');
|
||||
}
|
||||
});
|
||||
async function testTmdb(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!getTmdbToken(config)) {
|
||||
return {
|
||||
key: 'tmdb',
|
||||
label: 'TMDb',
|
||||
ok: false,
|
||||
message: 'Missing token'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await tmdbFetch(config, '/configuration');
|
||||
return { key: 'tmdb', label: 'TMDb', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'tmdb',
|
||||
label: 'TMDb',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function testTvdb(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('tvdb', 'TVDB', true, [['API key', getTvdbApiKey(config)]], async () => {
|
||||
async function testTvdb(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!getTvdbApiKey(config)) {
|
||||
return {
|
||||
key: 'tvdb',
|
||||
label: 'TVDB',
|
||||
ok: false,
|
||||
message: 'Missing API key'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await getTvdbToken(config);
|
||||
});
|
||||
return { key: 'tvdb', label: 'TVDB', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'tvdb',
|
||||
label: 'TVDB',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function jellyfinFetch<T>(config: AppConfig, pathWithQuery: string): Promise<T> {
|
||||
@@ -4044,17 +3908,11 @@ async function tmdbFetch(config: AppConfig, pathWithQuery: string): Promise<Reco
|
||||
return null;
|
||||
}
|
||||
|
||||
// TMDb hands out two credentials: the short v3 "API Key", which must go in
|
||||
// the query string, and the long "API Read Access Token", sent as a bearer.
|
||||
const isApiKey = /^[0-9a-f]{32}$/i.test(token);
|
||||
const url = new URL(`https://api.themoviedb.org/3${pathWithQuery}`);
|
||||
if (isApiKey) {
|
||||
url.searchParams.set('api_key', token);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchJson<Record<string, unknown>>(url.toString(), {
|
||||
headers: isApiKey ? {} : { Authorization: `Bearer ${token}` }
|
||||
return await fetchJson<Record<string, unknown>>(`https://api.themoviedb.org/3${pathWithQuery}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`TMDb fetch failed for ${pathWithQuery}:`, err);
|
||||
@@ -4114,13 +3972,6 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// Proxies answer with a whole HTML page; it says nothing the status does not.
|
||||
if (/^\s*<(!doctype|html)/i.test(detail)) detail = '';
|
||||
|
||||
if ([502, 503, 504].includes(response.status)) {
|
||||
throw new Error(`${response.status} ${response.statusText}: the service did not answer. Check that it is running and that the URL points to it.`);
|
||||
}
|
||||
|
||||
throw new Error(detail
|
||||
? `${response.status} ${response.statusText}: ${detail}`
|
||||
: `${response.status} ${response.statusText}`);
|
||||
@@ -4153,10 +4004,7 @@ function jellyfinToCard(
|
||||
const backdropItemId = item.ParentBackdropItemId || item.Id;
|
||||
const backdropTag = item.ParentBackdropImageTags?.[0] || item.BackdropImageTags?.[0] || posterTag;
|
||||
const progress = Math.round(item.UserData?.PlayedPercentage || 0);
|
||||
// An episode's own TMDb id is not its series' id, and artwork is looked up per series.
|
||||
const tmdbId = item.Type === 'Episode'
|
||||
? (item.SeriesId ? tmdbIdByItemId?.get(item.SeriesId) : undefined)
|
||||
: Number(item.ProviderIds?.Tmdb) || (item.SeriesId ? tmdbIdByItemId?.get(item.SeriesId) : undefined);
|
||||
const tmdbId = Number(item.ProviderIds?.Tmdb) || (item.SeriesId ? tmdbIdByItemId?.get(item.SeriesId) : undefined);
|
||||
const episodeCode = formatEpisodeCode(item.ParentIndexNumber, item.IndexNumber);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import type { WishlistEntry } from '../../shared/contracts.js';
|
||||
|
||||
interface StoredAdmin {
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
@@ -19,75 +17,37 @@ interface StoredState {
|
||||
admin: StoredAdmin | null;
|
||||
config: EncryptedConfig | null;
|
||||
configKey: string | null;
|
||||
/** Each profile's wishlist, keyed by its Jellyfin user id ('admin' for the administrator). */
|
||||
wishlists: Record<string, WishlistEntry[]>;
|
||||
}
|
||||
|
||||
const DATA_DIR = resolve(process.env.OMNIARR_DATA_DIR || resolve(process.cwd(), 'data'));
|
||||
const STORE_FILE = resolve(DATA_DIR, 'omniarr.secure.json');
|
||||
// The store used to be called aliceflix.secure.json; it is still read when the
|
||||
// new file does not exist yet, so a rename never looks like a fresh install.
|
||||
const LEGACY_STORE_FILE = resolve(DATA_DIR, 'aliceflix.secure.json');
|
||||
|
||||
const EMPTY_STATE: StoredState = {
|
||||
version: 1,
|
||||
admin: null,
|
||||
config: null,
|
||||
configKey: null,
|
||||
wishlists: {}
|
||||
configKey: null
|
||||
};
|
||||
|
||||
// Every write reads the file, changes it and writes it back, so writes are
|
||||
// queued one after another; two profiles saving at once would otherwise drop
|
||||
// one of the changes.
|
||||
let writeQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
async function readStoreFile(): Promise<string | null> {
|
||||
for (const file of [STORE_FILE, LEGACY_STORE_FILE]) {
|
||||
try {
|
||||
return await readFile(file, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only a store that does not exist counts as a fresh install. Any other read or
|
||||
// parse failure is thrown: treating it as empty would reopen the master
|
||||
// password setup and let the next visitor overwrite the real one.
|
||||
export async function loadState(): Promise<StoredState> {
|
||||
const raw = await readStoreFile();
|
||||
if (raw === null) return EMPTY_STATE;
|
||||
try {
|
||||
const raw = await readFile(STORE_FILE, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<StoredState>;
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<StoredState>;
|
||||
|
||||
return {
|
||||
version: parsed.configKey ? 2 : 1,
|
||||
admin: parsed.admin ?? null,
|
||||
config: parsed.config ?? null,
|
||||
configKey: typeof parsed.configKey === 'string' ? parsed.configKey : null,
|
||||
wishlists: parsed.wishlists && typeof parsed.wishlists === 'object' ? parsed.wishlists : {}
|
||||
};
|
||||
return {
|
||||
version: parsed.configKey ? 2 : 1,
|
||||
admin: parsed.admin ?? null,
|
||||
config: parsed.config ?? null,
|
||||
configKey: typeof parsed.configKey === 'string' ? parsed.configKey : null
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveState(state: StoredState): Promise<void> {
|
||||
await mkdir(dirname(STORE_FILE), { recursive: true });
|
||||
// Written to a temporary file first, so a crash mid-write never leaves a
|
||||
// half-written store behind.
|
||||
const temporaryFile = `${STORE_FILE}.tmp`;
|
||||
await writeFile(temporaryFile, JSON.stringify(state, null, 2), 'utf8');
|
||||
await rename(temporaryFile, STORE_FILE);
|
||||
}
|
||||
|
||||
export function updateState(change: (state: StoredState) => StoredState): Promise<StoredState> {
|
||||
const next = writeQueue.then(async () => {
|
||||
const updated = change(await loadState());
|
||||
await saveState(updated);
|
||||
return updated;
|
||||
});
|
||||
writeQueue = next.catch(() => undefined);
|
||||
return next;
|
||||
await writeFile(STORE_FILE, JSON.stringify(state, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export async function readEncryptedConfig(): Promise<EncryptedConfig | null> {
|
||||
@@ -100,20 +60,20 @@ export async function saveAdminAndConfig(
|
||||
config: EncryptedConfig,
|
||||
configKey: string
|
||||
): Promise<void> {
|
||||
await updateState((state) => ({
|
||||
...state,
|
||||
await saveState({
|
||||
version: 2,
|
||||
admin,
|
||||
config,
|
||||
configKey
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEncryptedConfig(config: EncryptedConfig): Promise<void> {
|
||||
await updateState((state) => ({
|
||||
const state = await loadState();
|
||||
await saveState({
|
||||
...state,
|
||||
config
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export async function readAdmin(): Promise<StoredAdmin | null> {
|
||||
@@ -122,29 +82,8 @@ export async function readAdmin(): Promise<StoredAdmin | null> {
|
||||
}
|
||||
|
||||
export async function migrateConfig(config: EncryptedConfig, configKey: string): Promise<void> {
|
||||
await updateState((state) => ({ ...state, version: 2, config, configKey }));
|
||||
}
|
||||
|
||||
export async function readWishlist(owner: string): Promise<WishlistEntry[]> {
|
||||
const state = await loadState();
|
||||
return state.wishlists[owner] ?? [];
|
||||
}
|
||||
|
||||
export async function updateWishlist(owner: string, change: (entries: WishlistEntry[]) => WishlistEntry[]): Promise<WishlistEntry[]> {
|
||||
const state = await updateState((current) => ({
|
||||
...current,
|
||||
wishlists: { ...current.wishlists, [owner]: change(current.wishlists[owner] ?? []) }
|
||||
}));
|
||||
return state.wishlists[owner];
|
||||
}
|
||||
|
||||
export async function readAllWishlists(): Promise<Record<string, WishlistEntry[]>> {
|
||||
const state = await loadState();
|
||||
return state.wishlists;
|
||||
}
|
||||
|
||||
export async function restoreBackup(config: EncryptedConfig, wishlists: Record<string, WishlistEntry[]>): Promise<void> {
|
||||
await updateState((state) => ({ ...state, config, wishlists }));
|
||||
await saveState({ ...state, version: 2, config, configKey });
|
||||
}
|
||||
|
||||
export type { EncryptedConfig, StoredAdmin, StoredState };
|
||||
@@ -12,7 +12,6 @@ export type AppRoute =
|
||||
| 'library'
|
||||
| 'calendar'
|
||||
| 'downloads'
|
||||
| 'wishlist'
|
||||
| 'settings';
|
||||
|
||||
export interface ServiceConfig {
|
||||
@@ -87,12 +86,6 @@ export interface ConnectionStatus {
|
||||
label: string;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
/** Every field the service needs is filled in. */
|
||||
configured: boolean;
|
||||
/** The app works without it; only Jellyfin is required. */
|
||||
optional: boolean;
|
||||
/** Some of its fields are filled in but not all of them. */
|
||||
incomplete?: boolean;
|
||||
}
|
||||
|
||||
export interface DiskSpaceStatus {
|
||||
@@ -304,15 +297,6 @@ export interface EpisodeRequestPayload {
|
||||
qualityProfileId?: number;
|
||||
}
|
||||
|
||||
// An episode of a series that is not in Sonarr yet: it is added with nothing
|
||||
// monitored but this episode.
|
||||
export interface SeriesEpisodeRequestPayload {
|
||||
tmdbId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
qualityProfileId?: number;
|
||||
}
|
||||
|
||||
export interface SeasonDeletePayload {
|
||||
seriesId: number;
|
||||
seasonNumber: number;
|
||||
@@ -357,29 +341,6 @@ export interface AuthResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface WishlistEntry {
|
||||
card: MediaCard;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface WishlistResponse {
|
||||
items: WishlistEntry[];
|
||||
}
|
||||
|
||||
export interface WishlistTogglePayload {
|
||||
card: MediaCard;
|
||||
wishlisted: boolean;
|
||||
}
|
||||
|
||||
/** What the administration exports: the service settings and every profile's wishlist. */
|
||||
export interface BackupFile {
|
||||
app: 'omniarr';
|
||||
format: 1;
|
||||
exportedAt: string;
|
||||
config: AppConfig;
|
||||
wishlists: Record<string, WishlistEntry[]>;
|
||||
}
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
error: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
AppConfig,
|
||||
AuthPayload,
|
||||
AuthResponse,
|
||||
BackupFile,
|
||||
CalendarResponse,
|
||||
ConnectionStatus,
|
||||
DashboardResponse,
|
||||
@@ -18,12 +17,9 @@ import type {
|
||||
EpisodeWatchStateRequest,
|
||||
EpisodeRequestPayload,
|
||||
SeasonDeletePayload,
|
||||
SeriesEpisodeRequestPayload,
|
||||
SeasonRequestPayload,
|
||||
TitleDetailsRequest,
|
||||
TitleDetailsResponse,
|
||||
WishlistResponse,
|
||||
WishlistTogglePayload
|
||||
TitleDetailsResponse
|
||||
} from '../shared/contracts'
|
||||
|
||||
async function request<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
@@ -86,7 +82,7 @@ export const api = {
|
||||
}),
|
||||
getSettings: () => request<AppConfig>('/api/settings'),
|
||||
saveSettings: (payload: AppConfig) =>
|
||||
request<{ config: AppConfig; connections: ConnectionStatus[] }>('/api/settings', {
|
||||
request<{ config: AppConfig }>('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
@@ -96,18 +92,6 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
getWishlist: () => request<WishlistResponse>('/api/wishlist'),
|
||||
setWishlisted: (payload: WishlistTogglePayload) =>
|
||||
request<WishlistResponse>('/api/wishlist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
exportBackup: () => request<BackupFile>('/api/admin/backup'),
|
||||
importBackup: (backup: unknown) =>
|
||||
request<{ config: AppConfig; connections: ConnectionStatus[] }>('/api/admin/backup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(backup),
|
||||
}),
|
||||
getDashboard: () => request<DashboardResponse>('/api/dashboard'),
|
||||
getDiskSpace: () => request<DiskSpaceStatus | null>('/api/storage'),
|
||||
getCalendar: (start: Date, end: Date) => request<CalendarResponse>(`/api/calendar?start=${start.toISOString()}&end=${end.toISOString()}`),
|
||||
@@ -158,11 +142,6 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...payload, user }),
|
||||
}),
|
||||
requestSeriesEpisode: (payload: SeriesEpisodeRequestPayload, user: string | null) =>
|
||||
request<{ ok: boolean }>('/api/requests/series-episode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...payload, user }),
|
||||
}),
|
||||
deleteSeason: (payload: SeasonDeletePayload) =>
|
||||
request<{ ok: boolean }>('/api/seasons/delete', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -48,7 +48,7 @@ img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.omniarr-app {
|
||||
.reiverr-app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -100,7 +100,7 @@ img {
|
||||
animation: entryStageBackEnter 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.omniarr-sidebar {
|
||||
.reiverr-sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 30;
|
||||
@@ -112,7 +112,7 @@ img {
|
||||
filter: drop-shadow(0 1.4rem 2rem rgba(0, 0, 0, 0.34));
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover {
|
||||
.reiverr-sidebar:hover {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
@@ -206,24 +206,24 @@ img {
|
||||
transition: opacity 180ms ease, transform 180ms ease, max-width 180ms ease;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover .sidebar-profile-copy,
|
||||
.omniarr-sidebar:hover .sidebar-link > span:last-child,
|
||||
.omniarr-sidebar:hover .sidebar-settings > span:last-child {
|
||||
.reiverr-sidebar:hover .sidebar-profile-copy,
|
||||
.reiverr-sidebar:hover .sidebar-link > span:last-child,
|
||||
.reiverr-sidebar:hover .sidebar-settings > span:last-child {
|
||||
opacity: 1;
|
||||
max-width: 12rem;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover .sidebar-profile,
|
||||
.omniarr-sidebar:hover .sidebar-link,
|
||||
.omniarr-sidebar:hover .sidebar-settings {
|
||||
.reiverr-sidebar:hover .sidebar-profile,
|
||||
.reiverr-sidebar:hover .sidebar-link,
|
||||
.reiverr-sidebar:hover .sidebar-settings {
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:not(:hover) .sidebar-profile,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-link,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-settings {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-profile,
|
||||
.reiverr-sidebar:not(:hover) .sidebar-link,
|
||||
.reiverr-sidebar:not(:hover) .sidebar-settings {
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
@@ -235,12 +235,12 @@ img {
|
||||
width: 2.9rem;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:not(:hover) .sidebar-profile {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-profile {
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:not(:hover) .sidebar-profile-copy {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-profile-copy {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -289,10 +289,6 @@ img {
|
||||
background: #ff5d73;
|
||||
}
|
||||
|
||||
.status-dot.off {
|
||||
background: #4a5468;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
@@ -360,18 +356,18 @@ img {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover .sidebar-disk {
|
||||
.reiverr-sidebar:hover .sidebar-disk {
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover .sidebar-disk-copy {
|
||||
.reiverr-sidebar:hover .sidebar-disk-copy {
|
||||
opacity: 1;
|
||||
max-width: 12rem;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.omniarr-sidebar:not(:hover) .sidebar-disk {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-disk {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
@@ -407,7 +403,7 @@ img {
|
||||
padding: 0.525rem;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover .sidebar-settings {
|
||||
.reiverr-sidebar:hover .sidebar-settings {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -488,8 +484,8 @@ img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.omniarr-sidebar:not(:hover) .sidebar-link,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-settings {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-link,
|
||||
.reiverr-sidebar:not(:hover) .sidebar-settings {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -504,13 +500,13 @@ img {
|
||||
padding: 0.525rem;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover .sidebar-footer > .sidebar-disk,
|
||||
.omniarr-sidebar:hover .sidebar-footer > .sidebar-settings {
|
||||
.reiverr-sidebar:hover .sidebar-footer > .sidebar-disk,
|
||||
.reiverr-sidebar:hover .sidebar-footer > .sidebar-settings {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:not(:hover) .sidebar-footer > .sidebar-disk,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-footer > .sidebar-settings {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-footer > .sidebar-disk,
|
||||
.reiverr-sidebar:not(:hover) .sidebar-footer > .sidebar-settings {
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
@@ -646,7 +642,7 @@ img {
|
||||
/* Keep the desktop layout usable on laptop-sized viewports without switching
|
||||
to the mobile presentation. */
|
||||
@media (min-width: 981px) and (max-width: 1440px), (min-width: 981px) and (max-height: 900px) {
|
||||
.omniarr-app {
|
||||
.reiverr-app {
|
||||
width: 121.95122%;
|
||||
height: 121.95122%;
|
||||
zoom: 0.82;
|
||||
@@ -1373,7 +1369,7 @@ img {
|
||||
box-shadow: -3px 6px 13px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.omniarr-sidebar:not(:hover) .sidebar-settings {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-settings {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -1435,7 +1431,7 @@ img {
|
||||
animation: modalSurfaceIn 260ms cubic-bezier(0.2, 0.9, 0.2, 1);
|
||||
}
|
||||
|
||||
.omniarr-title-modal {
|
||||
.reiverr-title-modal {
|
||||
width: min(88rem, 100%);
|
||||
max-height: calc(100vh - 4rem);
|
||||
border-radius: 1.75rem;
|
||||
@@ -1463,7 +1459,7 @@ img {
|
||||
}
|
||||
|
||||
|
||||
.omniarr-modal-close {
|
||||
.reiverr-modal-close {
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
}
|
||||
@@ -1857,55 +1853,6 @@ img {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Movie header: every control is a plain icon (heart, play, delete, request)
|
||||
like the ones in a series' season bar, centred on one line and split by
|
||||
dividers instead of sitting in boxes. */
|
||||
.title-details-summary-header > .title-season-action,
|
||||
.title-details-summary-header > .title-season-divider,
|
||||
.title-details-summary-header .title-summary-header-play,
|
||||
.title-details-summary-header .title-summary-header-delete,
|
||||
.title-details-summary-header > .secondary-button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.title-details-summary-header .title-summary-header-play,
|
||||
.title-details-summary-header .title-summary-header-delete,
|
||||
.title-details-summary-header > .secondary-button {
|
||||
min-width: 1.9rem;
|
||||
width: auto;
|
||||
min-height: 1.9rem;
|
||||
height: auto;
|
||||
margin-top: 0;
|
||||
padding: 0.18rem 0.4rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: rgba(237, 242, 255, 0.82);
|
||||
font-weight: 600;
|
||||
transition: color 180ms ease, transform 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.title-details-summary-header .title-summary-header-delete {
|
||||
padding: 0.18rem;
|
||||
}
|
||||
|
||||
.title-details-summary-header .title-summary-header-play:hover,
|
||||
.title-details-summary-header .title-summary-header-play:focus-visible,
|
||||
.title-details-summary-header .title-summary-header-delete:hover,
|
||||
.title-details-summary-header .title-summary-header-delete:focus-visible,
|
||||
.title-details-summary-header > .secondary-button:hover,
|
||||
.title-details-summary-header > .secondary-button:focus-visible {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.title-details-summary-header .title-action-icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
.title-movie-request-processing,
|
||||
.title-movie-request-progress {
|
||||
display: inline-flex;
|
||||
@@ -3942,55 +3889,6 @@ img {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.settings-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-card-footer {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.settings-card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
color: #c4cee0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-card-check {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #68d391;
|
||||
color: #07120d;
|
||||
transition: transform 180ms ease, filter 180ms ease;
|
||||
}
|
||||
|
||||
.settings-card-check svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
|
||||
.settings-card-check:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.settings-card-check:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.settings-card label,
|
||||
.auth-form label {
|
||||
display: grid;
|
||||
@@ -4074,7 +3972,9 @@ img {
|
||||
.auth-wallpaper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(145deg, #18263b, #080b12 70%);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.7)),
|
||||
url('/reiverr/server-wallpaper.png') center/cover no-repeat;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@@ -4252,30 +4152,6 @@ img {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.jellyfin-unavailable {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.6rem;
|
||||
width: min(100%, 32rem);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1.3rem;
|
||||
color: #c4cee0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jellyfin-unavailable p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.jellyfin-unavailable-detail {
|
||||
color: #72809c;
|
||||
font-size: 0.86rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.administration-layout {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
@@ -4284,11 +4160,10 @@ img {
|
||||
}
|
||||
|
||||
.administration-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2.9rem 1fr 2.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
max-width: 76rem;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
@@ -4746,7 +4621,7 @@ img {
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.omniarr-sidebar {
|
||||
.reiverr-sidebar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
@@ -4758,11 +4633,11 @@ img {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover {
|
||||
.reiverr-sidebar:hover {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.omniarr-sidebar.is-open {
|
||||
.reiverr-sidebar.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
@@ -4770,7 +4645,7 @@ img {
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.omniarr-sidebar .sidebar-wallpaper {
|
||||
.reiverr-sidebar .sidebar-wallpaper {
|
||||
inset: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -4815,9 +4690,9 @@ img {
|
||||
|
||||
.sidebar-profile,
|
||||
.sidebar-link,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-profile,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-link,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-settings {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-profile,
|
||||
.reiverr-sidebar:not(:hover) .sidebar-link,
|
||||
.reiverr-sidebar:not(:hover) .sidebar-settings {
|
||||
gap: 0.85rem;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
@@ -4826,9 +4701,9 @@ img {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover .sidebar-profile,
|
||||
.omniarr-sidebar:hover .sidebar-link,
|
||||
.omniarr-sidebar:hover .sidebar-settings {
|
||||
.reiverr-sidebar:hover .sidebar-profile,
|
||||
.reiverr-sidebar:hover .sidebar-link,
|
||||
.reiverr-sidebar:hover .sidebar-settings {
|
||||
justify-content: flex-start;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -4836,9 +4711,9 @@ img {
|
||||
.sidebar-profile-copy,
|
||||
.sidebar-link > span:last-child,
|
||||
.sidebar-settings > span:last-child,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-profile-copy,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-link > span:last-child,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-settings > span:last-child {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-profile-copy,
|
||||
.reiverr-sidebar:not(:hover) .sidebar-link > span:last-child,
|
||||
.reiverr-sidebar:not(:hover) .sidebar-settings > span:last-child {
|
||||
position: static;
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
@@ -4854,21 +4729,21 @@ img {
|
||||
}
|
||||
|
||||
.sidebar-disk,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-disk {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-disk {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebar-disk-copy,
|
||||
.omniarr-sidebar:not(:hover) .sidebar-disk-copy {
|
||||
.reiverr-sidebar:not(:hover) .sidebar-disk-copy {
|
||||
position: static;
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover .sidebar-footer > .sidebar-disk {
|
||||
.reiverr-sidebar:hover .sidebar-footer > .sidebar-disk {
|
||||
gap: 0.8rem;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
@@ -4878,7 +4753,7 @@ img {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.omniarr-sidebar:hover .sidebar-disk-copy {
|
||||
.reiverr-sidebar:hover .sidebar-disk-copy {
|
||||
position: static;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
@@ -4887,10 +4762,10 @@ img {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.omniarr-sidebar.is-open:not(:hover) .sidebar-profile-copy,
|
||||
.omniarr-sidebar.is-open:not(:hover) .sidebar-link > span:last-child,
|
||||
.omniarr-sidebar.is-open:not(:hover) .sidebar-settings > span:last-child,
|
||||
.omniarr-sidebar.is-open:not(:hover) .sidebar-disk-copy {
|
||||
.reiverr-sidebar.is-open:not(:hover) .sidebar-profile-copy,
|
||||
.reiverr-sidebar.is-open:not(:hover) .sidebar-link > span:last-child,
|
||||
.reiverr-sidebar.is-open:not(:hover) .sidebar-settings > span:last-child,
|
||||
.reiverr-sidebar.is-open:not(:hover) .sidebar-disk-copy {
|
||||
display: grid;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
@@ -4898,7 +4773,7 @@ img {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.omniarr-sidebar.is-open:not(:hover) .sidebar-link.active {
|
||||
.reiverr-sidebar.is-open:not(:hover) .sidebar-link.active {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
@@ -4909,12 +4784,12 @@ img {
|
||||
box-shadow: -3px 6px 13px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.omniarr-sidebar.is-open:not(:hover) .sidebar-link.active::before {
|
||||
.reiverr-sidebar.is-open:not(:hover) .sidebar-link.active::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.omniarr-sidebar.is-open:not(:hover) .sidebar-footer > .sidebar-disk,
|
||||
.omniarr-sidebar.is-open:not(:hover) .sidebar-footer > .sidebar-settings {
|
||||
.reiverr-sidebar.is-open:not(:hover) .sidebar-footer > .sidebar-disk,
|
||||
.reiverr-sidebar.is-open:not(:hover) .sidebar-footer > .sidebar-settings {
|
||||
gap: 0.8rem;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
@@ -5518,181 +5393,6 @@ img {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Spans the header's content box (below its top padding) so the picker
|
||||
centres on the same line as the month and arrows. */
|
||||
.calendar-timezone {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* The trigger, menu and options follow the season selector in the title view. */
|
||||
.calendar-timezone-native {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-timezone-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.1rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #d6d8de;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.calendar-timezone-button:hover,
|
||||
.calendar-timezone-button:focus-visible,
|
||||
.calendar-timezone-button[aria-expanded="true"] {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-timezone-button .title-season-select-chevron {
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
|
||||
.calendar-timezone-button[aria-expanded="true"] .title-season-select-chevron {
|
||||
transform: translateY(0.14rem) rotate(225deg);
|
||||
}
|
||||
|
||||
.calendar-timezone-icon {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.calendar-timezone-offset {
|
||||
color: #72809c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-timezone-menu {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
top: calc(100% + 0.55rem);
|
||||
right: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
width: min(20rem, calc(100vw - 3rem));
|
||||
padding: 0.55rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 0.8rem;
|
||||
background: rgba(18, 22, 31, 0.98);
|
||||
box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.calendar-timezone-search {
|
||||
box-sizing: border-box;
|
||||
padding: 0.62rem 0.85rem;
|
||||
border-radius: 0.55rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.calendar-timezone-options {
|
||||
display: grid;
|
||||
gap: 0.38rem;
|
||||
max-height: min(18rem, 50vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.calendar-timezone-options button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.68rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0.55rem;
|
||||
background: transparent;
|
||||
color: #b7c1d6;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.calendar-timezone-options button:hover,
|
||||
.calendar-timezone-options button:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.calendar-timezone-options button.is-selected {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-timezone-empty {
|
||||
margin: 0;
|
||||
padding: 0.68rem 0.75rem;
|
||||
color: #72809c;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* On phones the picker mirrors the menu button: a square globe button in
|
||||
the opposite corner, on the month row. An invisible native select on top
|
||||
opens the phone's own picker, like the season selector. */
|
||||
@media (max-width: 980px) {
|
||||
.calendar-timezone {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.calendar-timezone-button {
|
||||
justify-content: center;
|
||||
width: 2.9rem;
|
||||
height: 2.9rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(10, 12, 16, 0.86);
|
||||
box-shadow: 0 0.7rem 1.8rem rgba(0, 0, 0, 0.34);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-timezone-button > :not(.calendar-timezone-icon),
|
||||
.calendar-timezone-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-timezone-button .calendar-timezone-icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
color: #f5f7fb;
|
||||
}
|
||||
|
||||
.calendar-timezone-native {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -5989,87 +5689,3 @@ img {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.download-card.is-openable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-card.is-openable:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.6);
|
||||
outline-offset: 4px;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/* Wishlist */
|
||||
.media-wishlist-button {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
background: rgba(6, 8, 12, 0.55);
|
||||
color: #f3f6ff;
|
||||
backdrop-filter: blur(8px);
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease, transform 180ms ease, color 180ms ease;
|
||||
}
|
||||
|
||||
.media-wishlist-button .title-action-icon {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
}
|
||||
|
||||
.media-tile:hover .media-wishlist-button,
|
||||
.media-tile:focus-within .media-wishlist-button,
|
||||
.media-wishlist-button.is-wishlisted {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-wishlist-button:hover,
|
||||
.media-wishlist-button:focus-visible {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.media-wishlist-button.is-wishlisted,
|
||||
.title-wishlist-button.is-wishlisted {
|
||||
color: #ff5c7a;
|
||||
}
|
||||
|
||||
/* Phones and touch screens have no hover, and a heart on every poster is noise;
|
||||
the wishlist stays reachable from the title details. */
|
||||
@media (hover: none), (max-width: 980px) {
|
||||
.media-wishlist-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-backup-copy {
|
||||
margin: 0;
|
||||
color: #98a8c4;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.settings-backup-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-backup-actions .secondary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.administration-layout > .notice,
|
||||
.administration-layout > .error-banner {
|
||||
max-width: 76rem;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
@@ -2,9 +2,6 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
import { installTapGuard } from './tapGuard'
|
||||
|
||||
installTapGuard()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// Some mobile browsers (notably iOS, and installed web apps) fire a click when
|
||||
// a scroll gesture ends on top of a poster, which opens it by accident. This
|
||||
// swallows clicks that come from a touch that moved or from a scroll that was
|
||||
// still settling, so only real taps reach the app.
|
||||
|
||||
const TAP_SLOP_PX = 10
|
||||
const SCROLL_SETTLE_MS = 120
|
||||
const TOUCH_CLICK_WINDOW_MS = 800
|
||||
|
||||
export function installTapGuard() {
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let moved = false
|
||||
let lastTouchEndAt = 0
|
||||
let lastScrollAt = 0
|
||||
|
||||
window.addEventListener('touchstart', (event) => {
|
||||
const touch = event.touches[0]
|
||||
startX = touch?.clientX ?? 0
|
||||
startY = touch?.clientY ?? 0
|
||||
moved = false
|
||||
}, { capture: true, passive: true })
|
||||
|
||||
window.addEventListener('touchmove', (event) => {
|
||||
const touch = event.touches[0]
|
||||
if (touch && Math.hypot(touch.clientX - startX, touch.clientY - startY) > TAP_SLOP_PX) {
|
||||
moved = true
|
||||
}
|
||||
}, { capture: true, passive: true })
|
||||
|
||||
window.addEventListener('touchend', () => {
|
||||
lastTouchEndAt = Date.now()
|
||||
}, { capture: true, passive: true })
|
||||
|
||||
// Scroll events don't bubble, so listen in the capture phase to see every
|
||||
// scroll container (page, carousels, episode lists).
|
||||
document.addEventListener('scroll', () => {
|
||||
lastScrollAt = Date.now()
|
||||
}, { capture: true, passive: true })
|
||||
|
||||
window.addEventListener('click', (event) => {
|
||||
const now = Date.now()
|
||||
if (now - lastTouchEndAt > TOUCH_CLICK_WINDOW_MS) {
|
||||
return
|
||||
}
|
||||
if (moved || now - lastScrollAt < SCROLL_SETTLE_MS) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}, { capture: true })
|
||||
}
|
||||