Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24151d27f7 | ||
|
|
f3cc8cba0a | ||
|
|
53f6a890b9 | ||
|
|
7dbe6f61d0 | ||
|
|
fd460df243 | ||
|
|
2e5cf22626 | ||
|
|
092d639dd9 | ||
|
|
fc1f3202e8 | ||
|
|
3bf04f2abd | ||
|
|
38fb66d31e | ||
|
|
8b3801539e | ||
|
|
101ffae641 | ||
|
|
bc9017f54d | ||
|
|
b90dedfafc | ||
|
|
ee23de6d2f | ||
|
|
04980f93ab | ||
|
|
2a3213d706 | ||
|
|
c36a4ba2b8 | ||
|
|
ae3818304b | ||
|
|
b3882de893 | ||
|
|
af880a6c83 | ||
|
|
eb5502a16f | ||
|
|
50f06dabbf | ||
|
|
ddbc377d79 | ||
|
|
1e2c6f46ab | ||
|
|
dd1378cef5 | ||
|
|
e684456bba | ||
|
|
6bd3f015d6 | ||
|
|
7bd4c4d1d4 | ||
|
|
3005e577d7 | ||
|
|
2d97be0d6c | ||
|
|
966639df43 | ||
|
|
33e7691b94 | ||
|
|
d7b83d22ce | ||
|
|
b6eac0f364 | ||
|
|
572a7db4aa | ||
|
|
862cd2d6ac | ||
|
|
6f23abaa6d | ||
|
|
81518df89a | ||
|
|
604335a16d | ||
|
|
57e7d68092 | ||
|
|
d3622f7bb3 | ||
|
|
78ccea94bd | ||
|
|
a487ab4506 | ||
|
|
c93467b3ac | ||
|
|
c709e8596a | ||
|
|
26e49e73a5 | ||
|
|
d954328911 | ||
|
|
3e43586acc | ||
|
|
7040da1334 | ||
|
|
9d10e6a88c | ||
|
|
8942eb8b7c | ||
|
|
812fb2f087 | ||
|
|
c60667ba63 | ||
|
|
7d6831483a | ||
|
|
58c5c27929 | ||
|
|
bcd2bb7c96 | ||
|
|
5a72f5f86e | ||
|
|
7d4455ba6b | ||
|
|
2e7458457e |
@@ -773,6 +773,42 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "lunks",
|
||||||
|
"name": "Pedro Nascimento",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4",
|
||||||
|
"profile": "http://twitter.com/lunks/",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "owenvoke",
|
||||||
|
"name": "Owen Voke",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4",
|
||||||
|
"profile": "https://voke.dev",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Nimelrian",
|
||||||
|
"name": "Sebastian K",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4",
|
||||||
|
"profile": "https://github.com/Nimelrian",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "jariz",
|
||||||
|
"name": "jariz",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4",
|
||||||
|
"profile": "https://github.com/jariz",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -94,7 +94,7 @@ jobs:
|
|||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: semantic-release
|
needs: semantic-release
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: self-hosted
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v3
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
.next/
|
.next/
|
||||||
dist/
|
dist/
|
||||||
config/
|
config/
|
||||||
|
CHANGELOG.md
|
||||||
|
|
||||||
# assets
|
# assets
|
||||||
src/assets/
|
src/assets/
|
||||||
|
|||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -16,5 +16,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"files.associations": {
|
||||||
|
"globals.css": "tailwindcss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -1,3 +1,46 @@
|
|||||||
|
# [1.5.0](https://github.com/fallenbagel/jellyseerr/compare/v1.4.1...v1.5.0) (2023-04-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add better checks on 4k detection of series ([bc9017f](https://github.com/fallenbagel/jellyseerr/commit/bc9017f54d84ec24c4d74d38e1b4e24219425d41))
|
||||||
|
* added a refresh interval if download status is in progress ([#3275](https://github.com/fallenbagel/jellyseerr/issues/3275)) ([1e2c6f4](https://github.com/fallenbagel/jellyseerr/commit/1e2c6f46ab66c836f321b5d8e34f1e8124c0b542))
|
||||||
|
* **build:** increase threshold for amount of data to be fetched when SSR'ing ([#3320](https://github.com/fallenbagel/jellyseerr/issues/3320)) ([d7b83d2](https://github.com/fallenbagel/jellyseerr/commit/d7b83d22cee3d20db564cc0564d42802b02327e3))
|
||||||
|
* disable availability sync temporarily ([2e5cf22](https://github.com/fallenbagel/jellyseerr/commit/2e5cf226265686012329248e7f729fec324c3deb))
|
||||||
|
* hide remove button when default service is not configured ([7d4455b](https://github.com/fallenbagel/jellyseerr/commit/7d4455ba6bfd12e2730f7085cbb87df246f01d22))
|
||||||
|
* **jellyfin scan:** temporary workaround fix for jellyfin scan when display specials within season ([38fb66d](https://github.com/fallenbagel/jellyseerr/commit/38fb66d31e41232c01898d0d362af8338eb7b960)), closes [#215](https://github.com/fallenbagel/jellyseerr/issues/215) [#176](https://github.com/fallenbagel/jellyseerr/issues/176) [#246](https://github.com/fallenbagel/jellyseerr/issues/246)
|
||||||
|
* lint issues ([bcd2bb7](https://github.com/fallenbagel/jellyseerr/commit/bcd2bb7c96810f5a6932f42468a628d2db1bc771))
|
||||||
|
* logger was set to info for the wrong logs ([#3354](https://github.com/fallenbagel/jellyseerr/issues/3354)) ([c36a4ba](https://github.com/fallenbagel/jellyseerr/commit/c36a4ba2b8df05873f5dfd0946a9bc3dc4ecfd1d))
|
||||||
|
* remove unnecessary parenthesis from api key generation ([#3336](https://github.com/fallenbagel/jellyseerr/issues/3336)) ([6bd3f01](https://github.com/fallenbagel/jellyseerr/commit/6bd3f015d65507efca60279007bd2b86ee860643))
|
||||||
|
* **snapcraft:** use the correct config folder for image cache ([#3302](https://github.com/fallenbagel/jellyseerr/issues/3302)) ([c93467b](https://github.com/fallenbagel/jellyseerr/commit/c93467b3acf2c256324297e7e8f21e9944005dd4))
|
||||||
|
* **ui:** hide mini status badge if non-4K media status is unknown ([#3346](https://github.com/fallenbagel/jellyseerr/issues/3346)) ([50f06da](https://github.com/fallenbagel/jellyseerr/commit/50f06dabbffc693f0843584a64d1d96e77982820))
|
||||||
|
* **ui:** hide search bar behind slideover when opened ([#3348](https://github.com/fallenbagel/jellyseerr/issues/3348)) ([b3882de](https://github.com/fallenbagel/jellyseerr/commit/b3882de8930a70adb2f93a27be6370bfa1826587))
|
||||||
|
* **ui:** prevent title cards from flickering when quickly hovering across them ([#3349](https://github.com/fallenbagel/jellyseerr/issues/3349)) ([eb5502a](https://github.com/fallenbagel/jellyseerr/commit/eb5502a16f86e37a933f6beca0678c2d228e77d5))
|
||||||
|
* **watchlist:** correctly load more than 20 watchlist items ([#3351](https://github.com/fallenbagel/jellyseerr/issues/3351)) ([af880a6](https://github.com/fallenbagel/jellyseerr/commit/af880a6c839794b34bddcd7e0fe56353aa48ba36))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add a button in ManageSlideOver to remove the movie and the file from Radarr/Sonarr ([2e74584](https://github.com/fallenbagel/jellyseerr/commit/2e7458457e995dd3ec6dd96035fe997646cdd446))
|
||||||
|
* availability sync rework ([#3219](https://github.com/fallenbagel/jellyseerr/issues/3219)) ([ae38183](https://github.com/fallenbagel/jellyseerr/commit/ae3818304b2f75222d1bd223ece94f829a3b42d0)), closes [#377](https://github.com/fallenbagel/jellyseerr/issues/377)
|
||||||
|
* full title of download item on hover with tooltip ([#3296](https://github.com/fallenbagel/jellyseerr/issues/3296)) ([33e7691](https://github.com/fallenbagel/jellyseerr/commit/33e7691b94d7d369a0a1410e434850bc51e5572e))
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **imageproxy:** do not set cookies to image proxy so CDNs can cache images ([#3332](https://github.com/fallenbagel/jellyseerr/issues/3332)) ([966639d](https://github.com/fallenbagel/jellyseerr/commit/966639df430d32f6bfebdb16314dc4590d21caf8))
|
||||||
|
|
||||||
|
## [1.4.1](https://github.com/fallenbagel/jellyseerr/compare/v1.4.0...v1.4.1) (2023-01-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* pass in library type when scanning recently added items ([#3287](https://github.com/fallenbagel/jellyseerr/issues/3287)) ([8942eb8](https://github.com/fallenbagel/jellyseerr/commit/8942eb8b7c4fa1d16aa2e72e8ba7120a653c9aa2))
|
||||||
|
* **ui:** air date will use UTC for timezone ([#3297](https://github.com/fallenbagel/jellyseerr/issues/3297)) ([3e43586](https://github.com/fallenbagel/jellyseerr/commit/3e43586acc0804c3fff524509caa890a104e132b))
|
||||||
|
* **ui:** correct range slider styling in chrome ([#3299](https://github.com/fallenbagel/jellyseerr/issues/3299)) ([d954328](https://github.com/fallenbagel/jellyseerr/commit/d9543289111d72245564d25d300a71b0ea3954ba))
|
||||||
|
* **ui:** show 5 icons when possible on mobile menu ([#3298](https://github.com/fallenbagel/jellyseerr/issues/3298)) ([7040da1](https://github.com/fallenbagel/jellyseerr/commit/7040da1334f6d18e19a494c73caa17f7df552dfe))
|
||||||
|
* **ui:** style range thumbs correctly for firefox ([#3294](https://github.com/fallenbagel/jellyseerr/issues/3294)) ([9d10e6a](https://github.com/fallenbagel/jellyseerr/commit/9d10e6a88c0996671f1d9d20792e1930dbc82329))
|
||||||
|
|
||||||
# [1.4.0](https://github.com/fallenbagel/jellyseerr/compare/v1.3.0...v1.4.0) (2023-01-29)
|
# [1.4.0](https://github.com/fallenbagel/jellyseerr/compare/v1.3.0...v1.4.0) (2023-01-29)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
||||||
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
||||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||||
</p>
|
|
||||||
|
|
||||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
|
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
|
||||||
|
|
||||||
@@ -141,3 +140,7 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD
|
|||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
|
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||||
|
|
||||||
|
## Contributors ✨
|
||||||
|
|
||||||
|
Thanks goes to all wonderful people who contributed directly to Jellyseerr and Overseerr.
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe('Discover Customization', () => {
|
|||||||
.should('be.disabled');
|
.should('be.disabled');
|
||||||
|
|
||||||
cy.get('#data').clear();
|
cy.get('#data').clear();
|
||||||
cy.get('#data').type('time travel{enter}', { delay: 100 });
|
cy.get('#data').type('christmas{enter}', { delay: 100 });
|
||||||
|
|
||||||
// Confirming we have some results
|
// Confirming we have some results
|
||||||
cy.contains('.slider-header', sliderTitle)
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
|
largePageDataBytes: 256000,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3868,7 +3868,7 @@ paths:
|
|||||||
$ref: '#/components/schemas/User'
|
$ref: '#/components/schemas/User'
|
||||||
/user/{userId}/requests:
|
/user/{userId}/requests:
|
||||||
get:
|
get:
|
||||||
summary: Get user by ID
|
summary: Get requests for a specific user
|
||||||
description: |
|
description: |
|
||||||
Retrieves a user's requests in a JSON object.
|
Retrieves a user's requests in a JSON object.
|
||||||
tags:
|
tags:
|
||||||
@@ -3964,7 +3964,7 @@ paths:
|
|||||||
example: false
|
example: false
|
||||||
/user/{userId}/watchlist:
|
/user/{userId}/watchlist:
|
||||||
get:
|
get:
|
||||||
summary: Get user by ID
|
summary: Get the Plex watchlist for a specific user
|
||||||
description: |
|
description: |
|
||||||
Retrieves a user's Plex Watchlist in a JSON object.
|
Retrieves a user's Plex Watchlist in a JSON object.
|
||||||
tags:
|
tags:
|
||||||
@@ -5876,6 +5876,23 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Succesfully removed media item
|
description: Succesfully removed media item
|
||||||
|
/media/{mediaId}/file:
|
||||||
|
delete:
|
||||||
|
summary: Delete media file
|
||||||
|
description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action.
|
||||||
|
tags:
|
||||||
|
- media
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: mediaId
|
||||||
|
description: Media ID
|
||||||
|
required: true
|
||||||
|
example: '1'
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Succesfully removed media item
|
||||||
/media/{mediaId}/{status}:
|
/media/{mediaId}/{status}:
|
||||||
post:
|
post:
|
||||||
summary: Update media status
|
summary: Update media status
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellyseerr",
|
"name": "jellyseerr",
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||||
|
|||||||
@@ -226,12 +226,13 @@ class PlexAPI {
|
|||||||
id: string,
|
id: string,
|
||||||
options: { addedAt: number } = {
|
options: { addedAt: number } = {
|
||||||
addedAt: Date.now() - 1000 * 60 * 60,
|
addedAt: Date.now() - 1000 * 60 * 60,
|
||||||
}
|
},
|
||||||
|
mediaType: 'movie' | 'show'
|
||||||
): Promise<PlexLibraryItem[]> {
|
): Promise<PlexLibraryItem[]> {
|
||||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||||
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
uri: `/library/sections/${id}/all?type=${
|
||||||
options.addedAt / 1000
|
mediaType === 'show' ? '4' : '1'
|
||||||
)}`,
|
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||||
extraHeaders: {
|
extraHeaders: {
|
||||||
'X-Plex-Container-Start': `0`,
|
'X-Plex-Container-Start': `0`,
|
||||||
'X-Plex-Container-Size': `500`,
|
'X-Plex-Container-Size': `500`,
|
||||||
|
|||||||
@@ -213,6 +213,20 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public removeMovie = async (movieId: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id, title } = await this.getMovieByTmdbId(movieId);
|
||||||
|
await this.axios.delete(`/movie/${id}`, {
|
||||||
|
params: {
|
||||||
|
deleteFiles: true,
|
||||||
|
addImportExclusion: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info(`[Radarr] Removed movie ${title}`);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RadarrAPI;
|
export default RadarrAPI;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import ServarrBase from './base';
|
import ServarrBase from './base';
|
||||||
|
|
||||||
interface SonarrSeason {
|
export interface SonarrSeason {
|
||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
monitored: boolean;
|
monitored: boolean;
|
||||||
statistics?: {
|
statistics?: {
|
||||||
@@ -321,6 +321,20 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
return newSeasons;
|
return newSeasons;
|
||||||
}
|
}
|
||||||
|
public removeSerie = async (serieId: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
||||||
|
await this.axios.delete(`/series/${id}`, {
|
||||||
|
params: {
|
||||||
|
deleteFiles: true,
|
||||||
|
addImportExclusion: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info(`[Radarr] Removed serie ${title}`);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Radarr] Failed to remove serie: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SonarrAPI;
|
export default SonarrAPI;
|
||||||
|
|||||||
@@ -115,29 +115,29 @@ class Media {
|
|||||||
@Column({ type: 'datetime', nullable: true })
|
@Column({ type: 'datetime', nullable: true })
|
||||||
public mediaAddedAt: Date;
|
public mediaAddedAt: Date;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public serviceId?: number;
|
public serviceId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public serviceId4k?: number;
|
public serviceId4k?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public externalServiceId?: number;
|
public externalServiceId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public externalServiceId4k?: number;
|
public externalServiceId4k?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public externalServiceSlug?: string;
|
public externalServiceSlug?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public externalServiceSlug4k?: string;
|
public externalServiceSlug4k?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public ratingKey?: string;
|
public ratingKey?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public ratingKey4k?: string;
|
public ratingKey4k?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public jellyfinMediaId?: string;
|
public jellyfinMediaId?: string;
|
||||||
@@ -288,7 +288,9 @@ class Media {
|
|||||||
if (this.mediaType === MediaType.MOVIE) {
|
if (this.mediaType === MediaType.MOVIE) {
|
||||||
if (
|
if (
|
||||||
this.externalServiceId !== undefined &&
|
this.externalServiceId !== undefined &&
|
||||||
this.serviceId !== undefined
|
this.externalServiceId !== null &&
|
||||||
|
this.serviceId !== undefined &&
|
||||||
|
this.serviceId !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus = downloadTracker.getMovieProgress(
|
this.downloadStatus = downloadTracker.getMovieProgress(
|
||||||
this.serviceId,
|
this.serviceId,
|
||||||
@@ -298,7 +300,9 @@ class Media {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.externalServiceId4k !== undefined &&
|
this.externalServiceId4k !== undefined &&
|
||||||
this.serviceId4k !== undefined
|
this.externalServiceId4k !== null &&
|
||||||
|
this.serviceId4k !== undefined &&
|
||||||
|
this.serviceId4k !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
||||||
this.serviceId4k,
|
this.serviceId4k,
|
||||||
@@ -310,7 +314,9 @@ class Media {
|
|||||||
if (this.mediaType === MediaType.TV) {
|
if (this.mediaType === MediaType.TV) {
|
||||||
if (
|
if (
|
||||||
this.externalServiceId !== undefined &&
|
this.externalServiceId !== undefined &&
|
||||||
this.serviceId !== undefined
|
this.externalServiceId !== null &&
|
||||||
|
this.serviceId !== undefined &&
|
||||||
|
this.serviceId !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus = downloadTracker.getSeriesProgress(
|
this.downloadStatus = downloadTracker.getSeriesProgress(
|
||||||
this.serviceId,
|
this.serviceId,
|
||||||
@@ -320,7 +326,9 @@ class Media {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.externalServiceId4k !== undefined &&
|
this.externalServiceId4k !== undefined &&
|
||||||
this.serviceId4k !== undefined
|
this.externalServiceId4k !== null &&
|
||||||
|
this.serviceId4k !== undefined &&
|
||||||
|
this.serviceId4k !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
||||||
this.serviceId4k,
|
this.serviceId4k,
|
||||||
|
|||||||
@@ -1187,3 +1187,5 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default MediaRequest;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
import {
|
import {
|
||||||
|
AfterRemove,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
@@ -34,6 +36,18 @@ class SeasonRequest {
|
|||||||
constructor(init?: Partial<SeasonRequest>) {
|
constructor(init?: Partial<SeasonRequest>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterRemove()
|
||||||
|
public async handleRemoveParent(): Promise<void> {
|
||||||
|
const mediaRequestRepository = getRepository(MediaRequest);
|
||||||
|
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
||||||
|
where: { id: this.request.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestToBeDeleted.seasons.length === 0) {
|
||||||
|
await mediaRequestRepository.delete({ id: this.request.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SeasonRequest;
|
export default SeasonRequest;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
|
|||||||
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import clearCookies from '@server/middleware/clearcookies';
|
||||||
import routes from '@server/routes';
|
import routes from '@server/routes';
|
||||||
import imageproxy from '@server/routes/imageproxy';
|
import imageproxy from '@server/routes/imageproxy';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
@@ -192,7 +193,8 @@ app
|
|||||||
});
|
});
|
||||||
server.use('/api/v1', routes);
|
server.use('/api/v1', routes);
|
||||||
|
|
||||||
server.use('/imageproxy', imageproxy);
|
// Do not set cookies so CDNs can cache them
|
||||||
|
server.use('/imageproxy', clearCookies, imageproxy);
|
||||||
|
|
||||||
server.get('*', (req, res) => handle(req, res));
|
server.get('*', (req, res) => handle(req, res));
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -278,11 +278,11 @@ class JobJellyfinSync {
|
|||||||
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
||||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||||
if (MediaStream.Type === 'Video') {
|
if (MediaStream.Type === 'Video') {
|
||||||
if (MediaStream.Width ?? 0 < 2000) {
|
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||||
|
total4k += episodeCount;
|
||||||
|
} else {
|
||||||
totalStandard += episodeCount;
|
totalStandard += episodeCount;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
total4k += episodeCount;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -311,13 +311,13 @@ class JobJellyfinSync {
|
|||||||
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
||||||
// and then not modifying the status if there are 0 items
|
// and then not modifying the status if there are 0 items
|
||||||
existingSeason.status =
|
existingSeason.status =
|
||||||
totalStandard === season.episode_count
|
totalStandard >= season.episode_count
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: totalStandard > 0
|
: totalStandard > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: existingSeason.status;
|
: existingSeason.status;
|
||||||
existingSeason.status4k =
|
existingSeason.status4k =
|
||||||
this.enable4kShow && total4k === season.episode_count
|
this.enable4kShow && total4k >= season.episode_count
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: this.enable4kShow && total4k > 0
|
: this.enable4kShow && total4k > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
@@ -329,13 +329,13 @@ class JobJellyfinSync {
|
|||||||
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
||||||
// if we dont have any items for the season
|
// if we dont have any items for the season
|
||||||
status:
|
status:
|
||||||
totalStandard === season.episode_count
|
totalStandard >= season.episode_count
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: totalStandard > 0
|
: totalStandard > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: MediaStatus.UNKNOWN,
|
: MediaStatus.UNKNOWN,
|
||||||
status4k:
|
status4k:
|
||||||
this.enable4kShow && total4k === season.episode_count
|
this.enable4kShow && total4k >= season.episode_count
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: this.enable4kShow && total4k > 0
|
: this.enable4kShow && total4k > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface ScheduledJob {
|
|||||||
job: schedule.Job;
|
job: schedule.Job;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
interval: 'short' | 'long' | 'fixed';
|
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
running?: () => boolean;
|
running?: () => boolean;
|
||||||
cancelFn?: () => void;
|
cancelFn?: () => void;
|
||||||
@@ -34,7 +34,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-recently-added-scan',
|
id: 'plex-recently-added-scan',
|
||||||
name: 'Plex Recently Added Scan',
|
name: 'Plex Recently Added Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'short',
|
interval: 'minutes',
|
||||||
cronSchedule: jobs['plex-recently-added-scan'].schedule,
|
cronSchedule: jobs['plex-recently-added-scan'].schedule,
|
||||||
job: schedule.scheduleJob(
|
job: schedule.scheduleJob(
|
||||||
jobs['plex-recently-added-scan'].schedule,
|
jobs['plex-recently-added-scan'].schedule,
|
||||||
@@ -54,7 +54,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-full-scan',
|
id: 'plex-full-scan',
|
||||||
name: 'Plex Full Library Scan',
|
name: 'Plex Full Library Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['plex-full-scan'].schedule,
|
cronSchedule: jobs['plex-full-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||||
@@ -74,7 +74,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'jellyfin-recently-added-sync',
|
id: 'jellyfin-recently-added-sync',
|
||||||
name: 'Jellyfin Recently Added Sync',
|
name: 'Jellyfin Recently Added Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'minutes',
|
||||||
cronSchedule: jobs['jellyfin-recently-added-sync'].schedule,
|
cronSchedule: jobs['jellyfin-recently-added-sync'].schedule,
|
||||||
job: schedule.scheduleJob(
|
job: schedule.scheduleJob(
|
||||||
jobs['jellyfin-recently-added-sync'].schedule,
|
jobs['jellyfin-recently-added-sync'].schedule,
|
||||||
@@ -94,7 +94,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'jellyfin-full-sync',
|
id: 'jellyfin-full-sync',
|
||||||
name: 'Jellyfin Full Library Sync',
|
name: 'Jellyfin Full Library Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['jellyfin-full-sync'].schedule,
|
cronSchedule: jobs['jellyfin-full-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
|
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
||||||
@@ -112,7 +112,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-watchlist-sync',
|
id: 'plex-watchlist-sync',
|
||||||
name: 'Plex Watchlist Sync',
|
name: 'Plex Watchlist Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'short',
|
interval: 'minutes',
|
||||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||||
@@ -127,7 +127,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'radarr-scan',
|
id: 'radarr-scan',
|
||||||
name: 'Radarr Scan',
|
name: 'Radarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['radarr-scan'].schedule,
|
cronSchedule: jobs['radarr-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||||
@@ -142,7 +142,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'sonarr-scan',
|
id: 'sonarr-scan',
|
||||||
name: 'Sonarr Scan',
|
name: 'Sonarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['sonarr-scan'].schedule,
|
cronSchedule: jobs['sonarr-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||||
@@ -152,12 +152,30 @@ export const startJobs = (): void => {
|
|||||||
cancelFn: () => sonarrScanner.cancel(),
|
cancelFn: () => sonarrScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Checks if media is still available in plex/sonarr/radarr libs
|
||||||
|
/* scheduledJobs.push({
|
||||||
|
id: 'availability-sync',
|
||||||
|
name: 'Media Availability Sync',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'hours',
|
||||||
|
cronSchedule: jobs['availability-sync'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: Media Availability Sync', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
availabilitySync.run();
|
||||||
|
}),
|
||||||
|
running: () => availabilitySync.running,
|
||||||
|
cancelFn: () => availabilitySync.cancel(),
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
// Run download sync every minute
|
// Run download sync every minute
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'download-sync',
|
id: 'download-sync',
|
||||||
name: 'Download Sync',
|
name: 'Download Sync',
|
||||||
type: 'command',
|
type: 'command',
|
||||||
interval: 'fixed',
|
interval: 'seconds',
|
||||||
cronSchedule: jobs['download-sync'].schedule,
|
cronSchedule: jobs['download-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
||||||
logger.debug('Starting scheduled job: Download Sync', {
|
logger.debug('Starting scheduled job: Download Sync', {
|
||||||
@@ -172,7 +190,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'download-sync-reset',
|
id: 'download-sync-reset',
|
||||||
name: 'Download Sync Reset',
|
name: 'Download Sync Reset',
|
||||||
type: 'command',
|
type: 'command',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['download-sync-reset'].schedule,
|
cronSchedule: jobs['download-sync-reset'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Download Sync Reset', {
|
logger.info('Starting scheduled job: Download Sync Reset', {
|
||||||
@@ -182,12 +200,12 @@ export const startJobs = (): void => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run image cache cleanup every 5 minutes
|
// Run image cache cleanup every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'image-cache-cleanup',
|
id: 'image-cache-cleanup',
|
||||||
name: 'Image Cache Cleanup',
|
name: 'Image Cache Cleanup',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
||||||
|
|||||||
718
server/lib/availabilitySync.ts
Normal file
718
server/lib/availabilitySync.ts
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
import type { PlexMetadata } from '@server/api/plexapi';
|
||||||
|
import PlexAPI from '@server/api/plexapi';
|
||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import type { SonarrSeason } from '@server/api/servarr/sonarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
|
import Season from '@server/entity/Season';
|
||||||
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
|
||||||
|
class AvailabilitySync {
|
||||||
|
public running = false;
|
||||||
|
private plexClient: PlexAPI;
|
||||||
|
private plexSeasonsCache: Record<string, PlexMetadata[]> = {};
|
||||||
|
private sonarrSeasonsCache: Record<string, SonarrSeason[]> = {};
|
||||||
|
private radarrServers: RadarrSettings[];
|
||||||
|
private sonarrServers: SonarrSettings[];
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const settings = getSettings();
|
||||||
|
this.running = true;
|
||||||
|
this.plexSeasonsCache = {};
|
||||||
|
this.sonarrSeasonsCache = {};
|
||||||
|
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||||
|
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||||
|
await this.initPlexClient();
|
||||||
|
|
||||||
|
if (!this.plexClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Starting availability sync...`, {
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
const seasonRepository = getRepository(Season);
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
|
||||||
|
try {
|
||||||
|
if (!this.running) {
|
||||||
|
throw new Error('Job aborted');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaExists = await this.mediaExists(media);
|
||||||
|
|
||||||
|
//We can not delete media so if both versions do not exist, we will change both columns to unknown or null
|
||||||
|
if (!mediaExists) {
|
||||||
|
if (
|
||||||
|
media.status !== MediaStatus.UNKNOWN ||
|
||||||
|
media.status4k !== MediaStatus.UNKNOWN
|
||||||
|
) {
|
||||||
|
const request = await requestRepository.find({
|
||||||
|
relations: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
where: { media: { id: media.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`${
|
||||||
|
media.mediaType === 'tv' ? media.tvdbId : media.tmdbId
|
||||||
|
} does not exist in any of your media instances. We will change its status to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
serviceId: null,
|
||||||
|
serviceId4k: null,
|
||||||
|
externalServiceId: null,
|
||||||
|
externalServiceId4k: null,
|
||||||
|
externalServiceSlug: null,
|
||||||
|
externalServiceSlug4k: null,
|
||||||
|
ratingKey: null,
|
||||||
|
ratingKey4k: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestRepository.remove(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
// ok, the show itself exists, but do all it's seasons?
|
||||||
|
const seasons = await seasonRepository.find({
|
||||||
|
where: [
|
||||||
|
{ status: MediaStatus.AVAILABLE, media: { id: media.id } },
|
||||||
|
{
|
||||||
|
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
media: { id: media.id },
|
||||||
|
},
|
||||||
|
{ status4k: MediaStatus.AVAILABLE, media: { id: media.id } },
|
||||||
|
{
|
||||||
|
status4k: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
media: { id: media.id },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let didDeleteSeasons = false;
|
||||||
|
for (const season of seasons) {
|
||||||
|
if (
|
||||||
|
!mediaExists &&
|
||||||
|
(season.status !== MediaStatus.UNKNOWN ||
|
||||||
|
season.status4k !== MediaStatus.UNKNOWN)
|
||||||
|
) {
|
||||||
|
await seasonRepository.update(
|
||||||
|
{ id: season.id },
|
||||||
|
{
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const seasonExists = await this.seasonExists(media, season);
|
||||||
|
|
||||||
|
if (!seasonExists) {
|
||||||
|
logger.info(
|
||||||
|
`Removing season ${season.seasonNumber}, media id: ${media.tvdbId} because it does not exist in any of your media instances.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
season.status !== MediaStatus.UNKNOWN ||
|
||||||
|
season.status4k !== MediaStatus.UNKNOWN
|
||||||
|
) {
|
||||||
|
await seasonRepository.update(
|
||||||
|
{ id: season.id },
|
||||||
|
{
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonToBeDeleted =
|
||||||
|
await seasonRequestRepository.findOne({
|
||||||
|
relations: {
|
||||||
|
request: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
request: {
|
||||||
|
media: {
|
||||||
|
id: media.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seasonNumber: season.seasonNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seasonToBeDeleted) {
|
||||||
|
await seasonRequestRepository.remove(seasonToBeDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
didDeleteSeasons = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didDeleteSeasons) {
|
||||||
|
if (
|
||||||
|
media.status === MediaStatus.AVAILABLE ||
|
||||||
|
media.status4k === MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted some of its seasons.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (media.status === MediaStatus.AVAILABLE) {
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status4k: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
logger.error('Failure with media.', {
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
logger.error('Failed to complete availability sync.', {
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
logger.info(`Availability sync complete.`, {
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel() {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *loadAvailableMediaPaginated(pageSize: number) {
|
||||||
|
let offset = 0;
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const whereOptions = [
|
||||||
|
{ status: MediaStatus.AVAILABLE },
|
||||||
|
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
{ status4k: MediaStatus.AVAILABLE },
|
||||||
|
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
];
|
||||||
|
|
||||||
|
let mediaPage: Media[];
|
||||||
|
|
||||||
|
do {
|
||||||
|
yield* (mediaPage = await mediaRepository.find({
|
||||||
|
where: whereOptions,
|
||||||
|
skip: offset,
|
||||||
|
take: pageSize,
|
||||||
|
}));
|
||||||
|
offset += pageSize;
|
||||||
|
} while (mediaPage.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
const isTVType = media.mediaType === 'tv';
|
||||||
|
|
||||||
|
const request = await requestRepository.findOne({
|
||||||
|
relations: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
where: { media: { id: media.id }, is4k: is4k ? true : false },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`${media.tmdbId} does not exist in your ${is4k ? '4k' : 'non-4k'} ${
|
||||||
|
isTVType ? 'sonarr' : 'radarr'
|
||||||
|
} and plex instance. We will change its status to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await mediaRepository.update(
|
||||||
|
media.id,
|
||||||
|
is4k
|
||||||
|
? {
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
serviceId4k: null,
|
||||||
|
externalServiceId4k: null,
|
||||||
|
externalServiceSlug4k: null,
|
||||||
|
ratingKey4k: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
serviceId: null,
|
||||||
|
externalServiceId: null,
|
||||||
|
externalServiceSlug: null,
|
||||||
|
ratingKey: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isTVType) {
|
||||||
|
const seasonRepository = getRepository(Season);
|
||||||
|
|
||||||
|
await seasonRepository?.update(
|
||||||
|
{ media: { id: media.id } },
|
||||||
|
is4k
|
||||||
|
? { status4k: MediaStatus.UNKNOWN }
|
||||||
|
: { status: MediaStatus.UNKNOWN }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestRepository.delete({ id: request?.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInRadarr(
|
||||||
|
media: Media,
|
||||||
|
existsInPlex: boolean,
|
||||||
|
existsInPlex4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
let existsInRadarr = true;
|
||||||
|
let existsInRadarr4k = true;
|
||||||
|
|
||||||
|
for (const server of this.radarrServers) {
|
||||||
|
const api = new RadarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
const meta = await api.getMovieByTmdbId(media.tmdbId);
|
||||||
|
|
||||||
|
//check if both exist or if a single non-4k or 4k exists
|
||||||
|
//if both do not exist we will return false
|
||||||
|
if (!server.is4k && !meta.id) {
|
||||||
|
existsInRadarr = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && !meta.id) {
|
||||||
|
existsInRadarr4k = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInRadarr && existsInRadarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsInRadarr && existsInPlex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsInRadarr4k && existsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//if only a single non-4k or 4k exists, then change entity columns accordingly
|
||||||
|
//related media request will then be deleted
|
||||||
|
if (!existsInRadarr && existsInRadarr4k && !existsInPlex) {
|
||||||
|
if (media.status !== MediaStatus.UNKNOWN) {
|
||||||
|
this.mediaUpdater(media, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInRadarr && !existsInRadarr4k && !existsInPlex4k) {
|
||||||
|
if (media.status4k !== MediaStatus.UNKNOWN) {
|
||||||
|
this.mediaUpdater(media, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInRadarr || existsInRadarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInSonarr(
|
||||||
|
media: Media,
|
||||||
|
existsInPlex: boolean,
|
||||||
|
existsInPlex4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!media.tvdbId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let existsInSonarr = true;
|
||||||
|
let existsInSonarr4k = true;
|
||||||
|
|
||||||
|
for (const server of this.sonarrServers) {
|
||||||
|
const api = new SonarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = await api.getSeriesByTvdbId(media.tvdbId);
|
||||||
|
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = meta.seasons;
|
||||||
|
|
||||||
|
//check if both exist or if a single non-4k or 4k exists
|
||||||
|
//if both do not exist we will return false
|
||||||
|
if (!server.is4k && !meta.id) {
|
||||||
|
existsInSonarr = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && !meta.id) {
|
||||||
|
existsInSonarr4k = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInSonarr && existsInSonarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsInSonarr && existsInPlex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsInSonarr4k && existsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//if only a single non-4k or 4k exists, then change entity columns accordingly
|
||||||
|
//related media request will then be deleted
|
||||||
|
if (!existsInSonarr && existsInSonarr4k && !existsInPlex) {
|
||||||
|
if (media.status !== MediaStatus.UNKNOWN) {
|
||||||
|
this.mediaUpdater(media, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInSonarr && !existsInSonarr4k && !existsInPlex4k) {
|
||||||
|
if (media.status4k !== MediaStatus.UNKNOWN) {
|
||||||
|
this.mediaUpdater(media, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInSonarr || existsInSonarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonExistsInSonarr(
|
||||||
|
media: Media,
|
||||||
|
season: Season,
|
||||||
|
seasonExistsInPlex: boolean,
|
||||||
|
seasonExistsInPlex4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!media.tvdbId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seasonExistsInSonarr = true;
|
||||||
|
let seasonExistsInSonarr4k = true;
|
||||||
|
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const seasonRepository = getRepository(Season);
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
|
||||||
|
for (const server of this.sonarrServers) {
|
||||||
|
const api = new SonarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const seasons =
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] ??
|
||||||
|
(await api.getSeriesByTvdbId(media.tvdbId)).seasons;
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = seasons;
|
||||||
|
|
||||||
|
const hasMonitoredSeason = seasons.find(
|
||||||
|
({ monitored, seasonNumber }) =>
|
||||||
|
monitored && season.seasonNumber === seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!server.is4k && !hasMonitoredSeason) {
|
||||||
|
seasonExistsInSonarr = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && !hasMonitoredSeason) {
|
||||||
|
seasonExistsInSonarr4k = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonExistsInSonarr && seasonExistsInSonarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seasonExistsInSonarr && seasonExistsInPlex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seasonExistsInSonarr4k && seasonExistsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonToBeDeleted = await seasonRequestRepository.findOne({
|
||||||
|
relations: {
|
||||||
|
request: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
request: {
|
||||||
|
is4k: seasonExistsInSonarr ? true : false,
|
||||||
|
media: {
|
||||||
|
id: media.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seasonNumber: season.seasonNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
//if season does not exist, we will change status to unknown and delete related season request
|
||||||
|
//if parent media request is empty(all related seasons have been removed), parent is automatically deleted
|
||||||
|
if (
|
||||||
|
!seasonExistsInSonarr &&
|
||||||
|
seasonExistsInSonarr4k &&
|
||||||
|
!seasonExistsInPlex
|
||||||
|
) {
|
||||||
|
if (season.status !== MediaStatus.UNKNOWN) {
|
||||||
|
logger.info(
|
||||||
|
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your non-4k sonarr and plex instance. We will change its status to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
await seasonRepository.update(season.id, {
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seasonToBeDeleted) {
|
||||||
|
await seasonRequestRepository.remove(seasonToBeDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status === MediaStatus.AVAILABLE) {
|
||||||
|
logger.info(
|
||||||
|
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
seasonExistsInSonarr &&
|
||||||
|
!seasonExistsInSonarr4k &&
|
||||||
|
!seasonExistsInPlex4k
|
||||||
|
) {
|
||||||
|
if (season.status4k !== MediaStatus.UNKNOWN) {
|
||||||
|
logger.info(
|
||||||
|
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your 4k sonarr and plex instance. We will change its status to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
await seasonRepository.update(season.id, {
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seasonToBeDeleted) {
|
||||||
|
await seasonRequestRepository.remove(seasonToBeDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||||
|
logger.info(
|
||||||
|
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status4k: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonExistsInSonarr || seasonExistsInSonarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExists(media: Media): Promise<boolean> {
|
||||||
|
const ratingKey = media.ratingKey;
|
||||||
|
const ratingKey4k = media.ratingKey4k;
|
||||||
|
|
||||||
|
let existsInPlex = false;
|
||||||
|
let existsInPlex4k = false;
|
||||||
|
|
||||||
|
//check each plex instance to see if media exists
|
||||||
|
try {
|
||||||
|
if (ratingKey) {
|
||||||
|
const meta = await this.plexClient?.getMetadata(ratingKey);
|
||||||
|
if (meta) {
|
||||||
|
existsInPlex = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ratingKey4k) {
|
||||||
|
const meta4k = await this.plexClient?.getMetadata(ratingKey4k);
|
||||||
|
if (meta4k) {
|
||||||
|
existsInPlex4k = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
// TODO: oof, not the nicest way of handling this, but plex-api does not leave us with any other options...
|
||||||
|
if (!ex.message.includes('response code: 404')) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//base case for if both media versions exist in plex
|
||||||
|
if (existsInPlex && existsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//we then check radarr or sonarr has that specific media. If not, then we will move to delete
|
||||||
|
//if a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
|
||||||
|
if (media.mediaType === 'movie') {
|
||||||
|
const existsInRadarr = await this.mediaExistsInRadarr(
|
||||||
|
media,
|
||||||
|
existsInPlex,
|
||||||
|
existsInPlex4k
|
||||||
|
);
|
||||||
|
|
||||||
|
//if true, media exists in at least one radarr or plex instance.
|
||||||
|
if (existsInRadarr) {
|
||||||
|
logger.warn(
|
||||||
|
`${media.tmdbId} exists in at least one radarr or plex instance. Media will be updated if set to available.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
const existsInSonarr = await this.mediaExistsInSonarr(
|
||||||
|
media,
|
||||||
|
existsInPlex,
|
||||||
|
existsInPlex4k
|
||||||
|
);
|
||||||
|
|
||||||
|
//if true, media exists in at least one sonarr or plex instance.
|
||||||
|
if (existsInSonarr) {
|
||||||
|
logger.warn(
|
||||||
|
`${media.tvdbId} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonExists(media: Media, season: Season) {
|
||||||
|
const ratingKey = media.ratingKey;
|
||||||
|
const ratingKey4k = media.ratingKey4k;
|
||||||
|
|
||||||
|
let seasonExistsInPlex = false;
|
||||||
|
let seasonExistsInPlex4k = false;
|
||||||
|
|
||||||
|
if (ratingKey) {
|
||||||
|
const children =
|
||||||
|
this.plexSeasonsCache[ratingKey] ??
|
||||||
|
(await this.plexClient?.getChildrenMetadata(ratingKey)) ??
|
||||||
|
[];
|
||||||
|
this.plexSeasonsCache[ratingKey] = children;
|
||||||
|
const seasonMeta = children?.find(
|
||||||
|
(child) => child.index === season.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonMeta) {
|
||||||
|
seasonExistsInPlex = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingKey4k) {
|
||||||
|
const children4k =
|
||||||
|
this.plexSeasonsCache[ratingKey4k] ??
|
||||||
|
(await this.plexClient?.getChildrenMetadata(ratingKey4k)) ??
|
||||||
|
[];
|
||||||
|
this.plexSeasonsCache[ratingKey4k] = children4k;
|
||||||
|
const seasonMeta4k = children4k?.find(
|
||||||
|
(child) => child.index === season.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonMeta4k) {
|
||||||
|
seasonExistsInPlex4k = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//base case for if both season versions exist in plex
|
||||||
|
if (seasonExistsInPlex && seasonExistsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existsInSonarr = await this.seasonExistsInSonarr(
|
||||||
|
media,
|
||||||
|
season,
|
||||||
|
seasonExistsInPlex,
|
||||||
|
seasonExistsInPlex4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existsInSonarr) {
|
||||||
|
logger.warn(
|
||||||
|
`${media.tvdbId}, season: ${season.seasonNumber} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initPlexClient() {
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
const admin = await userRepository.findOne({
|
||||||
|
select: { id: true, plexToken: true },
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
logger.warning('No admin configured. Availability sync skipped.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilitySync = new AvailabilitySync();
|
||||||
|
export default availabilitySync;
|
||||||
@@ -18,14 +18,14 @@ type ImageResponse = {
|
|||||||
imageBuffer: Buffer;
|
imageBuffer: Buffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
|
||||||
|
? `${process.env.CONFIG_DIRECTORY}/cache/images`
|
||||||
|
: path.join(__dirname, '../../config/cache/images');
|
||||||
|
|
||||||
class ImageProxy {
|
class ImageProxy {
|
||||||
public static async clearCache(key: string) {
|
public static async clearCache(key: string) {
|
||||||
let deletedImages = 0;
|
let deletedImages = 0;
|
||||||
const cacheDirectory = path.join(
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
__dirname,
|
|
||||||
'../../config/cache/images/',
|
|
||||||
key
|
|
||||||
);
|
|
||||||
|
|
||||||
const files = await promises.readdir(cacheDirectory);
|
const files = await promises.readdir(cacheDirectory);
|
||||||
|
|
||||||
@@ -57,11 +57,7 @@ class ImageProxy {
|
|||||||
public static async getImageStats(
|
public static async getImageStats(
|
||||||
key: string
|
key: string
|
||||||
): Promise<{ size: number; imageCount: number }> {
|
): Promise<{ size: number; imageCount: number }> {
|
||||||
const cacheDirectory = path.join(
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
__dirname,
|
|
||||||
'../../config/cache/images/',
|
|
||||||
key
|
|
||||||
);
|
|
||||||
|
|
||||||
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
||||||
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
||||||
@@ -263,7 +259,7 @@ class ImageProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getCacheDirectory() {
|
private getCacheDirectory() {
|
||||||
return path.join(__dirname, '../../config/cache/images/', this.key);
|
return path.join(baseCacheDirectory, this.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ class PlexScanner
|
|||||||
// We remove 10 minutes from the last scan as a buffer
|
// We remove 10 minutes from the last scan as a buffer
|
||||||
addedAt: library.lastScan - 1000 * 60 * 10,
|
addedAt: library.lastScan - 1000 * 60 * 10,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined,
|
||||||
|
library.type
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bundle items up by rating keys
|
// Bundle items up by rating keys
|
||||||
|
|||||||
@@ -264,7 +264,8 @@ export type JobId =
|
|||||||
| 'download-sync-reset'
|
| 'download-sync-reset'
|
||||||
| 'jellyfin-recently-added-sync'
|
| 'jellyfin-recently-added-sync'
|
||||||
| 'jellyfin-full-sync'
|
| 'jellyfin-full-sync'
|
||||||
| 'image-cache-cleanup';
|
| 'image-cache-cleanup'
|
||||||
|
| 'availability-sync';
|
||||||
|
|
||||||
interface AllSettings {
|
interface AllSettings {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -435,6 +436,9 @@ class Settings {
|
|||||||
'sonarr-scan': {
|
'sonarr-scan': {
|
||||||
schedule: '0 30 4 * * *',
|
schedule: '0 30 4 * * *',
|
||||||
},
|
},
|
||||||
|
'availability-sync': {
|
||||||
|
schedule: '0 0 5 * * *',
|
||||||
|
},
|
||||||
'download-sync': {
|
'download-sync': {
|
||||||
schedule: '0 * * * * *',
|
schedule: '0 * * * * *',
|
||||||
},
|
},
|
||||||
@@ -590,7 +594,7 @@ class Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateApiKey(): string {
|
private generateApiKey(): string {
|
||||||
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
|
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateVapidKeys(force = false): void {
|
private generateVapidKeys(force = false): void {
|
||||||
|
|||||||
6
server/middleware/clearcookies.ts
Normal file
6
server/middleware/clearcookies.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const clearCookies: Middleware = (_req, res, next) => {
|
||||||
|
res.removeHeader('Set-Cookie');
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default clearCookies;
|
||||||
@@ -800,12 +800,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||||
'/watchlist',
|
'/watchlist',
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
const page = req.params.page ?? 1;
|
const page = Number(req.query.page) ?? 1;
|
||||||
const offset = (page - 1) * itemsPerPage;
|
const offset = (page - 1) * itemsPerPage;
|
||||||
|
|
||||||
const activeUser = await userRepository.findOne({
|
const activeUser = await userRepository.findOne({
|
||||||
@@ -829,8 +829,8 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
page,
|
page,
|
||||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
|
||||||
totalResults: watchlist.size,
|
totalResults: watchlist.totalSize,
|
||||||
results: watchlist.items.map((item) => ({
|
results: watchlist.items.map((item) => ({
|
||||||
ratingKey: item.ratingKey,
|
ratingKey: item.ratingKey,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import TautulliAPI from '@server/api/tautulli';
|
import TautulliAPI from '@server/api/tautulli';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
@@ -168,6 +171,100 @@ mediaRoutes.delete(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
mediaRoutes.delete(
|
||||||
|
'/:id/file',
|
||||||
|
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = getSettings();
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const media = await mediaRepository.findOneOrFail({
|
||||||
|
where: { id: Number(req.params.id) },
|
||||||
|
});
|
||||||
|
const is4k = media.serviceUrl4k !== undefined;
|
||||||
|
const isMovie = media.mediaType === MediaType.MOVIE;
|
||||||
|
let serviceSettings;
|
||||||
|
if (isMovie) {
|
||||||
|
serviceSettings = settings.radarr.find(
|
||||||
|
(radarr) => radarr.isDefault && radarr.is4k === is4k
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
serviceSettings = settings.sonarr.find(
|
||||||
|
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media.serviceId &&
|
||||||
|
media.serviceId >= 0 &&
|
||||||
|
serviceSettings?.id !== media.serviceId
|
||||||
|
) {
|
||||||
|
if (isMovie) {
|
||||||
|
serviceSettings = settings.radarr.find(
|
||||||
|
(radarr) => radarr.id === media.serviceId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
serviceSettings = settings.sonarr.find(
|
||||||
|
(sonarr) => sonarr.id === media.serviceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!serviceSettings) {
|
||||||
|
logger.warn(
|
||||||
|
`There is no default ${
|
||||||
|
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||||
|
}/ server configured. Did you set any of your ${
|
||||||
|
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||||
|
} servers as default?`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
mediaId: media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let service;
|
||||||
|
if (isMovie) {
|
||||||
|
service = new RadarrAPI({
|
||||||
|
apiKey: serviceSettings?.apiKey,
|
||||||
|
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
service = new SonarrAPI({
|
||||||
|
apiKey: serviceSettings?.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMovie) {
|
||||||
|
await (service as RadarrAPI).removeMovie(
|
||||||
|
parseInt(
|
||||||
|
is4k
|
||||||
|
? (media.externalServiceSlug4k as string)
|
||||||
|
: (media.externalServiceSlug as string)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||||
|
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||||
|
if (!tvdbId) {
|
||||||
|
throw new Error('TVDB ID not found');
|
||||||
|
}
|
||||||
|
await (service as SonarrAPI).removeSerie(tvdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong fetching media in delete request', {
|
||||||
|
label: 'Media',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
next({ status: 404, message: 'Media not found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
mediaRoutes.get<{ id: string }, MediaWatchDataResponse>(
|
mediaRoutes.get<{ id: string }, MediaWatchDataResponse>(
|
||||||
'/:id/watch_data',
|
'/:id/watch_data',
|
||||||
isAuthenticated(Permission.ADMIN),
|
isAuthenticated(Permission.ADMIN),
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get<{ id: string; page?: number }, WatchlistResponse>(
|
router.get<{ id: string }, WatchlistResponse>(
|
||||||
'/:id/watchlist',
|
'/:id/watchlist',
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
if (
|
if (
|
||||||
@@ -705,7 +705,7 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
const page = req.params.page ?? 1;
|
const page = Number(req.query.page) ?? 1;
|
||||||
const offset = (page - 1) * itemsPerPage;
|
const offset = (page - 1) * itemsPerPage;
|
||||||
|
|
||||||
const user = await getRepository(User).findOneOrFail({
|
const user = await getRepository(User).findOneOrFail({
|
||||||
@@ -729,8 +729,8 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
page,
|
page,
|
||||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
|
||||||
totalResults: watchlist.size,
|
totalResults: watchlist.totalSize,
|
||||||
results: watchlist.items.map((item) => ({
|
results: watchlist.items.map((item) => ({
|
||||||
ratingKey: item.ratingKey,
|
ratingKey: item.ratingKey,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
})}
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
{showRelative && (
|
{showRelative && (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import type { Collection } from '@server/models/Collection';
|
import type { Collection } from '@server/models/Collection';
|
||||||
@@ -39,20 +40,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
const [requestModal, setRequestModal] = useState(false);
|
const [requestModal, setRequestModal] = useState(false);
|
||||||
const [is4k, setIs4k] = useState(false);
|
const [is4k, setIs4k] = useState(false);
|
||||||
|
|
||||||
const {
|
const returnCollectionDownloadItems = (data: Collection | undefined) => {
|
||||||
data,
|
const [downloadStatus, downloadStatus4k] = [
|
||||||
error,
|
|
||||||
mutate: revalidate,
|
|
||||||
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
|
|
||||||
fallbackData: collection,
|
|
||||||
revalidateOnMount: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: genres } =
|
|
||||||
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
|
||||||
|
|
||||||
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
|
||||||
return [
|
|
||||||
data?.parts.flatMap((item) =>
|
data?.parts.flatMap((item) =>
|
||||||
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
||||||
),
|
),
|
||||||
@@ -60,7 +49,30 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}, [data?.parts]);
|
|
||||||
|
return { downloadStatus, downloadStatus4k };
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
mutate: revalidate,
|
||||||
|
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
|
||||||
|
fallbackData: collection,
|
||||||
|
revalidateOnMount: true,
|
||||||
|
refreshInterval: refreshIntervalHelper(
|
||||||
|
returnCollectionDownloadItems(collection),
|
||||||
|
15000
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: genres } =
|
||||||
|
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
||||||
|
|
||||||
|
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
||||||
|
const downloadItems = returnCollectionDownloadItems(data);
|
||||||
|
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const [titles, titles4k] = useMemo(() => {
|
const [titles, titles4k] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={isOpen}
|
show={isOpen}
|
||||||
enter="transition ease-out duration-100 opacity-0"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75 opacity-100"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
appear
|
appear
|
||||||
as="div"
|
as="div"
|
||||||
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
|
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
@@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
<Transition
|
<Transition
|
||||||
appear
|
appear
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition opacity-0 duration-300 transform scale-75"
|
enter="transition duration-300"
|
||||||
enterFrom="opacity-0 scale-75"
|
enterFrom="opacity-0 scale-75"
|
||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={loading}
|
show={loading}
|
||||||
@@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Transition
|
<Transition
|
||||||
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-headline"
|
aria-labelledby="modal-headline"
|
||||||
@@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
}}
|
}}
|
||||||
appear
|
appear
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition opacity-0 duration-300 transform scale-75"
|
enter="transition duration-300"
|
||||||
enterFrom="opacity-0 scale-75"
|
enterFrom="opacity-0 scale-75"
|
||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={!loading}
|
show={!loading}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
checked ? 'translate-x-5' : 'translate-x-0'
|
checked ? 'translate-x-5' : 'translate-x-0'
|
||||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ const SlideOver = ({
|
|||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={show}
|
show={show}
|
||||||
appear
|
appear
|
||||||
enter="opacity-0 transition ease-in-out duration-300"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition ease-in-out duration-300"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
@@ -58,16 +58,16 @@ const SlideOver = ({
|
|||||||
<section className="absolute inset-y-0 right-0 flex max-w-full">
|
<section className="absolute inset-y-0 right-0 flex max-w-full">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
appear
|
appear
|
||||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
enter="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||||
enterFrom="translate-x-full"
|
enterFrom="translate-x-full"
|
||||||
enterTo="translate-x-0"
|
enterTo="translate-x-0"
|
||||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
leave="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||||
leaveFrom="translate-x-0"
|
leaveFrom="translate-x-0"
|
||||||
leaveTo="translate-x-full"
|
leaveTo="translate-x-full"
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||||
<div
|
<div
|
||||||
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
|
className="slideover relative h-full w-screen max-w-md p-2 sm:p-3"
|
||||||
ref={slideoverRef}
|
ref={slideoverRef}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -165,10 +165,10 @@ const Discover = () => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
<Transition
|
<Transition
|
||||||
show={isEditing}
|
show={isEditing}
|
||||||
enter="transition transform duration-300"
|
enter="transition duration-300"
|
||||||
enterFrom="opacity-0 translate-y-6"
|
enterFrom="opacity-0 translate-y-6"
|
||||||
enterTo="opacity-100 translate-y-0"
|
enterTo="opacity-100 translate-y-0"
|
||||||
leave="transition duration-300 transform"
|
leave="transition duration-300"
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
leaveTo="opacity-0 translate-y-6"
|
leaveTo="opacity-0 translate-y-6"
|
||||||
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
|
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ const IssueComment = ({
|
|||||||
>
|
>
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={showDeleteModal}
|
show={showDeleteModal}
|
||||||
@@ -115,11 +115,11 @@ const IssueComment = ({
|
|||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={open}
|
show={open}
|
||||||
enter="transition ease-out duration-100"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items
|
<Menu.Items
|
||||||
static
|
static
|
||||||
@@ -164,7 +164,7 @@ const IssueComment = ({
|
|||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`absolute top-3 z-10 h-3 w-3 rotate-45 transform bg-gray-800 shadow ring-1 ring-gray-500 ${
|
className={`absolute top-3 z-10 h-3 w-3 rotate-45 bg-gray-800 shadow ring-1 ring-gray-500 ${
|
||||||
isReversed ? '-left-1' : '-right-1'
|
isReversed ? '-left-1' : '-right-1'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ const IssueDescription = ({
|
|||||||
show={open}
|
show={open}
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition ease-out duration-100"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items
|
<Menu.Items
|
||||||
static
|
static
|
||||||
|
|||||||
@@ -187,10 +187,10 @@ const IssueDetails = () => {
|
|||||||
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
|
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={showDeleteModal}
|
show={showDeleteModal}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ interface IssueModalProps {
|
|||||||
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
|
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={show}
|
show={show}
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ const LanguagePicker = () => {
|
|||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
show={isDropdownOpen}
|
show={isDropdownOpen}
|
||||||
enter="transition ease-out duration-100 opacity-0"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75 opacity-100"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"
|
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"
|
||||||
|
|||||||
@@ -131,13 +131,13 @@ const MobileMenu = () => {
|
|||||||
show={isOpen}
|
show={isOpen}
|
||||||
as="div"
|
as="div"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
enter="transition transform duration-500"
|
enter="transition duration-500"
|
||||||
enterFrom="opacity-0 translate-y-0"
|
enterFrom="opacity-0 translate-y-0"
|
||||||
enterTo="opacity-100 -translate-y-full"
|
enterTo="opacity-100 -translate-y-full"
|
||||||
leave="transition duration-500 transform"
|
leave="transition duration-500"
|
||||||
leaveFrom="opacity-100 -translate-y-full"
|
leaveFrom="opacity-100 -translate-y-full"
|
||||||
leaveTo="opacity-0 translate-y-0"
|
leaveTo="opacity-0 translate-y-0"
|
||||||
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
|
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
|
||||||
>
|
>
|
||||||
{filteredLinks.map((link) => {
|
{filteredLinks.map((link) => {
|
||||||
const isActive = router.pathname.match(link.activeRegExp);
|
const isActive = router.pathname.match(link.activeRegExp);
|
||||||
@@ -167,27 +167,29 @@ const MobileMenu = () => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
|
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
|
||||||
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
|
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
|
||||||
{filteredLinks.slice(0, 4).map((link) => {
|
{filteredLinks
|
||||||
const isActive =
|
.slice(0, filteredLinks.length === 5 ? 5 : 4)
|
||||||
router.pathname.match(link.activeRegExp) && !isOpen;
|
.map((link) => {
|
||||||
return (
|
const isActive =
|
||||||
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
router.pathname.match(link.activeRegExp) && !isOpen;
|
||||||
<a
|
return (
|
||||||
className={`flex flex-col items-center space-y-1 ${
|
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
||||||
isActive ? 'text-indigo-500' : ''
|
<a
|
||||||
}`}
|
className={`flex flex-col items-center space-y-1 ${
|
||||||
>
|
isActive ? 'text-indigo-500' : ''
|
||||||
{cloneElement(
|
}`}
|
||||||
isActive ? link.svgIconSelected : link.svgIcon,
|
>
|
||||||
{
|
{cloneElement(
|
||||||
className: 'h-6 w-6',
|
isActive ? link.svgIconSelected : link.svgIcon,
|
||||||
}
|
{
|
||||||
)}
|
className: 'h-6 w-6',
|
||||||
</a>
|
}
|
||||||
</Link>
|
)}
|
||||||
);
|
</a>
|
||||||
})}
|
</Link>
|
||||||
{filteredLinks.length > 4 && (
|
);
|
||||||
|
})}
|
||||||
|
{filteredLinks.length > 4 && filteredLinks.length !== 5 && (
|
||||||
<button
|
<button
|
||||||
className={`flex flex-col items-center space-y-1 ${
|
className={`flex flex-col items-center space-y-1 ${
|
||||||
isOpen ? 'text-indigo-500' : ''
|
isOpen ? 'text-indigo-500' : ''
|
||||||
|
|||||||
@@ -128,10 +128,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
|||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition ease-in-out duration-300 transform"
|
enter="transition-transform ease-in-out duration-300"
|
||||||
enterFrom="-translate-x-full"
|
enterFrom="-translate-x-full"
|
||||||
enterTo="translate-x-0"
|
enterTo="translate-x-0"
|
||||||
leave="transition ease-in-out duration-300 transform"
|
leave="transition-transform ease-in-out duration-300"
|
||||||
leaveFrom="translate-x-0"
|
leaveFrom="translate-x-0"
|
||||||
leaveTo="-translate-x-full"
|
leaveTo="-translate-x-full"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ const UserDropdown = () => {
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition ease-out duration-100"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
appear
|
appear
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">
|
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">
|
||||||
|
|||||||
@@ -100,10 +100,10 @@ const Login = () => {
|
|||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
show={!!error}
|
show={!!error}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||||
import SlideOver from '@app/components/Common/SlideOver';
|
import SlideOver from '@app/components/Common/SlideOver';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import DownloadBlock from '@app/components/DownloadBlock';
|
import DownloadBlock from '@app/components/DownloadBlock';
|
||||||
import IssueBlock from '@app/components/IssueBlock';
|
import IssueBlock from '@app/components/IssueBlock';
|
||||||
import RequestBlock from '@app/components/RequestBlock';
|
import RequestBlock from '@app/components/RequestBlock';
|
||||||
@@ -8,11 +9,20 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { Bars4Icon, ServerIcon } from '@heroicons/react/24/outline';
|
import { Bars4Icon, ServerIcon } from '@heroicons/react/24/outline';
|
||||||
import { CheckCircleIcon, DocumentMinusIcon } from '@heroicons/react/24/solid';
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
DocumentMinusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from '@heroicons/react/24/solid';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
|
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||||
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -32,8 +42,12 @@ const messages = defineMessages({
|
|||||||
manageModalClearMedia: 'Clear Data',
|
manageModalClearMedia: 'Clear Data',
|
||||||
manageModalClearMediaWarning:
|
manageModalClearMediaWarning:
|
||||||
'* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your {mediaServerName} library, the media information will be recreated during the next scan.',
|
'* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your {mediaServerName} library, the media information will be recreated during the next scan.',
|
||||||
|
manageModalRemoveMediaWarning:
|
||||||
|
'* This will irreversibly remove this {mediaType} from {arr}, including all files.',
|
||||||
openarr: 'Open in {arr}',
|
openarr: 'Open in {arr}',
|
||||||
|
removearr: 'Remove from {arr}',
|
||||||
openarr4k: 'Open in 4K {arr}',
|
openarr4k: 'Open in 4K {arr}',
|
||||||
|
removearr4k: 'Remove from 4K {arr}',
|
||||||
downloadstatus: 'Downloads',
|
downloadstatus: 'Downloads',
|
||||||
markavailable: 'Mark as Available',
|
markavailable: 'Mark as Available',
|
||||||
mark4kavailable: 'Mark as Available in 4K',
|
mark4kavailable: 'Mark as Available in 4K',
|
||||||
@@ -88,6 +102,12 @@ const ManageSlideOver = ({
|
|||||||
? `/api/v1/media/${data.mediaInfo.id}/watch_data`
|
? `/api/v1/media/${data.mediaInfo.id}/watch_data`
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
const { data: radarrData } = useSWR<RadarrSettings[]>(
|
||||||
|
'/api/v1/settings/radarr'
|
||||||
|
);
|
||||||
|
const { data: sonarrData } = useSWR<SonarrSettings[]>(
|
||||||
|
'/api/v1/settings/sonarr'
|
||||||
|
);
|
||||||
|
|
||||||
const deleteMedia = async () => {
|
const deleteMedia = async () => {
|
||||||
if (data.mediaInfo) {
|
if (data.mediaInfo) {
|
||||||
@@ -96,6 +116,35 @@ const ManageSlideOver = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteMediaFile = async () => {
|
||||||
|
if (data.mediaInfo) {
|
||||||
|
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
|
||||||
|
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDefaultService = () => {
|
||||||
|
if (data.mediaInfo) {
|
||||||
|
if (data.mediaInfo.mediaType === MediaType.MOVIE) {
|
||||||
|
return (
|
||||||
|
radarrData?.find(
|
||||||
|
(radarr) =>
|
||||||
|
radarr.isDefault && radarr.id === data.mediaInfo?.serviceId
|
||||||
|
) !== undefined
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
sonarrData?.find(
|
||||||
|
(sonarr) =>
|
||||||
|
sonarr.isDefault && sonarr.id === data.mediaInfo?.serviceId
|
||||||
|
) !== undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const markAvailable = async (is4k = false) => {
|
const markAvailable = async (is4k = false) => {
|
||||||
if (data.mediaInfo) {
|
if (data.mediaInfo) {
|
||||||
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
||||||
@@ -149,20 +198,24 @@ const ManageSlideOver = ({
|
|||||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||||
<ul>
|
<ul>
|
||||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||||
<li
|
<Tooltip
|
||||||
key={`dl-status-${status.externalId}-${index}`}
|
key={`dl-status-${status.externalId}-${index}`}
|
||||||
className="border-b border-gray-700 last:border-b-0"
|
content={status.title}
|
||||||
>
|
>
|
||||||
<DownloadBlock downloadItem={status} />
|
<li className="border-b border-gray-700 last:border-b-0">
|
||||||
</li>
|
<DownloadBlock downloadItem={status} />
|
||||||
|
</li>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||||
<li
|
<Tooltip
|
||||||
key={`dl-status-${status.externalId}-${index}`}
|
key={`dl-status-${status.externalId}-${index}`}
|
||||||
className="border-b border-gray-700 last:border-b-0"
|
content={status.title}
|
||||||
>
|
>
|
||||||
<DownloadBlock downloadItem={status} is4k />
|
<li className="border-b border-gray-700 last:border-b-0">
|
||||||
</li>
|
<DownloadBlock downloadItem={status} is4k />
|
||||||
|
</li>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,6 +381,40 @@ const ManageSlideOver = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasPermission(Permission.ADMIN) &&
|
||||||
|
data?.mediaInfo?.serviceUrl &&
|
||||||
|
isDefaultService() && (
|
||||||
|
<div>
|
||||||
|
<ConfirmButton
|
||||||
|
onClick={() => deleteMediaFile()}
|
||||||
|
confirmText={intl.formatMessage(
|
||||||
|
globalMessages.areyousure
|
||||||
|
)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.removearr, {
|
||||||
|
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</ConfirmButton>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.manageModalRemoveMediaWarning,
|
||||||
|
{
|
||||||
|
mediaType: intl.formatMessage(
|
||||||
|
mediaType === 'movie'
|
||||||
|
? messages.movie
|
||||||
|
: messages.tvshow
|
||||||
|
),
|
||||||
|
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -433,21 +520,54 @@ const ManageSlideOver = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data?.mediaInfo?.serviceUrl4k && (
|
{data?.mediaInfo?.serviceUrl4k && (
|
||||||
<a
|
<>
|
||||||
href={data?.mediaInfo?.serviceUrl4k}
|
<a
|
||||||
target="_blank"
|
href={data?.mediaInfo?.serviceUrl4k}
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
className="block"
|
rel="noreferrer"
|
||||||
>
|
className="block"
|
||||||
<Button buttonType="ghost" className="w-full">
|
>
|
||||||
<ServerIcon />
|
<Button buttonType="ghost" className="w-full">
|
||||||
<span>
|
<ServerIcon />
|
||||||
{intl.formatMessage(messages.openarr4k, {
|
<span>
|
||||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
{intl.formatMessage(messages.openarr4k, {
|
||||||
})}
|
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||||
</span>
|
})}
|
||||||
</Button>
|
</span>
|
||||||
</a>
|
</Button>
|
||||||
|
</a>
|
||||||
|
{isDefaultService() && (
|
||||||
|
<div>
|
||||||
|
<ConfirmButton
|
||||||
|
onClick={() => deleteMediaFile()}
|
||||||
|
confirmText={intl.formatMessage(
|
||||||
|
globalMessages.areyousure
|
||||||
|
)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.removearr4k, {
|
||||||
|
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</ConfirmButton>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.manageModalRemoveMediaWarning,
|
||||||
|
{
|
||||||
|
mediaType: intl.formatMessage(
|
||||||
|
mediaType === 'movie'
|
||||||
|
? messages.movie
|
||||||
|
: messages.tvshow
|
||||||
|
),
|
||||||
|
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import {
|
import {
|
||||||
ArrowRightCircleIcon,
|
ArrowRightCircleIcon,
|
||||||
CloudIcon,
|
CloudIcon,
|
||||||
@@ -116,6 +117,13 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
|
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
|
||||||
fallbackData: movie,
|
fallbackData: movie,
|
||||||
|
refreshInterval: refreshIntervalHelper(
|
||||||
|
{
|
||||||
|
downloadStatus: movie?.mediaInfo?.downloadStatus,
|
||||||
|
downloadStatus4k: movie?.mediaInfo?.downloadStatus4k,
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: ratingData } = useSWR<RTRating>(
|
const { data: ratingData } = useSWR<RTRating>(
|
||||||
@@ -651,6 +659,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -670,6 +679,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,11 +91,13 @@ const PersonDetails = () => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
}),
|
}),
|
||||||
deathdate: intl.formatDate(data.deathday, {
|
deathdate: intl.formatDate(data.deathday, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -106,6 +108,7 @@ const PersonDetails = () => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const RegionSelector = ({
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
show={open}
|
show={open}
|
||||||
leave="transition ease-in duration-100"
|
leave="transition-opacity ease-in duration-100"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"
|
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
|
|||||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import { withProperties } from '@app/utils/typeHelpers';
|
import { withProperties } from '@app/utils/typeHelpers';
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
@@ -220,6 +221,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
request.type === 'movie'
|
request.type === 'movie'
|
||||||
? `/api/v1/movie/${request.media.tmdbId}`
|
? `/api/v1/movie/${request.media.tmdbId}`
|
||||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||||
|
|
||||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||||
inView ? `${url}` : null
|
inView ? `${url}` : null
|
||||||
);
|
);
|
||||||
@@ -229,6 +231,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
||||||
fallbackData: request,
|
fallbackData: request,
|
||||||
|
refreshInterval: refreshIntervalHelper(
|
||||||
|
{
|
||||||
|
downloadStatus: request.media.downloadStatus,
|
||||||
|
downloadStatus4k: request.media.downloadStatus4k,
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
|
|||||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@@ -293,6 +294,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
`/api/v1/request/${request.id}`,
|
`/api/v1/request/${request.id}`,
|
||||||
{
|
{
|
||||||
fallbackData: request,
|
fallbackData: request,
|
||||||
|
refreshInterval: refreshIntervalHelper(
|
||||||
|
{
|
||||||
|
downloadStatus: request.media.downloadStatus,
|
||||||
|
downloadStatus4k: request.media.downloadStatus4k,
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -582,10 +582,10 @@ const AdvancedRequester = ({
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
show={open}
|
show={open}
|
||||||
enter="transition ease-in duration-300"
|
enter="transition-opacity ease-in duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in duration-100"
|
leave="transition-opacity ease-in duration-100"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"
|
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ const CollectionRequestModal = ({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
isAllParts() ? 'translate-x-5' : 'translate-x-0'
|
isAllParts() ? 'translate-x-5' : 'translate-x-0'
|
||||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -389,7 +389,7 @@ const CollectionRequestModal = ({
|
|||||||
isSelectedPart(part.id)
|
isSelectedPart(part.id)
|
||||||
? 'translate-x-5'
|
? 'translate-x-5'
|
||||||
: 'translate-x-0'
|
: 'translate-x-0'
|
||||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ const TvRequestModal = ({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
isAllSeasons() ? 'translate-x-5' : 'translate-x-0'
|
isAllSeasons() ? 'translate-x-5' : 'translate-x-0'
|
||||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -631,7 +631,7 @@ const TvRequestModal = ({
|
|||||||
isSelectedSeason(season.seasonNumber)
|
isSelectedSeason(season.seasonNumber)
|
||||||
? 'translate-x-5'
|
? 'translate-x-5'
|
||||||
: 'translate-x-0'
|
: 'translate-x-0'
|
||||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ const RequestModal = ({
|
|||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={show}
|
show={show}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const LibraryItem = ({ isEnabled, name, onToggle }: LibraryItemProps) => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
isEnabled ? 'translate-x-5' : 'translate-x-0'
|
isEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
} relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out`}
|
} relative inline-block h-5 w-5 rounded-full bg-white shadow transition duration-200 ease-in-out`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`${
|
className={`${
|
||||||
|
|||||||
@@ -214,10 +214,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
as="div"
|
as="div"
|
||||||
appear
|
appear
|
||||||
show
|
show
|
||||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacuty-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -63,10 +63,10 @@ const Release = ({ currentVersion, release, isLatest }: ReleaseProps) => {
|
|||||||
<div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3">
|
<div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3">
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={isModalOpen}
|
show={isModalOpen}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
|||||||
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
||||||
'jellyfin-recently-added-sync': 'Jellyfin Recently Added Scan',
|
'jellyfin-recently-added-sync': 'Jellyfin Recently Added Scan',
|
||||||
'jellyfin-full-sync': 'Jellyfin Full Library Scan',
|
'jellyfin-full-sync': 'Jellyfin Full Library Scan',
|
||||||
|
'availability-sync': 'Media Availability Sync',
|
||||||
'radarr-scan': 'Radarr Scan',
|
'radarr-scan': 'Radarr Scan',
|
||||||
'sonarr-scan': 'Sonarr Scan',
|
'sonarr-scan': 'Sonarr Scan',
|
||||||
'download-sync': 'Download Sync',
|
'download-sync': 'Download Sync',
|
||||||
@@ -71,6 +72,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
|||||||
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
|
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
|
||||||
editJobScheduleSelectorMinutes:
|
editJobScheduleSelectorMinutes:
|
||||||
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
|
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
|
||||||
|
editJobScheduleSelectorSeconds:
|
||||||
|
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
|
||||||
imagecache: 'Image Cache',
|
imagecache: 'Image Cache',
|
||||||
imagecacheDescription:
|
imagecacheDescription:
|
||||||
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
||||||
@@ -82,7 +85,7 @@ interface Job {
|
|||||||
id: JobId;
|
id: JobId;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
interval: 'short' | 'long' | 'fixed';
|
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
nextExecutionTime: string;
|
nextExecutionTime: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@@ -93,10 +96,11 @@ type JobModalState = {
|
|||||||
job?: Job;
|
job?: Job;
|
||||||
scheduleHours: number;
|
scheduleHours: number;
|
||||||
scheduleMinutes: number;
|
scheduleMinutes: number;
|
||||||
|
scheduleSeconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JobModalAction =
|
type JobModalAction =
|
||||||
| { type: 'set'; hours?: number; minutes?: number }
|
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
|
||||||
| {
|
| {
|
||||||
type: 'close';
|
type: 'close';
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,7 @@ const jobModalReducer = (
|
|||||||
job: action.job,
|
job: action.job,
|
||||||
scheduleHours: 1,
|
scheduleHours: 1,
|
||||||
scheduleMinutes: 5,
|
scheduleMinutes: 5,
|
||||||
|
scheduleSeconds: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'set':
|
case 'set':
|
||||||
@@ -126,6 +131,7 @@ const jobModalReducer = (
|
|||||||
...state,
|
...state,
|
||||||
scheduleHours: action.hours ?? state.scheduleHours,
|
scheduleHours: action.hours ?? state.scheduleHours,
|
||||||
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
|
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
|
||||||
|
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -153,6 +159,7 @@ const SettingsJobs = () => {
|
|||||||
isOpen: false,
|
isOpen: false,
|
||||||
scheduleHours: 1,
|
scheduleHours: 1,
|
||||||
scheduleMinutes: 5,
|
scheduleMinutes: 5,
|
||||||
|
scheduleSeconds: 30,
|
||||||
});
|
});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
@@ -205,9 +212,11 @@ const SettingsJobs = () => {
|
|||||||
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
|
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (jobModalState.job?.interval === 'short') {
|
if (jobModalState.job?.interval === 'seconds') {
|
||||||
|
jobScheduleCron.splice(0, 2, `*/${jobModalState.scheduleSeconds}`, '*');
|
||||||
|
} else if (jobModalState.job?.interval === 'minutes') {
|
||||||
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
|
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
|
||||||
} else if (jobModalState.job?.interval === 'long') {
|
} else if (jobModalState.job?.interval === 'hours') {
|
||||||
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
|
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
|
||||||
} else {
|
} else {
|
||||||
// jobs with interval: fixed should not be editable
|
// jobs with interval: fixed should not be editable
|
||||||
@@ -249,10 +258,10 @@ const SettingsJobs = () => {
|
|||||||
/>
|
/>
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={jobModalState.isOpen}
|
show={jobModalState.isOpen}
|
||||||
@@ -291,7 +300,30 @@ const SettingsJobs = () => {
|
|||||||
{intl.formatMessage(messages.editJobSchedulePrompt)}
|
{intl.formatMessage(messages.editJobSchedulePrompt)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
{jobModalState.job?.interval === 'short' ? (
|
{jobModalState.job?.interval === 'seconds' ? (
|
||||||
|
<select
|
||||||
|
name="jobScheduleSeconds"
|
||||||
|
className="inline"
|
||||||
|
value={jobModalState.scheduleSeconds}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'set',
|
||||||
|
seconds: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[30, 45, 60].map((v) => (
|
||||||
|
<option value={v} key={`jobScheduleSeconds-${v}`}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.editJobScheduleSelectorSeconds,
|
||||||
|
{
|
||||||
|
jobScheduleSeconds: v,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : jobModalState.job?.interval === 'minutes' ? (
|
||||||
<select
|
<select
|
||||||
name="jobScheduleMinutes"
|
name="jobScheduleMinutes"
|
||||||
className="inline"
|
className="inline"
|
||||||
|
|||||||
@@ -143,10 +143,10 @@ const SettingsLogs = () => {
|
|||||||
/>
|
/>
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
appear
|
appear
|
||||||
|
|||||||
@@ -247,10 +247,10 @@ const SettingsServices = () => {
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={deleteServerModal.open}
|
show={deleteServerModal.open}
|
||||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacuty-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -223,10 +223,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
as="div"
|
as="div"
|
||||||
appear
|
appear
|
||||||
show
|
show
|
||||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacuty-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ const StatusChecker = () => {
|
|||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
appear
|
appear
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const TitleCard = ({
|
|||||||
: intl.formatMessage(globalMessages.tvshow)}
|
: intl.formatMessage(globalMessages.tvshow)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{currentStatus && (
|
{currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
|
||||||
<div className="pointer-events-none z-40 flex items-center">
|
<div className="pointer-events-none z-40 flex items-center">
|
||||||
<StatusBadgeMini
|
<StatusBadgeMini
|
||||||
status={currentStatus}
|
status={currentStatus}
|
||||||
@@ -154,10 +154,10 @@ const TitleCard = ({
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={isUpdating}
|
show={isUpdating}
|
||||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
@@ -169,10 +169,10 @@ const TitleCard = ({
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={!image || showDetail || showRequestModal}
|
show={!image || showDetail || showRequestModal}
|
||||||
enter="transition transform opacity-0"
|
enter="transition-opacity"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition transform opacity-100"
|
leave="transition-opacity"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import { Disclosure, Transition } from '@headlessui/react';
|
import { Disclosure, Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
ArrowRightCircleIcon,
|
ArrowRightCircleIcon,
|
||||||
@@ -112,6 +113,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, {
|
} = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, {
|
||||||
fallbackData: tv,
|
fallbackData: tv,
|
||||||
|
refreshInterval: refreshIntervalHelper(
|
||||||
|
{
|
||||||
|
downloadStatus: tv?.mediaInfo?.downloadStatus,
|
||||||
|
downloadStatus4k: tv?.mediaInfo?.downloadStatus4k,
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: ratingData } = useSWR<RTRating>(
|
const { data: ratingData } = useSWR<RTRating>(
|
||||||
@@ -759,18 +767,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
)}
|
)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={`${
|
className={`${
|
||||||
open ? 'rotate-180 transform' : ''
|
open ? 'rotate-180' : ''
|
||||||
} h-6 w-6 text-gray-500`}
|
} h-6 w-6 text-gray-500`}
|
||||||
/>
|
/>
|
||||||
</Disclosure.Button>
|
</Disclosure.Button>
|
||||||
<Transition
|
<Transition
|
||||||
show={open}
|
show={open}
|
||||||
enter="transition duration-100 ease-out"
|
enter="transition-opacity duration-100 ease-out"
|
||||||
enterFrom="transform opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="transform opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition duration-75 ease-out"
|
leave="transition-opacity duration-75 ease-out"
|
||||||
leaveFrom="transform opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="transform opacity-0"
|
leaveTo="opacity-0"
|
||||||
// Not sure why this transition is adding a margin without this here
|
// Not sure why this transition is adding a margin without this here
|
||||||
style={{ margin: '0px' }}
|
style={{ margin: '0px' }}
|
||||||
>
|
>
|
||||||
@@ -876,6 +884,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -890,6 +899,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
|
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
|
||||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -194,7 +194,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
isSelectedUser(user.id)
|
isSelectedUser(user.id)
|
||||||
? 'translate-x-5'
|
? 'translate-x-5'
|
||||||
: 'translate-x-0'
|
: 'translate-x-0'
|
||||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -233,10 +233,10 @@ const UserList = () => {
|
|||||||
<PageTitle title={intl.formatMessage(messages.users)} />
|
<PageTitle title={intl.formatMessage(messages.users)} />
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={deleteModal.isOpen}
|
show={deleteModal.isOpen}
|
||||||
@@ -262,10 +262,10 @@ const UserList = () => {
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={createModal.isOpen}
|
show={createModal.isOpen}
|
||||||
@@ -445,10 +445,10 @@ const UserList = () => {
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={showBulkEditModal}
|
show={showBulkEditModal}
|
||||||
@@ -466,10 +466,10 @@ const UserList = () => {
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={showImportModal}
|
show={showImportModal}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type AvailableLocale =
|
|||||||
| 'sq'
|
| 'sq'
|
||||||
| 'sr'
|
| 'sr'
|
||||||
| 'sv'
|
| 'sv'
|
||||||
|
| 'ua'
|
||||||
| 'zh-CN'
|
| 'zh-CN'
|
||||||
| 'zh-TW';
|
| 'zh-TW';
|
||||||
|
|
||||||
@@ -125,6 +126,10 @@ export const availableLanguages: AvailableLanguageObject = {
|
|||||||
code: 'ja',
|
code: 'ja',
|
||||||
display: '日本語',
|
display: '日本語',
|
||||||
},
|
},
|
||||||
|
ua: {
|
||||||
|
code: 'ua',
|
||||||
|
display: 'українська',
|
||||||
|
},
|
||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
code: 'zh-TW',
|
code: 'zh-TW',
|
||||||
display: '繁體中文',
|
display: '繁體中文',
|
||||||
|
|||||||
@@ -631,6 +631,7 @@
|
|||||||
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
|
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
|
||||||
"components.Settings.SettingsAbout.uptodate": "Up to Date",
|
"components.Settings.SettingsAbout.uptodate": "Up to Date",
|
||||||
"components.Settings.SettingsAbout.version": "Version",
|
"components.Settings.SettingsAbout.version": "Version",
|
||||||
|
"components.Settings.SettingsJobsCache.availability-sync": "Media Availability Sync",
|
||||||
"components.Settings.SettingsJobsCache.cache": "Cache",
|
"components.Settings.SettingsJobsCache.cache": "Cache",
|
||||||
"components.Settings.SettingsJobsCache.cacheDescription": "Jellyseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.",
|
"components.Settings.SettingsJobsCache.cacheDescription": "Jellyseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.",
|
||||||
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.",
|
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.",
|
||||||
@@ -649,6 +650,7 @@
|
|||||||
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
|
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
|
||||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
|
||||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
|
||||||
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
|
||||||
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
|
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
|
||||||
"components.Settings.SettingsJobsCache.jelly-recently-added-scan": "Jellyfin Recently Added Scan",
|
"components.Settings.SettingsJobsCache.jelly-recently-added-scan": "Jellyfin Recently Added Scan",
|
||||||
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
|
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1143
src/i18n/locale/ua.json
Normal file
1143
src/i18n/locale/ua.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,8 @@ const loadLocaleData = (locale: AvailableLocale): Promise<any> => {
|
|||||||
return import('../i18n/locale/sr.json');
|
return import('../i18n/locale/sr.json');
|
||||||
case 'sv':
|
case 'sv':
|
||||||
return import('../i18n/locale/sv.json');
|
return import('../i18n/locale/sv.json');
|
||||||
|
case 'ua':
|
||||||
|
return import('../i18n/locale/ua.json');
|
||||||
case 'zh-CN':
|
case 'zh-CN':
|
||||||
return import('../i18n/locale/zh_Hans.json');
|
return import('../i18n/locale/zh_Hans.json');
|
||||||
case 'zh-TW':
|
case 'zh-TW':
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slideover {
|
.slideover {
|
||||||
padding-top: calc(1rem + env(safe-area-inset-top)) !important;
|
padding-top: calc(0.75rem + env(safe-area-inset-top)) !important;
|
||||||
padding-bottom: calc(1rem + env(safe-area-inset-top)) !important;
|
padding-bottom: calc(0.75rem + env(safe-area-inset-top)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-close-button {
|
.sidebar-close-button {
|
||||||
@@ -453,7 +453,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type='range']::-webkit-slider-thumb {
|
input[type='range']::-webkit-slider-thumb {
|
||||||
@apply rounded-full bg-indigo-500;
|
@apply rounded-full border-0 bg-indigo-500;
|
||||||
|
pointer-events: all;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-thumb {
|
||||||
|
@apply rounded-full border-0 bg-indigo-500;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
|||||||
18
src/utils/refreshIntervalHelper.ts
Normal file
18
src/utils/refreshIntervalHelper.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||||
|
|
||||||
|
export const refreshIntervalHelper = (
|
||||||
|
downloadItem: {
|
||||||
|
downloadStatus: DownloadingItem[] | undefined;
|
||||||
|
downloadStatus4k: DownloadingItem[] | undefined;
|
||||||
|
},
|
||||||
|
timer: number
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
(downloadItem.downloadStatus ?? []).length > 0 ||
|
||||||
|
(downloadItem.downloadStatus4k ?? []).length > 0
|
||||||
|
) {
|
||||||
|
return timer;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user