mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
9 Commits
fix-typeor
...
preview-ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6315ba5246 | ||
|
|
f6b0147049 | ||
|
|
f50f4af4ad | ||
|
|
97784e568a | ||
|
|
470c96a4a0 | ||
|
|
a46860ed15 | ||
|
|
5c0d5262d5 | ||
|
|
e3e5c2028e | ||
|
|
f03c85ad34 |
@@ -277,8 +277,7 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/13810742?v=4",
|
||||
"profile": "https://athfan.com",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"code"
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -839,123 +838,6 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "benbeauchamp7",
|
||||
"name": "Ben Beauchamp",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/43358492?v=4",
|
||||
"profile": "https://github.com/benbeauchamp7",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "j0srisk",
|
||||
"name": "Joseph Risk",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
||||
"profile": "http://josephrisk.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Loetwiek",
|
||||
"name": "Loetwiek",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
||||
"profile": "https://github.com/Loetwiek",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Fuochi",
|
||||
"name": "Fuochi",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
||||
"profile": "https://github.com/Fuochi",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "demrich",
|
||||
"name": "David Emrich",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
||||
"profile": "https://github.com/demrich",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "maxnatamo",
|
||||
"name": "Max T. Kristiansen",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
||||
"profile": "https://maxtrier.dk",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "DamsDev1",
|
||||
"name": "Damien Fajole",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
||||
"profile": "https://damsdev.me",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "AhmedNSidd",
|
||||
"name": "Ahmed Siddiqui",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
||||
"profile": "https://github.com/AhmedNSidd",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "JackW6809",
|
||||
"name": "JackOXI",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4",
|
||||
"profile": "https://github.com/JackW6809",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "StancuFlorin",
|
||||
"name": "Stancu Florin",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4",
|
||||
"profile": "http://indicus.ro",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lmiklosko",
|
||||
"name": "Lukas Miklosko",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/44380311?v=4",
|
||||
"profile": "https://github.com/lmiklosko",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "gauthier-th",
|
||||
"name": "Gauthier",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
||||
"profile": "https://gauthierth.fr/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "vfaergestad",
|
||||
"name": "vfaergestad",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/49147564?v=4",
|
||||
"profile": "https://github.com/vfaergestad",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -19,6 +19,5 @@
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"files.associations": {
|
||||
"globals.css": "tailwindcss"
|
||||
},
|
||||
"i18n-ally.localesPaths": ["src/i18n/locale"]
|
||||
}
|
||||
}
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -42,13 +42,13 @@ FROM node:22-alpine
|
||||
ARG BUILD_DATE
|
||||
ARG BUILD_VERSION
|
||||
LABEL \
|
||||
org.opencontainers.image.authors="Fallenbagel" \
|
||||
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
|
||||
org.opencontainers.image.created=${BUILD_DATE} \
|
||||
org.opencontainers.image.version=${BUILD_VERSION} \
|
||||
org.opencontainers.image.title="Jellyseerr" \
|
||||
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
org.opencontainers.image.authors="Fallenbagel" \
|
||||
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
|
||||
org.opencontainers.image.created=${BUILD_DATE} \
|
||||
org.opencontainers.image.version=${BUILD_VERSION} \
|
||||
org.opencontainers.image.title="Jellyseerr" \
|
||||
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-92-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-91-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||
@@ -202,9 +202,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brotaxt"><img src="https://avatars.githubusercontent.com/u/25477935?v=4?s=100" width="100px;" alt="DominicKo"/><br /><sub><b>DominicKo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=brotaxt" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://doctolib.com"><img src="https://avatars.githubusercontent.com/u/30508927?v=4?s=100" width="100px;" alt="Corentin Normand"/><br /><sub><b>Corentin Normand</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=corentinnormand" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/benbeauchamp7"><img src="https://avatars.githubusercontent.com/u/43358492?v=4?s=100" width="100px;" alt="Ben Beauchamp"/><br /><sub><b>Ben Beauchamp</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benbeauchamp7" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -358,7 +355,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmiklosko"><img src="https://avatars.githubusercontent.com/u/44380311?v=4?s=100" width="100px;" alt="Lukas Miklosko"/><br /><sub><b>Lukas Miklosko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lmiklosko" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vfaergestad"><img src="https://avatars.githubusercontent.com/u/49147564?v=4?s=100" width="100px;" alt="vfaergestad"/><br /><sub><b>vfaergestad</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=vfaergestad" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3,7 +3,7 @@ kubeVersion: ">=1.23.0-0"
|
||||
name: jellyseerr-chart
|
||||
description: Jellyseerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 2.4.0
|
||||
version: 2.3.3
|
||||
appVersion: "2.5.2"
|
||||
maintainers:
|
||||
- name: Jellyseerr
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# jellyseerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Jellyseerr helm chart for Kubernetes
|
||||
|
||||
@@ -52,9 +52,6 @@ Kubernetes: `>=1.23.0-0`
|
||||
| podAnnotations | object | `{}` | |
|
||||
| podLabels | object | `{}` | |
|
||||
| podSecurityContext | object | `{}` | |
|
||||
| probes.livenessProbe | object | `{}` | Configure liveness probe |
|
||||
| probes.readinessProbe | object | `{}` | Configure readiness probe |
|
||||
| probes.startupProbe | string | `nil` | Configure startup probe |
|
||||
| replicaCount | int | `1` | |
|
||||
| resources | object | `{}` | |
|
||||
| securityContext | object | `{}` | |
|
||||
|
||||
@@ -48,44 +48,10 @@ spec:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
{{- if .Values.probes.livenessProbe.initialDelaySeconds }}
|
||||
initialDelaySeconds: {{ .Values.probes.livenessProbe.initialDelaySeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.livenessProbe.periodSeconds }}
|
||||
periodSeconds: {{ .Values.probes.livenessProbe.periodSeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.livenessProbe.timeoutSeconds }}
|
||||
timeoutSeconds: {{ .Values.probes.livenessProbe.timeoutSeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.livenessProbe.successThreshold }}
|
||||
successThreshold: {{ .Values.probes.livenessProbe.successThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.livenessProbe.failureThreshold }}
|
||||
failureThreshold: {{ .Values.probes.livenessProbe.failureThreshold }}
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
{{- if .Values.probes.readinessProbe.initialDelaySeconds }}
|
||||
initialDelaySeconds: {{ .Values.probes.readinessProbe.initialDelaySeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.readinessProbe.periodSeconds }}
|
||||
periodSeconds: {{ .Values.probes.readinessProbe.periodSeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.readinessProbe.timeoutSeconds }}
|
||||
timeoutSeconds: {{ .Values.probes.readinessProbe.timeoutSeconds }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.readinessProbe.successThreshold }}
|
||||
successThreshold: {{ .Values.probes.readinessProbe.successThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.readinessProbe.failureThreshold }}
|
||||
failureThreshold: {{ .Values.probes.readinessProbe.failureThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.startupProbe }}
|
||||
startupProbe:
|
||||
{{- toYaml .Values.probes.startupProbe | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.extraEnv }}
|
||||
|
||||
@@ -16,27 +16,6 @@ fullnameOverride: ""
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
# Liveness / Readiness / Startup Probes
|
||||
probes:
|
||||
# -- Configure liveness probe
|
||||
livenessProbe: {}
|
||||
# initialDelaySeconds: 60
|
||||
# periodSeconds: 30
|
||||
# timeoutSeconds: 5
|
||||
# successThreshold: 1
|
||||
# failureThreshold: 5
|
||||
# -- Configure readiness probe
|
||||
readinessProbe: {}
|
||||
# initialDelaySeconds: 60
|
||||
# periodSeconds: 30
|
||||
# timeoutSeconds: 5
|
||||
# successThreshold: 1
|
||||
# failureThreshold: 5
|
||||
# -- Configure startup probe
|
||||
startupProbe: null
|
||||
# tcpSocket:
|
||||
# port: http
|
||||
|
||||
# -- Environment variables to add to the jellyseerr pods
|
||||
extraEnv: []
|
||||
# -- Environment variables from secrets or configmaps to add to the jellyseerr pods
|
||||
@@ -57,15 +36,15 @@ podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
# fsGroup: 2000
|
||||
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
@@ -91,8 +70,8 @@ ingress:
|
||||
enabled: false
|
||||
ingressClassName: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
hosts:
|
||||
- host: chart-example.local
|
||||
paths:
|
||||
@@ -104,16 +83,16 @@ ingress:
|
||||
# - chart-example.local
|
||||
|
||||
resources: {}
|
||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||
# choice for the user. This also increases chances charts run on environments with little
|
||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||
# choice for the user. This also increases chances charts run on environments with little
|
||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
# -- Additional volumes on the output Deployment definition.
|
||||
volumes: []
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
"discoverRegion": "",
|
||||
"streamingRegion": "",
|
||||
"originalLanguage": "",
|
||||
"blacklistedTags": "",
|
||||
"blacklistedTagsLimit": 50,
|
||||
"trustProxy": false,
|
||||
"mediaServerType": 1,
|
||||
"partialRequestsEnabled": true,
|
||||
|
||||
@@ -62,14 +62,6 @@ Set the default display language for Jellyseerr. Users can override this setting
|
||||
|
||||
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
|
||||
|
||||
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
|
||||
|
||||
These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.
|
||||
|
||||
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.
|
||||
|
||||
Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
|
||||
|
||||
## Hide Available Media
|
||||
|
||||
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
|
||||
|
||||
@@ -1157,7 +1157,7 @@ components:
|
||||
status:
|
||||
type: number
|
||||
example: 0
|
||||
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED`
|
||||
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
|
||||
requests:
|
||||
type: array
|
||||
readOnly: true
|
||||
@@ -4239,12 +4239,6 @@ paths:
|
||||
type: string
|
||||
nullable: true
|
||||
example: dune
|
||||
- in: query
|
||||
name: filter
|
||||
schema:
|
||||
type: string
|
||||
enum: [all, manual, blacklistedTags]
|
||||
default: manual
|
||||
responses:
|
||||
'200':
|
||||
description: Blacklisted items returned
|
||||
@@ -5676,8 +5670,6 @@ paths:
|
||||
processing,
|
||||
unavailable,
|
||||
failed,
|
||||
deleted,
|
||||
completed,
|
||||
]
|
||||
- in: query
|
||||
name: sort
|
||||
@@ -6424,16 +6416,7 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
[
|
||||
all,
|
||||
available,
|
||||
partial,
|
||||
allavailable,
|
||||
processing,
|
||||
pending,
|
||||
deleted,
|
||||
]
|
||||
enum: [all, available, partial, allavailable, processing, pending]
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
@@ -6509,7 +6492,7 @@ paths:
|
||||
example: available
|
||||
schema:
|
||||
type: string
|
||||
enum: [available, partial, processing, pending, unknown, deleted]
|
||||
enum: [available, partial, processing, pending, unknown]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.2.5",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typeorm": "0.3.12",
|
||||
"typeorm": "0.3.11",
|
||||
"undici": "^7.3.0",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"web-push": "3.5.0",
|
||||
|
||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
@@ -64,7 +64,7 @@ importers:
|
||||
version: 2.11.0
|
||||
connect-typeorm:
|
||||
specifier: 1.1.4
|
||||
version: 1.1.4(typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)))
|
||||
version: 1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)))
|
||||
cookie-parser:
|
||||
specifier: 1.4.7
|
||||
version: 1.4.7
|
||||
@@ -213,8 +213,8 @@ importers:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
typeorm:
|
||||
specifier: 0.3.12
|
||||
version: 0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
||||
specifier: 0.3.11
|
||||
version: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
||||
ua-parser-js:
|
||||
specifier: ^1.0.35
|
||||
version: 1.0.40
|
||||
@@ -6973,11 +6973,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
mkdirp@2.1.6:
|
||||
resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
modify-values@1.0.1:
|
||||
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -9195,8 +9190,8 @@ packages:
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
typeorm@0.3.12:
|
||||
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==}
|
||||
typeorm@0.3.11:
|
||||
resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
|
||||
engines: {node: '>= 12.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -9207,7 +9202,7 @@ packages:
|
||||
ioredis: ^5.0.4
|
||||
mongodb: ^3.6.0
|
||||
mssql: ^7.3.0
|
||||
mysql2: ^2.2.5 || ^3.0.1
|
||||
mysql2: ^2.2.5
|
||||
oracledb: ^5.1.0
|
||||
pg: ^8.5.1
|
||||
pg-native: ^3.0.0
|
||||
@@ -14918,13 +14913,13 @@ snapshots:
|
||||
ini: 1.3.8
|
||||
proto-list: 1.2.4
|
||||
|
||||
connect-typeorm@1.1.4(typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))):
|
||||
connect-typeorm@1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))):
|
||||
dependencies:
|
||||
'@types/debug': 0.0.31
|
||||
'@types/express-session': 1.17.6
|
||||
debug: 4.4.0(supports-color@5.5.0)
|
||||
express-session: 1.17.3
|
||||
typeorm: 0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
||||
typeorm: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -15799,7 +15794,7 @@ snapshots:
|
||||
debug: 4.3.5
|
||||
enhanced-resolve: 5.17.0
|
||||
eslint: 8.35.0
|
||||
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0)
|
||||
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0)
|
||||
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.54.0(eslint@8.35.0)(typescript@4.9.5))(eslint@8.35.0)
|
||||
fast-glob: 3.3.2
|
||||
get-tsconfig: 4.7.5
|
||||
@@ -15821,7 +15816,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0):
|
||||
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0):
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
optionalDependencies:
|
||||
@@ -18287,8 +18282,6 @@ snapshots:
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
|
||||
mkdirp@2.1.6: {}
|
||||
|
||||
modify-values@1.0.1: {}
|
||||
|
||||
moment@2.30.1: {}
|
||||
@@ -20706,7 +20699,7 @@ snapshots:
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)):
|
||||
typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)):
|
||||
dependencies:
|
||||
'@sqltools/formatter': 1.2.5
|
||||
app-root-path: 3.1.0
|
||||
@@ -20714,15 +20707,15 @@ snapshots:
|
||||
chalk: 4.1.2
|
||||
cli-highlight: 2.1.11
|
||||
date-fns: 2.29.3
|
||||
debug: 4.4.0(supports-color@5.5.0)
|
||||
debug: 4.3.5
|
||||
dotenv: 16.4.5
|
||||
glob: 8.1.0
|
||||
glob: 7.2.3
|
||||
js-yaml: 4.1.0
|
||||
mkdirp: 2.1.6
|
||||
mkdirp: 1.0.4
|
||||
reflect-metadata: 0.1.13
|
||||
sha.js: 2.4.11
|
||||
tslib: 2.8.1
|
||||
uuid: 9.0.1
|
||||
tslib: 2.6.3
|
||||
uuid: 8.3.2
|
||||
xml2js: 0.4.23
|
||||
yargs: 17.7.2
|
||||
optionalDependencies:
|
||||
@@ -20891,7 +20884,8 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
uuid@9.0.1:
|
||||
optional: true
|
||||
|
||||
uvu@0.5.6:
|
||||
dependencies:
|
||||
|
||||
@@ -367,12 +367,12 @@ class PlexTvAPI extends ExternalAPI {
|
||||
|
||||
public async pingToken() {
|
||||
try {
|
||||
const response = await this.axios.get('/api/v2/ping', {
|
||||
const data: { pong: unknown } = await this.get('/api/v2/ping', {
|
||||
headers: {
|
||||
'X-Plex-Client-Identifier': randomUUID(),
|
||||
},
|
||||
});
|
||||
if (!response?.data?.pong) {
|
||||
if (!data?.pong) {
|
||||
throw new Error('No pong response');
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -38,26 +38,23 @@ interface SingleSearchOptions extends SearchOptions {
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export const SortOptionsIterable = [
|
||||
'popularity.desc',
|
||||
'popularity.asc',
|
||||
'release_date.desc',
|
||||
'release_date.asc',
|
||||
'revenue.desc',
|
||||
'revenue.asc',
|
||||
'primary_release_date.desc',
|
||||
'primary_release_date.asc',
|
||||
'original_title.asc',
|
||||
'original_title.desc',
|
||||
'vote_average.desc',
|
||||
'vote_average.asc',
|
||||
'vote_count.desc',
|
||||
'vote_count.asc',
|
||||
'first_air_date.desc',
|
||||
'first_air_date.asc',
|
||||
] as const;
|
||||
|
||||
export type SortOptions = (typeof SortOptionsIterable)[number];
|
||||
export type SortOptions =
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
| 'release_date.asc'
|
||||
| 'release_date.desc'
|
||||
| 'revenue.asc'
|
||||
| 'revenue.desc'
|
||||
| 'primary_release_date.asc'
|
||||
| 'primary_release_date.desc'
|
||||
| 'original_title.asc'
|
||||
| 'original_title.desc'
|
||||
| 'vote_average.asc'
|
||||
| 'vote_average.desc'
|
||||
| 'vote_count.asc'
|
||||
| 'vote_count.desc'
|
||||
| 'first_air_date.asc'
|
||||
| 'first_air_date.desc';
|
||||
|
||||
interface DiscoverMovieOptions {
|
||||
page?: number;
|
||||
|
||||
@@ -3,7 +3,6 @@ export enum MediaRequestStatus {
|
||||
APPROVED,
|
||||
DECLINED,
|
||||
FAILED,
|
||||
COMPLETED,
|
||||
}
|
||||
|
||||
export enum MediaType {
|
||||
@@ -18,5 +17,4 @@ export enum MediaStatus {
|
||||
PARTIALLY_AVAILABLE,
|
||||
AVAILABLE,
|
||||
BLACKLISTED,
|
||||
DELETED,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { MediaStatus, type MediaType } from '@server/constants/media';
|
||||
import dataSource from '@server/datasource';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -36,7 +35,7 @@ export class Blacklist implements BlacklistItem {
|
||||
@ManyToOne(() => User, (user) => user.id, {
|
||||
eager: true,
|
||||
})
|
||||
user?: User;
|
||||
user: User;
|
||||
|
||||
@OneToOne(() => Media, (media) => media.blacklist, {
|
||||
onDelete: 'CASCADE',
|
||||
@@ -44,9 +43,6 @@ export class Blacklist implements BlacklistItem {
|
||||
@JoinColumn()
|
||||
public media: Media;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public blacklistedTags?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@@ -54,32 +50,27 @@ export class Blacklist implements BlacklistItem {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
public static async addToBlacklist(
|
||||
{
|
||||
blacklistRequest,
|
||||
}: {
|
||||
blacklistRequest: {
|
||||
mediaType: MediaType;
|
||||
title?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId: ZodNumber['_output'];
|
||||
blacklistedTags?: string;
|
||||
};
|
||||
},
|
||||
entityManager?: EntityManager
|
||||
): Promise<void> {
|
||||
const em = entityManager ?? dataSource;
|
||||
public static async addToBlacklist({
|
||||
blacklistRequest,
|
||||
}: {
|
||||
blacklistRequest: {
|
||||
mediaType: MediaType;
|
||||
title?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId: ZodNumber['_output'];
|
||||
};
|
||||
}): Promise<void> {
|
||||
const blacklist = new this({
|
||||
...blacklistRequest,
|
||||
});
|
||||
|
||||
const mediaRepository = em.getRepository(Media);
|
||||
const mediaRepository = getRepository(Media);
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: blacklistRequest.tmdbId,
|
||||
},
|
||||
});
|
||||
|
||||
const blacklistRepository = em.getRepository(this);
|
||||
const blacklistRepository = getRepository(this);
|
||||
|
||||
await blacklistRepository.save(blacklist);
|
||||
|
||||
|
||||
@@ -108,9 +108,7 @@ class Media {
|
||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||
public status4k: MediaStatus;
|
||||
|
||||
@OneToMany(() => MediaRequest, (request) => request.media, {
|
||||
cascade: ['insert', 'remove'],
|
||||
})
|
||||
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
||||
public requests: MediaRequest[];
|
||||
|
||||
@OneToMany(() => Watchlist, (watchlist) => watchlist.media)
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import type {
|
||||
AddSeriesOptions,
|
||||
SonarrSeries,
|
||||
} from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
@@ -13,9 +20,10 @@ import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { truncate } from 'lodash';
|
||||
import { isEqual, truncate } from 'lodash';
|
||||
import {
|
||||
AfterInsert,
|
||||
AfterRemove,
|
||||
AfterUpdate,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -173,8 +181,7 @@ export class MediaRequest {
|
||||
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||
if (
|
||||
requestBody.mediaType === MediaType.MOVIE &&
|
||||
existing[0].status !== MediaRequestStatus.DECLINED &&
|
||||
existing[0].status !== MediaRequestStatus.COMPLETED
|
||||
existing[0].status !== MediaRequestStatus.DECLINED
|
||||
) {
|
||||
logger.warn('Duplicate request for media blocked', {
|
||||
tmdbId: tmdbMedia.id,
|
||||
@@ -381,9 +388,7 @@ export class MediaRequest {
|
||||
>;
|
||||
let requestedSeasons =
|
||||
requestBody.seasons === 'all'
|
||||
? tmdbMediaShow.seasons
|
||||
.filter((season) => season.season_number !== 0)
|
||||
.map((season) => season.season_number)
|
||||
? tmdbMediaShow.seasons.map((season) => season.season_number)
|
||||
: (requestBody.seasons as number[]);
|
||||
if (!settings.main.enableSpecialEpisodes) {
|
||||
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
|
||||
@@ -399,8 +404,7 @@ export class MediaRequest {
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === requestBody.is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
)
|
||||
.reduce((seasons, request) => {
|
||||
const combinedSeasons = request.seasons.map(
|
||||
@@ -419,9 +423,7 @@ export class MediaRequest {
|
||||
.filter(
|
||||
(season) =>
|
||||
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN &&
|
||||
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.DELETED
|
||||
MediaStatus.UNKNOWN
|
||||
)
|
||||
.map((season) => season.seasonNumber),
|
||||
];
|
||||
@@ -606,6 +608,12 @@ export class MediaRequest {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
public async sendMedia(): Promise<void> {
|
||||
await Promise.all([this.sendToRadarr(), this.sendToSonarr()]);
|
||||
}
|
||||
|
||||
@AfterInsert()
|
||||
public async notifyNewRequest(): Promise<void> {
|
||||
if (this.status === MediaRequestStatus.PENDING) {
|
||||
@@ -622,14 +630,10 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
MediaRequest.sendNotification(this, media, Notification.MEDIA_PENDING);
|
||||
this.sendNotification(media, Notification.MEDIA_PENDING);
|
||||
|
||||
if (this.isAutoRequest) {
|
||||
MediaRequest.sendNotification(
|
||||
this,
|
||||
media,
|
||||
Notification.MEDIA_AUTO_REQUESTED
|
||||
);
|
||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -667,8 +671,7 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
MediaRequest.sendNotification(
|
||||
this,
|
||||
this.sendNotification(
|
||||
media,
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? autoApproved
|
||||
@@ -682,11 +685,7 @@ export class MediaRequest {
|
||||
autoApproved &&
|
||||
this.isAutoRequest
|
||||
) {
|
||||
MediaRequest.sendNotification(
|
||||
this,
|
||||
media,
|
||||
Notification.MEDIA_AUTO_REQUESTED
|
||||
);
|
||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -698,56 +697,699 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
|
||||
static async sendNotification(
|
||||
entity: MediaRequest,
|
||||
media: Media,
|
||||
type: Notification
|
||||
) {
|
||||
@AfterUpdate()
|
||||
@AfterInsert()
|
||||
public async updateParentStatus(): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
if (!media) {
|
||||
logger.error('Media data not found', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
// Do not update the status if the item is already partially available or available
|
||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
||||
media[this.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.PARTIALLY_AVAILABLE &&
|
||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
|
||||
) {
|
||||
const statusField = this.is4k ? 'status4k' : 'status';
|
||||
|
||||
await mediaRepository.update(
|
||||
{ id: this.media.id },
|
||||
{ [statusField]: MediaStatus.PROCESSING }
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
media.mediaType === MediaType.MOVIE &&
|
||||
this.status === MediaRequestStatus.DECLINED
|
||||
) {
|
||||
const statusField = this.is4k ? 'status4k' : 'status';
|
||||
await mediaRepository.update(
|
||||
{ id: this.media.id },
|
||||
{ [statusField]: MediaStatus.UNKNOWN }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the media type is TV, and we are declining a request,
|
||||
* we must check if its the only pending request and that
|
||||
* there the current media status is just pending (meaning no
|
||||
* other requests have yet to be approved)
|
||||
*/
|
||||
if (
|
||||
media.mediaType === MediaType.TV &&
|
||||
this.status === MediaRequestStatus.DECLINED &&
|
||||
media.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
).length === 0 &&
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
||||
) {
|
||||
const statusField = this.is4k ? 'status4k' : 'status';
|
||||
mediaRepository.update(
|
||||
{ id: this.media.id },
|
||||
{ [statusField]: MediaStatus.UNKNOWN }
|
||||
);
|
||||
}
|
||||
|
||||
// Approve child seasons if parent is approved
|
||||
if (
|
||||
media.mediaType === MediaType.TV &&
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
) {
|
||||
this.seasons.forEach((season) => {
|
||||
season.status = MediaRequestStatus.APPROVED;
|
||||
seasonRequestRepository.save(season);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@AfterRemove()
|
||||
public async handleRemoveParentUpdate(): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const fullMedia = await mediaRepository.findOneOrFail({
|
||||
where: { id: this.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (
|
||||
!fullMedia.requests.some((request) => !request.is4k) &&
|
||||
fullMedia.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
fullMedia.status = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
if (
|
||||
!fullMedia.requests.some((request) => request.is4k) &&
|
||||
fullMedia.status4k !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
fullMedia.status4k = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
mediaRepository.save(fullMedia);
|
||||
}
|
||||
|
||||
public async sendToRadarr(): Promise<void> {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
this.type === MediaType.MOVIE
|
||||
) {
|
||||
try {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const settings = getSettings();
|
||||
if (settings.radarr.length === 0 && !settings.radarr[0]) {
|
||||
logger.info(
|
||||
'No Radarr server configured, skipping request processing',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && radarr.is4k === this.is4k
|
||||
);
|
||||
|
||||
if (
|
||||
this.serverId !== null &&
|
||||
this.serverId >= 0 &&
|
||||
radarrSettings?.id !== this.serverId
|
||||
) {
|
||||
radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.id === this.serverId
|
||||
);
|
||||
logger.info(
|
||||
`Request has an override server: ${radarrSettings?.name}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!radarrSettings) {
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}Radarr server configured. Did you set any of your ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}Radarr servers as default?`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let rootFolder = radarrSettings.activeDirectory;
|
||||
let qualityProfile = radarrSettings.activeProfileId;
|
||||
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
this.rootFolder !== '' &&
|
||||
this.rootFolder !== radarrSettings.activeDirectory
|
||||
) {
|
||||
rootFolder = this.rootFolder;
|
||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.profileId &&
|
||||
this.profileId !== radarrSettings.activeProfileId
|
||||
) {
|
||||
qualityProfile = this.profileId;
|
||||
logger.info(
|
||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
|
||||
tags = this.tags;
|
||||
logger.info(`Request has override tags`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
tagIds: tags,
|
||||
});
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
|
||||
});
|
||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
logger.error('Media data not found', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (radarrSettings.tagRequests) {
|
||||
let userTag = (await radarr.getTags()).find((v) =>
|
||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
logger.info(`Requester has no active tag. Creating new`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
userId: this.requestedBy.id,
|
||||
newTag:
|
||||
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||
});
|
||||
userTag = await radarr.createTag({
|
||||
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
if (!tags?.find((v) => v === userTag?.id)) {
|
||||
tags?.push(userTag.id);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Requester has no tag and failed to add one`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
userId: this.requestedBy.id,
|
||||
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
logger.warn('Media already exists, marking request as APPROVED', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
await requestRepository.update(this.id, {
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrMovieOptions: RadarrMovieOptions = {
|
||||
profileId: qualityProfile,
|
||||
qualityProfileId: qualityProfile,
|
||||
rootFolderPath: rootFolder,
|
||||
minimumAvailability: radarrSettings.minimumAvailability,
|
||||
title: movie.title,
|
||||
tmdbId: movie.id,
|
||||
year: Number(movie.release_date.slice(0, 4)),
|
||||
monitored: true,
|
||||
tags,
|
||||
searchNow: !radarrSettings.preventSearch,
|
||||
};
|
||||
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
radarr
|
||||
.addMovie(radarrMovieOptions)
|
||||
.then(async (radarrMovie) => {
|
||||
// We grab media again here to make sure we have the latest version of it
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
const updateFields = {
|
||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
||||
radarrMovie.id,
|
||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
||||
radarrMovie.titleSlug,
|
||||
[this.is4k ? 'serviceId4k' : 'serviceId']: radarrSettings?.id,
|
||||
};
|
||||
|
||||
await mediaRepository.update({ id: this.media.id }, updateFields);
|
||||
})
|
||||
.catch(async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
await requestRepository.update(this.id, {
|
||||
status: MediaRequestStatus.FAILED,
|
||||
});
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
radarrMovieOptions,
|
||||
}
|
||||
);
|
||||
|
||||
this.sendNotification(media, Notification.MEDIA_FAILED);
|
||||
})
|
||||
.finally(() => {
|
||||
radarr.clearCache({
|
||||
tmdbId: movie.id,
|
||||
externalId: this.is4k
|
||||
? media.externalServiceId4k
|
||||
: media.externalServiceId,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Radarr', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending request to Radarr', {
|
||||
label: 'Media Request',
|
||||
errorMessage: e.message,
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async sendToSonarr(): Promise<void> {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED &&
|
||||
this.type === MediaType.TV
|
||||
) {
|
||||
try {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const settings = getSettings();
|
||||
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
|
||||
logger.warn(
|
||||
'No Sonarr server configured, skipping request processing',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k
|
||||
);
|
||||
|
||||
if (
|
||||
this.serverId !== null &&
|
||||
this.serverId >= 0 &&
|
||||
sonarrSettings?.id !== this.serverId
|
||||
) {
|
||||
sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.id === this.serverId
|
||||
);
|
||||
logger.info(
|
||||
`Request has an override server: ${sonarrSettings?.name}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!sonarrSettings) {
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}Sonarr server configured. Did you set any of your ${
|
||||
this.is4k ? '4K ' : ''
|
||||
}Sonarr servers as default?`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
logger.warn('Media already exists, marking request as APPROVED', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
await requestRepository.update(this.id, {
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||
});
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||
|
||||
if (!tvdbId) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
await mediaRepository.remove(media);
|
||||
await requestRepository.remove(this);
|
||||
throw new Error('TVDB ID not found');
|
||||
}
|
||||
|
||||
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
||||
|
||||
// Change series type to anime if the anime keyword is present on tmdb
|
||||
if (
|
||||
series.keywords.results.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
) {
|
||||
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
||||
}
|
||||
|
||||
let rootFolder =
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
||||
? sonarrSettings.activeAnimeDirectory
|
||||
: sonarrSettings.activeDirectory;
|
||||
let qualityProfile =
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||
? sonarrSettings.activeAnimeProfileId
|
||||
: sonarrSettings.activeProfileId;
|
||||
let languageProfile =
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
|
||||
? sonarrSettings.activeAnimeLanguageProfileId
|
||||
: sonarrSettings.activeLanguageProfileId;
|
||||
let tags =
|
||||
seriesType === 'anime'
|
||||
? sonarrSettings.animeTags
|
||||
? [...sonarrSettings.animeTags]
|
||||
: []
|
||||
: sonarrSettings.tags
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
this.rootFolder !== '' &&
|
||||
this.rootFolder !== rootFolder
|
||||
) {
|
||||
rootFolder = this.rootFolder;
|
||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.profileId && this.profileId !== qualityProfile) {
|
||||
qualityProfile = this.profileId;
|
||||
logger.info(
|
||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.languageProfileId &&
|
||||
this.languageProfileId !== languageProfile
|
||||
) {
|
||||
languageProfile = this.languageProfileId;
|
||||
logger.info(
|
||||
`Request has an override language profile ID: ${languageProfile}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (this.tags && !isEqual(this.tags, tags)) {
|
||||
tags = this.tags;
|
||||
logger.info(`Request has override tags`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
tagIds: tags,
|
||||
});
|
||||
}
|
||||
|
||||
if (sonarrSettings.tagRequests) {
|
||||
let userTag = (await sonarr.getTags()).find((v) =>
|
||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
logger.info(`Requester has no active tag. Creating new`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
userId: this.requestedBy.id,
|
||||
newTag:
|
||||
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||
});
|
||||
userTag = await sonarr.createTag({
|
||||
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
if (!tags?.find((v) => v === userTag?.id)) {
|
||||
tags?.push(userTag.id);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Requester has no tag and failed to add one`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
userId: this.requestedBy.id,
|
||||
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||
profileId: qualityProfile,
|
||||
languageProfileId: languageProfile,
|
||||
rootFolderPath: rootFolder,
|
||||
title: series.name,
|
||||
tvdbid: tvdbId,
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||
seriesType,
|
||||
tags,
|
||||
monitored: true,
|
||||
searchNow: !sonarrSettings.preventSearch,
|
||||
};
|
||||
|
||||
// Run this asynchronously so we don't wait for it on the UI side
|
||||
sonarr
|
||||
.addSeries(sonarrSeriesOptions)
|
||||
.then(async (sonarrSeries) => {
|
||||
// We grab media again here to make sure we have the latest version of it
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: this.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
const updateFields = {
|
||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
||||
sonarrSeries.id,
|
||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
||||
sonarrSeries.titleSlug,
|
||||
[this.is4k ? 'serviceId4k' : 'serviceId']: sonarrSettings?.id,
|
||||
};
|
||||
|
||||
await mediaRepository.update({ id: this.media.id }, updateFields);
|
||||
})
|
||||
.catch(async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
await requestRepository.update(
|
||||
{ id: this.id },
|
||||
{ status: MediaRequestStatus.FAILED }
|
||||
);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
sonarrSeriesOptions,
|
||||
}
|
||||
);
|
||||
|
||||
this.sendNotification(media, Notification.MEDIA_FAILED);
|
||||
})
|
||||
.finally(() => {
|
||||
sonarr.clearCache({
|
||||
tvdbId,
|
||||
externalId: this.is4k
|
||||
? media.externalServiceId4k
|
||||
: media.externalServiceId,
|
||||
title: series.name,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Sonarr', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending request to Sonarr', {
|
||||
label: 'Media Request',
|
||||
errorMessage: e.message,
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async sendNotification(media: Media, type: Notification) {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||
let event: string | undefined;
|
||||
let notifyAdmin = true;
|
||||
let notifySystem = true;
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_APPROVED:
|
||||
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Approved`;
|
||||
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Approved`;
|
||||
notifyAdmin = false;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Declined`;
|
||||
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Declined`;
|
||||
notifyAdmin = false;
|
||||
break;
|
||||
case Notification.MEDIA_PENDING:
|
||||
event = `New ${entity.is4k ? '4K ' : ''}${mediaType} Request`;
|
||||
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_REQUESTED:
|
||||
event = `${
|
||||
entity.is4k ? '4K ' : ''
|
||||
this.is4k ? '4K ' : ''
|
||||
}${mediaType} Request Automatically Submitted`;
|
||||
notifyAdmin = false;
|
||||
notifySystem = false;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
event = `${
|
||||
entity.is4k ? '4K ' : ''
|
||||
this.is4k ? '4K ' : ''
|
||||
}${mediaType} Request Automatically Approved`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Failed`;
|
||||
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (entity.type === MediaType.MOVIE) {
|
||||
if (this.type === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
||||
notificationManager.sendNotification(type, {
|
||||
media,
|
||||
request: entity,
|
||||
request: this,
|
||||
notifyAdmin,
|
||||
notifySystem,
|
||||
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||
event,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
@@ -759,14 +1401,14 @@ export class MediaRequest {
|
||||
}),
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
});
|
||||
} else if (entity.type === MediaType.TV) {
|
||||
} else if (this.type === MediaType.TV) {
|
||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
notificationManager.sendNotification(type, {
|
||||
media,
|
||||
request: entity,
|
||||
request: this,
|
||||
notifyAdmin,
|
||||
notifySystem,
|
||||
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
||||
event,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
@@ -780,7 +1422,7 @@ export class MediaRequest {
|
||||
extra: [
|
||||
{
|
||||
name: 'Requested Seasons',
|
||||
value: entity.seasons
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
@@ -791,8 +1433,8 @@ export class MediaRequest {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import {
|
||||
AfterRemove,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@@ -34,6 +36,18 @@ class SeasonRequest {
|
||||
constructor(init?: Partial<SeasonRequest>) {
|
||||
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;
|
||||
|
||||
@@ -6,8 +6,7 @@ export interface BlacklistItem {
|
||||
mediaType: 'movie' | 'tv';
|
||||
title?: string;
|
||||
createdAt?: Date;
|
||||
user?: User;
|
||||
blacklistedTags?: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface BlacklistResultsResponse extends PaginatedResponse {
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { SortOptions } from '@server/api/themoviedb';
|
||||
import { SortOptionsIterable } from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbSearchMovieResponse,
|
||||
TmdbSearchTvResponse,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import dataSource from '@server/datasource';
|
||||
import { Blacklist } from '@server/entity/Blacklist';
|
||||
import Media from '@server/entity/Media';
|
||||
import type {
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '@server/lib/scanners/baseScanner';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { createTmdbWithRegionLanguage } from '@server/routes/discover';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
|
||||
const TMDB_API_DELAY_MS = 250;
|
||||
class AbortTransaction extends Error {}
|
||||
|
||||
class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
private running = false;
|
||||
private progress = 0;
|
||||
private total = 0;
|
||||
|
||||
public async run() {
|
||||
this.running = true;
|
||||
|
||||
try {
|
||||
await dataSource.transaction(async (em) => {
|
||||
await this.cleanBlacklist(em);
|
||||
await this.createBlacklistEntries(em);
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AbortTransaction) {
|
||||
logger.info('Aborting job: Process Blacklisted Tags', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public status(): StatusBase {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.total,
|
||||
};
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
this.running = false;
|
||||
this.progress = 0;
|
||||
this.total = 0;
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.cancel();
|
||||
}
|
||||
|
||||
private async createBlacklistEntries(em: EntityManager) {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
const settings = getSettings();
|
||||
const blacklistedTags = settings.main.blacklistedTags;
|
||||
const blacklistedTagsArr = blacklistedTags.split(',');
|
||||
|
||||
const pageLimit = settings.main.blacklistedTagsLimit;
|
||||
|
||||
if (blacklistedTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The maximum number of queries we're expected to execute
|
||||
this.total =
|
||||
2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
|
||||
|
||||
for (const type of [MediaType.MOVIE, MediaType.TV]) {
|
||||
const getDiscover =
|
||||
type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv;
|
||||
|
||||
// Iterate for each tag
|
||||
for (const tag of blacklistedTagsArr) {
|
||||
let queryMax = pageLimit * SortOptionsIterable.length;
|
||||
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
|
||||
|
||||
for (let query = 0; query < queryMax; query++) {
|
||||
const page: number = fixedSortMode
|
||||
? query + 1
|
||||
: (query % pageLimit) + 1;
|
||||
const sortBy: SortOptions | undefined = fixedSortMode
|
||||
? undefined
|
||||
: SortOptionsIterable[query % SortOptionsIterable.length];
|
||||
|
||||
if (!this.running) {
|
||||
throw new AbortTransaction();
|
||||
}
|
||||
|
||||
const response = await getDiscover({
|
||||
page,
|
||||
sortBy,
|
||||
keywords: tag,
|
||||
});
|
||||
await this.processResults(response, tag, type, em);
|
||||
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
|
||||
|
||||
this.progress++;
|
||||
if (page === 1 && response.total_pages <= queryMax) {
|
||||
// We will finish the tag with less queries than expected, move progress accordingly
|
||||
this.progress += queryMax - response.total_pages;
|
||||
fixedSortMode = true;
|
||||
queryMax = response.total_pages;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processResults(
|
||||
response: TmdbSearchMovieResponse | TmdbSearchTvResponse,
|
||||
keywordId: string,
|
||||
mediaType: MediaType,
|
||||
em: EntityManager
|
||||
) {
|
||||
const blacklistRepository = em.getRepository(Blacklist);
|
||||
|
||||
for (const entry of response.results) {
|
||||
const blacklistEntry = await blacklistRepository.findOne({
|
||||
where: { tmdbId: entry.id },
|
||||
});
|
||||
|
||||
if (blacklistEntry) {
|
||||
// Don't mark manual blacklists with tags
|
||||
// If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist
|
||||
if (
|
||||
blacklistEntry.blacklistedTags &&
|
||||
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
|
||||
) {
|
||||
blacklistEntry.blacklistedTags = `${blacklistEntry.blacklistedTags}${keywordId},`;
|
||||
await blacklistRepository.save(blacklistEntry);
|
||||
}
|
||||
} else {
|
||||
// Media wasn't previously blacklisted, add it to the blacklist
|
||||
await Blacklist.addToBlacklist(
|
||||
{
|
||||
blacklistRequest: {
|
||||
mediaType,
|
||||
title: 'title' in entry ? entry.title : entry.name,
|
||||
tmdbId: entry.id,
|
||||
blacklistedTags: `,${keywordId},`,
|
||||
},
|
||||
},
|
||||
em
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanBlacklist(em: EntityManager) {
|
||||
// Remove blacklist and media entries blacklisted by tags
|
||||
const mediaRepository = em.getRepository(Media);
|
||||
const mediaToRemove = await mediaRepository
|
||||
.createQueryBuilder('media')
|
||||
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
|
||||
.where(`blist.blacklistedTags IS NOT NULL`)
|
||||
.getMany();
|
||||
|
||||
// Batch removes so the query doesn't get too large
|
||||
for (let i = 0; i < mediaToRemove.length; i += 500) {
|
||||
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
|
||||
|
||||
export default blacklistedTagsProcessor;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
|
||||
import availabilitySync from '@server/lib/availabilitySync';
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
@@ -22,7 +21,7 @@ interface ScheduledJob {
|
||||
job: schedule.Job;
|
||||
name: string;
|
||||
type: 'process' | 'command';
|
||||
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
|
||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||
cronSchedule: string;
|
||||
running?: () => boolean;
|
||||
cancelFn?: () => void;
|
||||
@@ -238,21 +237,5 @@ export const startJobs = (): void => {
|
||||
}),
|
||||
});
|
||||
|
||||
scheduledJobs.push({
|
||||
id: 'process-blacklisted-tags',
|
||||
name: 'Process Blacklisted Tags',
|
||||
type: 'process',
|
||||
interval: 'days',
|
||||
cronSchedule: jobs['process-blacklisted-tags'].schedule,
|
||||
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Process Blacklisted Tags', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
blacklistedTagsProcessor.run();
|
||||
}),
|
||||
running: () => blacklistedTagsProcessor.status().running,
|
||||
cancelFn: () => blacklistedTagsProcessor.cancel(),
|
||||
});
|
||||
|
||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MediaRequest from '@server/entity/MediaRequest';
|
||||
import type 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';
|
||||
@@ -41,7 +42,7 @@ class AvailabilitySync {
|
||||
|
||||
try {
|
||||
logger.info(`Starting availability sync...`, {
|
||||
label: 'Availability Sync',
|
||||
label: 'AvailabilitySync',
|
||||
});
|
||||
const pageSize = 50;
|
||||
|
||||
@@ -455,11 +456,11 @@ class AvailabilitySync {
|
||||
} catch (ex) {
|
||||
logger.error('Failed to complete availability sync.', {
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
label: 'AvailabilitySync',
|
||||
});
|
||||
} finally {
|
||||
logger.info(`Availability sync complete.`, {
|
||||
label: 'Availability Sync',
|
||||
label: 'AvailabilitySync',
|
||||
});
|
||||
this.running = false;
|
||||
}
|
||||
@@ -495,66 +496,98 @@ class AvailabilitySync {
|
||||
} while (mediaPage.length > 0);
|
||||
}
|
||||
|
||||
private findMediaStatus(
|
||||
requests: MediaRequest[],
|
||||
is4k: boolean
|
||||
): MediaStatus {
|
||||
const filteredRequests = requests.filter(
|
||||
(request) => request.is4k === is4k
|
||||
);
|
||||
|
||||
let mediaStatus: MediaStatus;
|
||||
|
||||
if (
|
||||
filteredRequests.some(
|
||||
(request) => request.status === MediaRequestStatus.APPROVED
|
||||
)
|
||||
) {
|
||||
mediaStatus = MediaStatus.PROCESSING;
|
||||
} else if (
|
||||
filteredRequests.some(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
)
|
||||
) {
|
||||
mediaStatus = MediaStatus.PENDING;
|
||||
} else {
|
||||
mediaStatus = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
return mediaStatus;
|
||||
}
|
||||
|
||||
private async mediaUpdater(
|
||||
media: Media,
|
||||
is4k: boolean,
|
||||
mediaServerType: MediaServerType
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
try {
|
||||
// If media type is tv, check if a season is processing
|
||||
// to see if we need to keep the external metadata
|
||||
let isMediaProcessing = false;
|
||||
// Find all related requests only if
|
||||
// the related media has an available status
|
||||
const requests = await requestRepository
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.where('(media.id = :id)', {
|
||||
id: media.id,
|
||||
})
|
||||
.andWhere(
|
||||
`(request.is4k = :is4k AND media.${
|
||||
is4k ? 'status4k' : 'status'
|
||||
} IN (:...mediaStatus))`,
|
||||
{
|
||||
mediaStatus: [
|
||||
MediaStatus.AVAILABLE,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
],
|
||||
is4k: is4k,
|
||||
}
|
||||
)
|
||||
.getMany();
|
||||
|
||||
// Check if a season is processing or pending to
|
||||
// make sure we set the media to the correct status
|
||||
let mediaStatus = MediaStatus.UNKNOWN;
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
const request = await requestRepository
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.where('(media.id = :id)', {
|
||||
id: media.id,
|
||||
})
|
||||
.andWhere(
|
||||
'(request.is4k = :is4k AND request.status = :requestStatus)',
|
||||
{
|
||||
requestStatus: MediaRequestStatus.APPROVED,
|
||||
is4k: is4k,
|
||||
}
|
||||
)
|
||||
.getOne();
|
||||
|
||||
if (request) {
|
||||
isMediaProcessing = true;
|
||||
}
|
||||
mediaStatus = this.findMediaStatus(requests, is4k);
|
||||
}
|
||||
|
||||
// Set the non-4K or 4K media to deleted
|
||||
// and change related columns to null if media
|
||||
// is not processing
|
||||
media[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
|
||||
media[is4k ? 'serviceId4k' : 'serviceId'] = isMediaProcessing
|
||||
? media[is4k ? 'serviceId4k' : 'serviceId']
|
||||
: null;
|
||||
media[is4k ? 'status4k' : 'status'] = mediaStatus;
|
||||
media[is4k ? 'serviceId4k' : 'serviceId'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'serviceId4k' : 'serviceId']
|
||||
: null;
|
||||
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
isMediaProcessing
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
||||
: null;
|
||||
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
isMediaProcessing
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
||||
: null;
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
media[is4k ? 'ratingKey4k' : 'ratingKey'] = isMediaProcessing
|
||||
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||
: null;
|
||||
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||
: null;
|
||||
} else if (
|
||||
mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY
|
||||
) {
|
||||
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||
isMediaProcessing
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
||||
: null;
|
||||
}
|
||||
@@ -569,11 +602,18 @@ class AvailabilitySync {
|
||||
: mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
} instance. Status will be changed to deleted.`,
|
||||
} instance. Status will be changed to unknown.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
|
||||
await mediaRepository.save(media);
|
||||
await mediaRepository.save({ media, ...media });
|
||||
|
||||
// Only delete media request if type is movie.
|
||||
// Type tv request deletion is handled
|
||||
// in the season request entity
|
||||
if (requests.length > 0 && media.mediaType === 'movie') {
|
||||
await requestRepository.remove(requests);
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.debug(
|
||||
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||
@@ -581,7 +621,7 @@ class AvailabilitySync {
|
||||
} [TMDB ID ${media.tmdbId}].`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -594,44 +634,61 @@ class AvailabilitySync {
|
||||
mediaServerType: MediaServerType
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
|
||||
// Filter out only the values that are false
|
||||
// (media that should be deleted)
|
||||
const seasonsPendingRemoval = new Map(
|
||||
// Disabled linter as only the value is needed from the filter
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[...seasons].filter(([_, exists]) => !exists)
|
||||
);
|
||||
// Retrieve the season keys to pass into our log
|
||||
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
||||
|
||||
// let isSeasonRemoved = false;
|
||||
|
||||
try {
|
||||
// Need to check and see if there are any related season
|
||||
// requests. If they are, we will need to delete them.
|
||||
const seasonRequests = await seasonRequestRepository
|
||||
.createQueryBuilder('seasonRequest')
|
||||
.leftJoinAndSelect('seasonRequest.request', 'request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.where('(media.id = :id)', { id: media.id })
|
||||
.andWhere(
|
||||
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
|
||||
{
|
||||
seasonNumbers: seasonKeys,
|
||||
is4k: is4k,
|
||||
}
|
||||
)
|
||||
.getMany();
|
||||
|
||||
for (const mediaSeason of media.seasons) {
|
||||
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
||||
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
|
||||
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
if (media.status === MediaStatus.AVAILABLE && !is4k) {
|
||||
if (media.status === MediaStatus.AVAILABLE) {
|
||||
media.status = MediaStatus.PARTIALLY_AVAILABLE;
|
||||
logger.info(
|
||||
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||
{ label: 'Availability Sync' }
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
}
|
||||
|
||||
if (media.status4k === MediaStatus.AVAILABLE && is4k) {
|
||||
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
|
||||
logger.info(
|
||||
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||
{ label: 'Availability Sync' }
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
}
|
||||
|
||||
media.lastSeasonChange = new Date();
|
||||
await mediaRepository.save(media);
|
||||
await mediaRepository.save({ media, ...media });
|
||||
|
||||
if (seasonRequests.length > 0) {
|
||||
await seasonRequestRepository.remove(seasonRequests);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
||||
@@ -644,7 +701,7 @@ class AvailabilitySync {
|
||||
: mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'jellyfin'
|
||||
: 'emby'
|
||||
} instance. Status will be changed to deleted.`,
|
||||
} instance. Status will be changed to unknown.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
} catch (ex) {
|
||||
@@ -654,7 +711,7 @@ class AvailabilitySync {
|
||||
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -668,9 +725,7 @@ class AvailabilitySync {
|
||||
|
||||
// Check for availability in all of the available radarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.radarrServers.filter(
|
||||
(server) => server.is4k === is4k
|
||||
)) {
|
||||
for (const server of this.radarrServers) {
|
||||
const radarrAPI = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||
@@ -679,13 +734,13 @@ class AvailabilitySync {
|
||||
try {
|
||||
let radarr: RadarrMovie | undefined;
|
||||
|
||||
if (media.externalServiceId && !is4k) {
|
||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||
radarr = await radarrAPI.getMovie({
|
||||
id: media.externalServiceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (media.externalServiceId4k && is4k) {
|
||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||
radarr = await radarrAPI.getMovie({
|
||||
id: media.externalServiceId4k,
|
||||
});
|
||||
@@ -707,7 +762,7 @@ class AvailabilitySync {
|
||||
}] from Radarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -726,9 +781,7 @@ class AvailabilitySync {
|
||||
|
||||
// Check for availability in all of the available sonarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.sonarrServers.filter((server) => {
|
||||
return server.is4k === is4k;
|
||||
})) {
|
||||
for (const server of this.sonarrServers) {
|
||||
const sonarrAPI = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||
@@ -737,13 +790,13 @@ class AvailabilitySync {
|
||||
try {
|
||||
let sonarr: SonarrSeries | undefined;
|
||||
|
||||
if (media.externalServiceId && !is4k) {
|
||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
||||
sonarr.seasons;
|
||||
}
|
||||
|
||||
if (media.externalServiceId4k && is4k) {
|
||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
||||
sonarr.seasons;
|
||||
@@ -762,7 +815,7 @@ class AvailabilitySync {
|
||||
}] from Sonarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -808,9 +861,7 @@ class AvailabilitySync {
|
||||
// Check each sonarr instance to see if the media still exists
|
||||
// If found, we will assume the media exists and prevent removal
|
||||
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
|
||||
for (const server of this.sonarrServers.filter(
|
||||
(server) => server.is4k === is4k
|
||||
)) {
|
||||
for (const server of this.sonarrServers) {
|
||||
let sonarrSeasons: SonarrSeason[] | undefined;
|
||||
|
||||
if (media.externalServiceId && !is4k) {
|
||||
@@ -885,7 +936,7 @@ class AvailabilitySync {
|
||||
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'Availability Sync',
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1074,5 +1125,4 @@ class AvailabilitySync {
|
||||
}
|
||||
|
||||
const availabilitySync = new AvailabilitySync();
|
||||
|
||||
export default availabilitySync;
|
||||
|
||||
@@ -3,7 +3,6 @@ import axios from 'axios';
|
||||
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
|
||||
import { createHash } from 'crypto';
|
||||
import { promises } from 'fs';
|
||||
import mime from 'mime/lite';
|
||||
import path, { join } from 'path';
|
||||
|
||||
type ImageResponse = {
|
||||
@@ -270,10 +269,7 @@ class ImageProxy {
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(response.data, 'binary');
|
||||
|
||||
const contentType = response.headers['content-type'] || '';
|
||||
const extension = mime.getExtension(contentType) || '';
|
||||
|
||||
const extension = path.split('.').pop() ?? '';
|
||||
let maxAge = Number(
|
||||
(response.headers['cache-control'] ?? '0').split('=')[1]
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ class PushoverAgent
|
||||
logger.error('Error getting image payload', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: e?.response?.data,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ class WebPushAgent
|
||||
const allSubs = await userPushSubRepository
|
||||
.createQueryBuilder('pushSub')
|
||||
.leftJoinAndSelect('pushSub.user', 'user')
|
||||
.where('pushSub.userId IN (:...users)', {
|
||||
.where('pushSub.userId IN (:users)', {
|
||||
users: manageUsers.map((user) => user.id),
|
||||
})
|
||||
.getMany();
|
||||
|
||||
@@ -281,9 +281,7 @@ class BaseScanner<T> {
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status !== MediaStatus.DELETED
|
||||
: !season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status;
|
||||
|
||||
@@ -296,9 +294,7 @@ class BaseScanner<T> {
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride &&
|
||||
season.processing &&
|
||||
existingSeason.status4k !== MediaStatus.DELETED
|
||||
: season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
@@ -328,25 +324,19 @@ class BaseScanner<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// We want to skip specials when checking if a show is available
|
||||
const isAllStandardSeasons =
|
||||
seasons.length &&
|
||||
seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.every(
|
||||
(season) =>
|
||||
season.episodes === season.totalEpisodes && season.episodes > 0
|
||||
);
|
||||
seasons.every(
|
||||
(season) =>
|
||||
season.episodes === season.totalEpisodes && season.episodes > 0
|
||||
);
|
||||
|
||||
const isAll4kSeasons =
|
||||
seasons.length &&
|
||||
seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.every(
|
||||
(season) =>
|
||||
season.episodes4k === season.totalEpisodes &&
|
||||
season.episodes4k > 0
|
||||
);
|
||||
seasons.every(
|
||||
(season) =>
|
||||
season.episodes4k === season.totalEpisodes && season.episodes4k > 0
|
||||
);
|
||||
|
||||
if (media) {
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
@@ -408,23 +398,16 @@ class BaseScanner<T> {
|
||||
}
|
||||
|
||||
// If the show is already available, and there are no new seasons, dont adjust
|
||||
// the status. Skip specials when performing availability check
|
||||
// the status
|
||||
const shouldStayAvailable =
|
||||
media.status === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter(
|
||||
(season) =>
|
||||
season.status !== MediaStatus.UNKNOWN &&
|
||||
season.status !== MediaStatus.DELETED &&
|
||||
season.seasonNumber !== 0
|
||||
).length === 0;
|
||||
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
|
||||
.length === 0;
|
||||
const shouldStayAvailable4k =
|
||||
media.status4k === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter(
|
||||
(season) =>
|
||||
season.status4k !== MediaStatus.UNKNOWN &&
|
||||
season.status4k !== MediaStatus.DELETED &&
|
||||
season.seasonNumber !== 0
|
||||
).length === 0;
|
||||
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
|
||||
.length === 0;
|
||||
|
||||
media.status =
|
||||
isAllStandardSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
@@ -434,13 +417,11 @@ class BaseScanner<T> {
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||
: !seasons.length ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k =
|
||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||
@@ -452,13 +433,11 @@ class BaseScanner<T> {
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
|
||||
: !seasons.length ||
|
||||
media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status4k === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${title}`);
|
||||
|
||||
@@ -128,8 +128,6 @@ export interface MainSettings {
|
||||
discoverRegion: string;
|
||||
streamingRegion: string;
|
||||
originalLanguage: string;
|
||||
blacklistedTags: string;
|
||||
blacklistedTagsLimit: number;
|
||||
mediaServerType: number;
|
||||
partialRequestsEnabled: boolean;
|
||||
enableSpecialEpisodes: boolean;
|
||||
@@ -304,8 +302,7 @@ export type JobId =
|
||||
| 'jellyfin-recently-added-scan'
|
||||
| 'jellyfin-full-scan'
|
||||
| 'image-cache-cleanup'
|
||||
| 'availability-sync'
|
||||
| 'process-blacklisted-tags';
|
||||
| 'availability-sync';
|
||||
|
||||
export interface AllSettings {
|
||||
clientId: string;
|
||||
@@ -352,8 +349,6 @@ class Settings {
|
||||
discoverRegion: '',
|
||||
streamingRegion: '',
|
||||
originalLanguage: '',
|
||||
blacklistedTags: '',
|
||||
blacklistedTagsLimit: 50,
|
||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||
partialRequestsEnabled: true,
|
||||
enableSpecialEpisodes: false,
|
||||
@@ -510,9 +505,6 @@ class Settings {
|
||||
'image-cache-cleanup': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
'process-blacklisted-tags': {
|
||||
schedule: '0 30 1 */7 * *',
|
||||
},
|
||||
},
|
||||
network: {
|
||||
csrfProtection: false,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface {
|
||||
name = 'AddBlacklistTagsColumn1737320080282';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" ADD blacklistedTags character varying`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" DROP COLUMN blacklistedTags`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateWebPush1745492376568 implements MigrationInterface {
|
||||
name = 'UpdateWebPush1745492376568';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME COLUMN "blacklistedtags" TO "blacklistedTags"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME COLUMN "blacklistedTags" TO "blacklistedtags"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface {
|
||||
name = 'AddBlacklistTagsColumn1737320080282';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateWebPush1745492372230 implements MigrationInterface {
|
||||
name = 'UpdateWebPush1745492372230';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
}
|
||||
}
|
||||
@@ -19,54 +19,39 @@ export const blacklistAdd = z.object({
|
||||
user: z.coerce.number(),
|
||||
});
|
||||
|
||||
const blacklistGet = z.object({
|
||||
take: z.coerce.number().int().positive().default(25),
|
||||
skip: z.coerce.number().int().nonnegative().default(0),
|
||||
search: z.string().optional(),
|
||||
filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(),
|
||||
});
|
||||
|
||||
blacklistRoutes.get(
|
||||
'/',
|
||||
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const { take, skip, search, filter } = blacklistGet.parse(req.query);
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const search = (req.query.search as string) ?? '';
|
||||
|
||||
try {
|
||||
let query = getRepository(Blacklist)
|
||||
.createQueryBuilder('blacklist')
|
||||
.leftJoinAndSelect('blacklist.user', 'user')
|
||||
.where('1 = 1'); // Allow use of andWhere later
|
||||
.leftJoinAndSelect('blacklist.user', 'user');
|
||||
|
||||
switch (filter) {
|
||||
case 'manual':
|
||||
query = query.andWhere('blacklist.blacklistedTags IS NULL');
|
||||
break;
|
||||
case 'blacklistedTags':
|
||||
query = query.andWhere('blacklist.blacklistedTags IS NOT NULL');
|
||||
break;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query = query.andWhere('blacklist.title like :title', {
|
||||
if (search.length > 0) {
|
||||
query = query.where('blacklist.title like :title', {
|
||||
title: `%${search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
const [blacklistedItems, itemsCount] = await query
|
||||
.orderBy('blacklist.createdAt', 'DESC')
|
||||
.take(take)
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(itemsCount / take),
|
||||
pageSize: take,
|
||||
pages: Math.ceil(itemsCount / pageSize),
|
||||
pageSize,
|
||||
results: itemsCount,
|
||||
page: Math.ceil(skip / take) + 1,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: blacklistedItems,
|
||||
} as BlacklistResultsResponse);
|
||||
|
||||
@@ -5,7 +5,6 @@ import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import Season from '@server/entity/Season';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
MediaResultsResponse,
|
||||
@@ -102,7 +101,6 @@ mediaRoutes.post<
|
||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||
async (req, res, next) => {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const seasonRepository = getRepository(Season);
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: Number(req.params.id) },
|
||||
@@ -117,25 +115,11 @@ mediaRoutes.post<
|
||||
switch (req.params.status) {
|
||||
case 'available':
|
||||
media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
||||
|
||||
if (media.mediaType === MediaType.TV) {
|
||||
const expectedSeasons = req.body.seasons ?? [];
|
||||
|
||||
for (const expectedSeason of expectedSeasons) {
|
||||
let season = media.seasons.find(
|
||||
(s) => s.seasonNumber === expectedSeason?.seasonNumber
|
||||
);
|
||||
|
||||
if (!season) {
|
||||
// Create the season if it doesn't exist
|
||||
season = seasonRepository.create({
|
||||
seasonNumber: expectedSeason?.seasonNumber,
|
||||
});
|
||||
media.seasons.push(season);
|
||||
}
|
||||
|
||||
// Mark all seasons available
|
||||
media.seasons.forEach((season) => {
|
||||
season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'partial':
|
||||
|
||||
@@ -44,6 +44,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
switch (req.query.filter) {
|
||||
case 'approved':
|
||||
case 'processing':
|
||||
case 'available':
|
||||
statusFilter = [MediaRequestStatus.APPROVED];
|
||||
break;
|
||||
case 'pending':
|
||||
@@ -58,18 +59,12 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
case 'failed':
|
||||
statusFilter = [MediaRequestStatus.FAILED];
|
||||
break;
|
||||
case 'completed':
|
||||
case 'available':
|
||||
case 'deleted':
|
||||
statusFilter = [MediaRequestStatus.COMPLETED];
|
||||
break;
|
||||
default:
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
MediaRequestStatus.DECLINED,
|
||||
MediaRequestStatus.FAILED,
|
||||
MediaRequestStatus.COMPLETED,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -88,9 +83,6 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
];
|
||||
break;
|
||||
case 'deleted':
|
||||
mediaStatusFilter = [MediaStatus.DELETED];
|
||||
break;
|
||||
default:
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
@@ -98,7 +90,6 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
MediaStatus.AVAILABLE,
|
||||
MediaStatus.DELETED,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -307,7 +298,7 @@ requestRoutes.get('/count', async (_req, res, next) => {
|
||||
try {
|
||||
const query = requestRepository
|
||||
.createQueryBuilder('request')
|
||||
.innerJoinAndSelect('request.media', 'media');
|
||||
.leftJoinAndSelect('request.media', 'media');
|
||||
|
||||
const totalCount = await query.getCount();
|
||||
|
||||
@@ -501,8 +492,7 @@ requestRoutes.put<{ requestId: string }>(
|
||||
(r) =>
|
||||
r.is4k === request.is4k &&
|
||||
r.id !== request.id &&
|
||||
r.status !== MediaRequestStatus.DECLINED &&
|
||||
r.status !== MediaRequestStatus.COMPLETED
|
||||
r.status !== MediaRequestStatus.DECLINED
|
||||
)
|
||||
.reduce((seasons, r) => {
|
||||
const combinedSeasons = r.seasons.map(
|
||||
|
||||
@@ -1,818 +0,0 @@
|
||||
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import type {
|
||||
AddSeriesOptions,
|
||||
SonarrSeries,
|
||||
} from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isEqual, truncate } from 'lodash';
|
||||
import type {
|
||||
EntityManager,
|
||||
EntitySubscriberInterface,
|
||||
InsertEvent,
|
||||
RemoveEvent,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import { EventSubscriber } from 'typeorm';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaRequestSubscriber
|
||||
implements EntitySubscriberInterface<MediaRequest>
|
||||
{
|
||||
private async notifyAvailableMovie(entity: MediaRequest) {
|
||||
if (
|
||||
entity.media[entity.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE
|
||||
) {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const movie = await tmdb.getMovie({
|
||||
movieId: entity.media.tmdbId,
|
||||
});
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: entity.requestedBy,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(movie.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
media: entity.media,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: entity,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(entity: MediaRequest) {
|
||||
// Find all seasons in the related media entity
|
||||
// and see if they are available, then we can check
|
||||
// if the request contains the same seasons
|
||||
const requestedSeasons =
|
||||
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
|
||||
const availableSeasons = entity.media.seasons.filter(
|
||||
(season) =>
|
||||
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||
requestedSeasons.includes(season.seasonNumber)
|
||||
);
|
||||
const isMediaAvailable =
|
||||
availableSeasons.length > 0 &&
|
||||
availableSeasons.length === requestedSeasons.length;
|
||||
|
||||
if (isMediaAvailable) {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(tv.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: entity.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
media: entity.media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Requested Seasons',
|
||||
value: entity.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: entity,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async sendToRadarr(entity: MediaRequest): Promise<void> {
|
||||
if (
|
||||
entity.status === MediaRequestStatus.APPROVED &&
|
||||
entity.type === MediaType.MOVIE
|
||||
) {
|
||||
try {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const settings = getSettings();
|
||||
if (settings.radarr.length === 0 && !settings.radarr[0]) {
|
||||
logger.info(
|
||||
'No Radarr server configured, skipping request processing',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && radarr.is4k === entity.is4k
|
||||
);
|
||||
|
||||
if (
|
||||
entity.serverId !== null &&
|
||||
entity.serverId >= 0 &&
|
||||
radarrSettings?.id !== entity.serverId
|
||||
) {
|
||||
radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.id === entity.serverId
|
||||
);
|
||||
logger.info(
|
||||
`Request has an override server: ${radarrSettings?.name}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!radarrSettings) {
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
entity.is4k ? '4K ' : ''
|
||||
}Radarr server configured. Did you set any of your ${
|
||||
entity.is4k ? '4K ' : ''
|
||||
}Radarr servers as default?`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let rootFolder = radarrSettings.activeDirectory;
|
||||
let qualityProfile = radarrSettings.activeProfileId;
|
||||
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||
|
||||
if (
|
||||
entity.rootFolder &&
|
||||
entity.rootFolder !== '' &&
|
||||
entity.rootFolder !== radarrSettings.activeDirectory
|
||||
) {
|
||||
rootFolder = entity.rootFolder;
|
||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
entity.profileId &&
|
||||
entity.profileId !== radarrSettings.activeProfileId
|
||||
) {
|
||||
qualityProfile = entity.profileId;
|
||||
logger.info(
|
||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (entity.tags && !isEqual(entity.tags, radarrSettings.tags)) {
|
||||
tags = entity.tags;
|
||||
logger.info(`Request has override tags`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
tagIds: tags,
|
||||
});
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
|
||||
});
|
||||
const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
logger.error('Media data not found', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (radarrSettings.tagRequests) {
|
||||
let userTag = (await radarr.getTags()).find((v) =>
|
||||
v.label.startsWith(entity.requestedBy.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
logger.info(`Requester has no active tag. Creating new`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
});
|
||||
userTag = await radarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
if (!tags?.find((v) => v === userTag?.id)) {
|
||||
tags?.push(userTag.id);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Requester has no tag and failed to add one`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
logger.warn('Media already exists, marking request as APPROVED', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
entity.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrMovieOptions: RadarrMovieOptions = {
|
||||
profileId: qualityProfile,
|
||||
qualityProfileId: qualityProfile,
|
||||
rootFolderPath: rootFolder,
|
||||
minimumAvailability: radarrSettings.minimumAvailability,
|
||||
title: movie.title,
|
||||
tmdbId: movie.id,
|
||||
year: Number(movie.release_date.slice(0, 4)),
|
||||
monitored: true,
|
||||
tags,
|
||||
searchNow: !radarrSettings.preventSearch,
|
||||
};
|
||||
|
||||
// Run entity asynchronously so we don't wait for it on the UI side
|
||||
radarr
|
||||
.addMovie(radarrMovieOptions)
|
||||
.then(async (radarrMovie) => {
|
||||
// We grab media again here to make sure we have the latest version of it
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
radarrMovie.id;
|
||||
media[
|
||||
entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||
] = radarrMovie.titleSlug;
|
||||
media[entity.is4k ? 'serviceId4k' : 'serviceId'] =
|
||||
radarrSettings?.id;
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
entity.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(entity);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
radarrMovieOptions,
|
||||
}
|
||||
);
|
||||
|
||||
MediaRequest.sendNotification(
|
||||
entity,
|
||||
media,
|
||||
Notification.MEDIA_FAILED
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
radarr.clearCache({
|
||||
tmdbId: movie.id,
|
||||
externalId: entity.is4k
|
||||
? media.externalServiceId4k
|
||||
: media.externalServiceId,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Radarr', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending request to Radarr', {
|
||||
label: 'Media Request',
|
||||
errorMessage: e.message,
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async sendToSonarr(entity: MediaRequest): Promise<void> {
|
||||
if (
|
||||
entity.status === MediaRequestStatus.APPROVED &&
|
||||
entity.type === MediaType.TV
|
||||
) {
|
||||
try {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const settings = getSettings();
|
||||
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
|
||||
logger.warn(
|
||||
'No Sonarr server configured, skipping request processing',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && sonarr.is4k === entity.is4k
|
||||
);
|
||||
|
||||
if (
|
||||
entity.serverId !== null &&
|
||||
entity.serverId >= 0 &&
|
||||
sonarrSettings?.id !== entity.serverId
|
||||
) {
|
||||
sonarrSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.id === entity.serverId
|
||||
);
|
||||
logger.info(
|
||||
`Request has an override server: ${sonarrSettings?.name}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!sonarrSettings) {
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
entity.is4k ? '4K ' : ''
|
||||
}Sonarr server configured. Did you set any of your ${
|
||||
entity.is4k ? '4K ' : ''
|
||||
}Sonarr servers as default?`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
if (
|
||||
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
logger.warn('Media already exists, marking request as APPROVED', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
entity.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||
});
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||
|
||||
if (!tvdbId) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
await mediaRepository.remove(media);
|
||||
await requestRepository.remove(entity);
|
||||
throw new Error('TVDB ID not found');
|
||||
}
|
||||
|
||||
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
||||
|
||||
// Change series type to anime if the anime keyword is present on tmdb
|
||||
if (
|
||||
series.keywords.results.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
) {
|
||||
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
||||
}
|
||||
|
||||
let rootFolder =
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
||||
? sonarrSettings.activeAnimeDirectory
|
||||
: sonarrSettings.activeDirectory;
|
||||
let qualityProfile =
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||
? sonarrSettings.activeAnimeProfileId
|
||||
: sonarrSettings.activeProfileId;
|
||||
let languageProfile =
|
||||
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
|
||||
? sonarrSettings.activeAnimeLanguageProfileId
|
||||
: sonarrSettings.activeLanguageProfileId;
|
||||
let tags =
|
||||
seriesType === 'anime'
|
||||
? sonarrSettings.animeTags
|
||||
? [...sonarrSettings.animeTags]
|
||||
: []
|
||||
: sonarrSettings.tags
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
|
||||
if (
|
||||
entity.rootFolder &&
|
||||
entity.rootFolder !== '' &&
|
||||
entity.rootFolder !== rootFolder
|
||||
) {
|
||||
rootFolder = entity.rootFolder;
|
||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (entity.profileId && entity.profileId !== qualityProfile) {
|
||||
qualityProfile = entity.profileId;
|
||||
logger.info(
|
||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
entity.languageProfileId &&
|
||||
entity.languageProfileId !== languageProfile
|
||||
) {
|
||||
languageProfile = entity.languageProfileId;
|
||||
logger.info(
|
||||
`Request has an override language profile ID: ${languageProfile}`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (entity.tags && !isEqual(entity.tags, tags)) {
|
||||
tags = entity.tags;
|
||||
logger.info(`Request has override tags`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
tagIds: tags,
|
||||
});
|
||||
}
|
||||
|
||||
if (sonarrSettings.tagRequests) {
|
||||
let userTag = (await sonarr.getTags()).find((v) =>
|
||||
v.label.startsWith(entity.requestedBy.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
logger.info(`Requester has no active tag. Creating new`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
});
|
||||
userTag = await sonarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
if (!tags?.find((v) => v === userTag?.id)) {
|
||||
tags?.push(userTag.id);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Requester has no tag and failed to add one`, {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||
profileId: qualityProfile,
|
||||
languageProfileId: languageProfile,
|
||||
rootFolderPath: rootFolder,
|
||||
title: series.name,
|
||||
tvdbid: tvdbId,
|
||||
seasons: entity.seasons.map((season) => season.seasonNumber),
|
||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||
seriesType,
|
||||
tags,
|
||||
monitored: true,
|
||||
searchNow: !sonarrSettings.preventSearch,
|
||||
};
|
||||
|
||||
// Run entity asynchronously so we don't wait for it on the UI side
|
||||
sonarr
|
||||
.addSeries(sonarrSeriesOptions)
|
||||
.then(async (sonarrSeries) => {
|
||||
// We grab media again here to make sure we have the latest version of it
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
sonarrSeries.id;
|
||||
media[
|
||||
entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||
] = sonarrSeries.titleSlug;
|
||||
media[entity.is4k ? 'serviceId4k' : 'serviceId'] =
|
||||
sonarrSettings?.id;
|
||||
await mediaRepository.save(media);
|
||||
})
|
||||
.catch(async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
entity.status = MediaRequestStatus.FAILED;
|
||||
requestRepository.save(entity);
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||
{
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
sonarrSeriesOptions,
|
||||
}
|
||||
);
|
||||
|
||||
MediaRequest.sendNotification(
|
||||
entity,
|
||||
media,
|
||||
Notification.MEDIA_FAILED
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
sonarr.clearCache({
|
||||
tvdbId,
|
||||
externalId: entity.is4k
|
||||
? media.externalServiceId4k
|
||||
: media.externalServiceId,
|
||||
title: series.name,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Sonarr', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending request to Sonarr', {
|
||||
label: 'Media Request',
|
||||
errorMessage: e.message,
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
throw new Error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async updateParentStatus(entity: MediaRequest): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
if (!media) {
|
||||
logger.error('Media data not found', {
|
||||
label: 'Media Request',
|
||||
requestId: entity.id,
|
||||
mediaId: entity.media.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
if (
|
||||
entity.status === MediaRequestStatus.APPROVED &&
|
||||
// Do not update the status if the item is already partially available or available
|
||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.PARTIALLY_AVAILABLE &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
|
||||
) {
|
||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
if (
|
||||
media.mediaType === MediaType.MOVIE &&
|
||||
entity.status === MediaRequestStatus.DECLINED &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||
) {
|
||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the media type is TV, and we are declining a request,
|
||||
* we must check if its the only pending request and that
|
||||
* there the current media status is just pending (meaning no
|
||||
* other requests have yet to be approved)
|
||||
*/
|
||||
if (
|
||||
media.mediaType === MediaType.TV &&
|
||||
entity.status === MediaRequestStatus.DECLINED &&
|
||||
media.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
).length === 0 &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING &&
|
||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||
) {
|
||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
}
|
||||
|
||||
// Approve child seasons if parent is approved
|
||||
if (
|
||||
media.mediaType === MediaType.TV &&
|
||||
entity.status === MediaRequestStatus.APPROVED
|
||||
) {
|
||||
entity.seasons.forEach((season) => {
|
||||
season.status = MediaRequestStatus.APPROVED;
|
||||
seasonRequestRepository.save(season);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async handleRemoveParentUpdate(
|
||||
manager: EntityManager,
|
||||
entity: MediaRequest
|
||||
): Promise<void> {
|
||||
const fullMedia = await manager.findOneOrFail(Media, {
|
||||
where: { id: entity.media.id },
|
||||
relations: { requests: true },
|
||||
});
|
||||
|
||||
if (!fullMedia) return;
|
||||
|
||||
if (
|
||||
!fullMedia.requests.some((request) => !request.is4k) &&
|
||||
fullMedia.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
fullMedia.status = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
if (
|
||||
!fullMedia.requests.some((request) => request.is4k) &&
|
||||
fullMedia.status4k !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
fullMedia.status4k = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
await manager.save(fullMedia);
|
||||
}
|
||||
|
||||
public afterUpdate(event: UpdateEvent<MediaRequest>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendToRadarr(event.entity as MediaRequest);
|
||||
this.sendToSonarr(event.entity as MediaRequest);
|
||||
|
||||
this.updateParentStatus(event.entity as MediaRequest);
|
||||
|
||||
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||
this.notifyAvailableMovie(event.entity as MediaRequest);
|
||||
}
|
||||
if (event.entity.media.mediaType === MediaType.TV) {
|
||||
this.notifyAvailableSeries(event.entity as MediaRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public afterInsert(event: InsertEvent<MediaRequest>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendToRadarr(event.entity as MediaRequest);
|
||||
this.sendToSonarr(event.entity as MediaRequest);
|
||||
|
||||
this.updateParentStatus(event.entity as MediaRequest);
|
||||
}
|
||||
|
||||
public async afterRemove(event: RemoveEvent<MediaRequest>): Promise<void> {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleRemoveParentUpdate(
|
||||
event.manager as EntityManager,
|
||||
event.entity as MediaRequest
|
||||
);
|
||||
}
|
||||
|
||||
public listenTo(): typeof MediaRequest {
|
||||
return MediaRequest;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
@@ -7,12 +8,172 @@ 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 notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import logger from '@server/logger';
|
||||
import { truncate } from 'lodash';
|
||||
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
|
||||
import { EventSubscriber } from 'typeorm';
|
||||
import { EventSubscriber, In, Not } from 'typeorm';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||
private async notifyAvailableMovie(
|
||||
entity: Media,
|
||||
dbEntity: Media,
|
||||
is4k: boolean
|
||||
) {
|
||||
if (
|
||||
entity[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||
dbEntity[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
if (entity.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: {
|
||||
media: {
|
||||
id: entity.id,
|
||||
},
|
||||
is4k,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(
|
||||
Notification.MEDIA_AVAILABLE,
|
||||
{
|
||||
event: `${is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: request.requestedBy,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date
|
||||
? ` (${movie.release_date.slice(0, 4)})`
|
||||
: ''
|
||||
}`,
|
||||
message: truncate(movie.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
media: entity,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request,
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(
|
||||
entity: Media,
|
||||
dbEntity: Media,
|
||||
is4k: boolean
|
||||
) {
|
||||
const seasonRepository = getRepository(Season);
|
||||
const newAvailableSeasons = entity.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
const oldSeasonIds = dbEntity.seasons.map((season) => season.id);
|
||||
const oldSeasons = await seasonRepository.findBy({ id: In(oldSeasonIds) });
|
||||
const oldAvailableSeasons = oldSeasons
|
||||
.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
const changedSeasons = newAvailableSeasons.filter(
|
||||
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
|
||||
);
|
||||
|
||||
if (changedSeasons.length > 0) {
|
||||
const tmdb = new TheMovieDb();
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const processedSeasons: number[] = [];
|
||||
|
||||
for (const changedSeasonNumber of changedSeasons) {
|
||||
const requests = await requestRepository.find({
|
||||
where: {
|
||||
media: {
|
||||
id: entity.id,
|
||||
},
|
||||
is4k,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
});
|
||||
const request = requests.find(
|
||||
(request) =>
|
||||
// Check if the season is complete AND it contains the current season that was just marked available
|
||||
request.seasons.every((season) =>
|
||||
newAvailableSeasons.includes(season.seasonNumber)
|
||||
) &&
|
||||
request.seasons.some(
|
||||
(season) => season.seasonNumber === changedSeasonNumber
|
||||
)
|
||||
);
|
||||
|
||||
if (request && !processedSeasons.includes(changedSeasonNumber)) {
|
||||
processedSeasons.push(
|
||||
...request.seasons.map((season) => season.seasonNumber)
|
||||
);
|
||||
|
||||
try {
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${is4k ? '4K ' : ''}Series Request Now Available`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(tv.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
media: entity,
|
||||
extra: [
|
||||
{
|
||||
name: 'Requested Seasons',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async updateChildRequestStatus(event: Media, is4k: boolean) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
@@ -31,101 +192,57 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRelatedMediaRequest(
|
||||
event: Media,
|
||||
databaseEvent: Media,
|
||||
is4k: boolean
|
||||
) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
|
||||
const relatedRequests = await requestRepository.find({
|
||||
relations: {
|
||||
media: true,
|
||||
},
|
||||
where: {
|
||||
media: { id: event.id },
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
is4k,
|
||||
},
|
||||
});
|
||||
|
||||
// Check the media entity status and if available
|
||||
// or deleted, set the related request to completed
|
||||
if (relatedRequests.length > 0) {
|
||||
const completedRequests: MediaRequest[] = [];
|
||||
|
||||
for (const request of relatedRequests) {
|
||||
let shouldComplete = false;
|
||||
|
||||
if (
|
||||
(event[request.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE ||
|
||||
event[request.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.DELETED) &&
|
||||
event.mediaType === MediaType.MOVIE
|
||||
) {
|
||||
shouldComplete = true;
|
||||
} else if (event.mediaType === 'tv') {
|
||||
const allSeasonResults = await Promise.all(
|
||||
request.seasons.map(async (requestSeason) => {
|
||||
const matchingSeason = event.seasons.find(
|
||||
(mediaSeason) =>
|
||||
mediaSeason.seasonNumber === requestSeason.seasonNumber
|
||||
);
|
||||
const matchingOldSeason = databaseEvent.seasons.find(
|
||||
(oldSeason) =>
|
||||
oldSeason.seasonNumber === requestSeason.seasonNumber
|
||||
);
|
||||
|
||||
if (!matchingSeason) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentSeasonStatus =
|
||||
matchingSeason[request.is4k ? 'status4k' : 'status'];
|
||||
const previousSeasonStatus =
|
||||
matchingOldSeason?.[request.is4k ? 'status4k' : 'status'];
|
||||
|
||||
const hasStatusChanged =
|
||||
currentSeasonStatus !== previousSeasonStatus;
|
||||
|
||||
const shouldUpdate =
|
||||
(hasStatusChanged ||
|
||||
requestSeason.status === MediaRequestStatus.COMPLETED) &&
|
||||
(currentSeasonStatus === MediaStatus.AVAILABLE ||
|
||||
currentSeasonStatus === MediaStatus.DELETED);
|
||||
|
||||
if (shouldUpdate) {
|
||||
requestSeason.status = MediaRequestStatus.COMPLETED;
|
||||
await seasonRequestRepository.save(requestSeason);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
const allSeasonsReady = allSeasonResults.every((result) => result);
|
||||
shouldComplete = allSeasonsReady;
|
||||
}
|
||||
|
||||
if (shouldComplete) {
|
||||
request.status = MediaRequestStatus.COMPLETED;
|
||||
completedRequests.push(request);
|
||||
}
|
||||
}
|
||||
|
||||
await requestRepository.save(completedRequests);
|
||||
}
|
||||
}
|
||||
|
||||
public async beforeUpdate(event: UpdateEvent<Media>): Promise<void> {
|
||||
public beforeUpdate(event: UpdateEvent<Media>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status4k === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.TV &&
|
||||
(event.entity.status === MediaStatus.AVAILABLE ||
|
||||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.TV &&
|
||||
(event.entity.status4k === MediaStatus.AVAILABLE ||
|
||||
event.entity.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.status === MediaStatus.AVAILABLE &&
|
||||
event.databaseEntity.status === MediaStatus.PENDING
|
||||
@@ -139,65 +256,6 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||
) {
|
||||
this.updateChildRequestStatus(event.entity as Media, true);
|
||||
}
|
||||
|
||||
// Manually load related seasons into databaseEntity
|
||||
// for seasonStatusCheck in afterUpdate
|
||||
const seasons = await event.manager
|
||||
.getRepository(Season)
|
||||
.createQueryBuilder('season')
|
||||
.leftJoin('season.media', 'media')
|
||||
.where('media.id = :id', { id: event.databaseEntity.id })
|
||||
.getMany();
|
||||
|
||||
event.databaseEntity.seasons = seasons;
|
||||
}
|
||||
|
||||
public async afterUpdate(event: UpdateEvent<Media>): Promise<void> {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validStatuses = [
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
MediaStatus.AVAILABLE,
|
||||
MediaStatus.DELETED,
|
||||
];
|
||||
|
||||
const seasonStatusCheck = (is4k: boolean) => {
|
||||
return event.entity?.seasons?.some((season: Season, index: number) => {
|
||||
const previousSeason = event.databaseEntity.seasons[index];
|
||||
|
||||
return (
|
||||
season[is4k ? 'status4k' : 'status'] !==
|
||||
previousSeason?.[is4k ? 'status4k' : 'status']
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (
|
||||
(event.entity.status !== event.databaseEntity?.status ||
|
||||
(event.entity.mediaType === MediaType.TV &&
|
||||
seasonStatusCheck(false))) &&
|
||||
validStatuses.includes(event.entity.status)
|
||||
) {
|
||||
this.updateRelatedMediaRequest(
|
||||
event.entity as Media,
|
||||
event.databaseEntity as Media,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(event.entity.status4k !== event.databaseEntity?.status4k ||
|
||||
(event.entity.mediaType === MediaType.TV && seasonStatusCheck(true))) &&
|
||||
validStatuses.includes(event.entity.status4k)
|
||||
) {
|
||||
this.updateRelatedMediaRequest(
|
||||
event.entity as Media,
|
||||
event.databaseEntity as Media,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public listenTo(): typeof Media {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
@@ -15,7 +14,6 @@ import defineMessages from '@app/utils/defineMessages';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
@@ -44,17 +42,8 @@ const messages = defineMessages('components.Blacklist', {
|
||||
blacklistdate: 'date',
|
||||
blacklistedby: '{date} by {user}',
|
||||
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
|
||||
filterManual: 'Manual',
|
||||
filterBlacklistedTags: 'Blacklisted Tags',
|
||||
showAllBlacklisted: 'Show All Blacklisted Media',
|
||||
});
|
||||
|
||||
enum Filter {
|
||||
ALL = 'all',
|
||||
MANUAL = 'manual',
|
||||
BLACKLISTEDTAGS = 'blacklistedTags',
|
||||
}
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
};
|
||||
@@ -63,7 +52,6 @@ const Blacklist = () => {
|
||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
||||
useDebouncedState('');
|
||||
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.MANUAL);
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -76,11 +64,9 @@ const Blacklist = () => {
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<BlacklistResultsResponse>(
|
||||
`/api/v1/blacklist/?take=${currentPageSize}&skip=${
|
||||
pageIndex * currentPageSize
|
||||
}&filter=${currentFilter}${
|
||||
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
|
||||
}`,
|
||||
`/api/v1/blacklist/?take=${currentPageSize}
|
||||
&skip=${pageIndex * currentPageSize}
|
||||
${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
revalidateOnFocus: false,
|
||||
@@ -108,52 +94,19 @@ const Blacklist = () => {
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
|
||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
|
||||
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
|
||||
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||
<FunnelIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="filter"
|
||||
name="filter"
|
||||
onChange={(e) => {
|
||||
setCurrentFilter(e.target.value as Filter);
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
});
|
||||
}}
|
||||
value={currentFilter}
|
||||
className="rounded-r-only"
|
||||
>
|
||||
<option value="all">
|
||||
{intl.formatMessage(globalMessages.all)}
|
||||
</option>
|
||||
<option value="manual">
|
||||
{intl.formatMessage(messages.filterManual)}
|
||||
</option>
|
||||
<option value="blacklistedTags">
|
||||
{intl.formatMessage(messages.filterBlacklistedTags)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||
<MagnifyingGlassIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="rounded-r-only"
|
||||
value={searchFilter}
|
||||
onChange={(e) => searchItem(e)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||
<MagnifyingGlassIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="rounded-r-only"
|
||||
value={searchFilter}
|
||||
onChange={(e) => searchItem(e)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,16 +117,6 @@ const Blacklist = () => {
|
||||
<span className="text-2xl text-gray-400">
|
||||
{intl.formatMessage(globalMessages.noresults)}
|
||||
</span>
|
||||
{currentFilter !== Filter.ALL && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => setCurrentFilter(Filter.ALL)}
|
||||
>
|
||||
{intl.formatMessage(messages.showAllBlacklisted)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
data.results.map((item: BlacklistItem) => {
|
||||
@@ -409,7 +352,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
numeric="auto"
|
||||
/>
|
||||
),
|
||||
user: item.user ? (
|
||||
user: (
|
||||
<Link href={`/users/${item.user.id}`}>
|
||||
<span className="group flex items-center truncate">
|
||||
<CachedImage
|
||||
@@ -426,14 +369,6 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
) : item.blacklistedTags ? (
|
||||
<span className="ml-1">
|
||||
<BlacklistedTagsBadge data={item} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-1 truncate text-sm font-semibold">
|
||||
???
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
@@ -78,33 +77,22 @@ const BlacklistBlock = ({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
||||
<div className="white mb-1 flex flex-nowrap">
|
||||
{data.user ? (
|
||||
<>
|
||||
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
|
||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
<Link
|
||||
href={
|
||||
data.user.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${data.user.id}`
|
||||
}
|
||||
>
|
||||
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
{data.user.displayName}
|
||||
</span>
|
||||
</Link>
|
||||
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
|
||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
<Link
|
||||
href={
|
||||
data.user.id === user?.id
|
||||
? '/profile'
|
||||
: `/users/${data.user.id}`
|
||||
}
|
||||
>
|
||||
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
{data.user.displayName}
|
||||
</span>
|
||||
</>
|
||||
) : data.blacklistedTags ? (
|
||||
<>
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
{intl.formatMessage(messages.blacklistedby)}:
|
||||
</span>
|
||||
<BlacklistedTagsBadge data={data} />
|
||||
</>
|
||||
) : null}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { TagIcon } from '@heroicons/react/20/solid';
|
||||
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||
import type { Keyword } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Settings', {
|
||||
blacklistedTagsText: 'Blacklisted Tags',
|
||||
});
|
||||
|
||||
interface BlacklistedTagsBadgeProps {
|
||||
data: BlacklistItem;
|
||||
}
|
||||
|
||||
const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
||||
const [tagNamesBlacklistedFor, setTagNamesBlacklistedFor] =
|
||||
useState<string>('Loading...');
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.blacklistedTags) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
|
||||
Promise.all(
|
||||
keywordIds.map(async (keywordId) => {
|
||||
try {
|
||||
const { data } = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return data.name;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
).then((keywords) => {
|
||||
setTagNamesBlacklistedFor(keywords.join(', '));
|
||||
});
|
||||
}, [data.blacklistedTags]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={tagNamesBlacklistedFor}
|
||||
tooltipConfig={{ followCursor: false }}
|
||||
>
|
||||
<Badge
|
||||
badgeType="dark"
|
||||
className="items-center border border-red-500 !text-red-400"
|
||||
>
|
||||
<TagIcon className="mr-1 h-4" />
|
||||
{intl.formatMessage(messages.blacklistedTagsText)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlacklistedTagsBadge;
|
||||
@@ -1,402 +0,0 @@
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import CopyButton from '@app/components/Settings/CopyButton';
|
||||
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { ArrowDownIcon } from '@heroicons/react/24/solid';
|
||||
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
|
||||
import type { Keyword } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { useFormikContext } from 'formik';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import type { ClearIndicatorProps, GroupBase, MultiValue } from 'react-select';
|
||||
import { components } from 'react-select';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
|
||||
const messages = defineMessages('components.Settings', {
|
||||
copyBlacklistedTags: 'Copied blacklisted tags to clipboard.',
|
||||
copyBlacklistedTagsTip: 'Copy blacklisted tag configuration',
|
||||
copyBlacklistedTagsEmpty: 'Nothing to copy',
|
||||
importBlacklistedTagsTip: 'Import blacklisted tag configuration',
|
||||
clearBlacklistedTagsConfirm:
|
||||
'Are you sure you want to clear the blacklisted tags?',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
searchKeywords: 'Search keywords…',
|
||||
starttyping: 'Starting typing to search.',
|
||||
nooptions: 'No results.',
|
||||
blacklistedTagImportTitle: 'Import Blacklisted Tag Configuration',
|
||||
blacklistedTagImportInstructions: 'Paste blacklist tag configuration below.',
|
||||
valueRequired: 'You must provide a value.',
|
||||
noSpecialCharacters:
|
||||
'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.',
|
||||
invalidKeyword: '{keywordId} is not a TMDB keyword.',
|
||||
});
|
||||
|
||||
type SingleVal = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
type BlacklistedTagsSelectorProps = {
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
const BlacklistedTagsSelector = ({
|
||||
defaultValue,
|
||||
}: BlacklistedTagsSelectorProps) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [value, setValue] = useState<string | undefined>(defaultValue);
|
||||
const intl = useIntl();
|
||||
const [selectorValue, setSelectorValue] =
|
||||
useState<MultiValue<SingleVal> | null>(null);
|
||||
|
||||
const update = useCallback(
|
||||
(value: MultiValue<SingleVal> | null) => {
|
||||
const strVal = value?.map((v) => v.value).join(',');
|
||||
setSelectorValue(value);
|
||||
setValue(strVal);
|
||||
setFieldValue('blacklistedTags', strVal);
|
||||
},
|
||||
[setSelectorValue, setValue, setFieldValue]
|
||||
);
|
||||
|
||||
const copyDisabled = value === null || value?.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlledKeywordSelector
|
||||
value={selectorValue}
|
||||
onChange={update}
|
||||
defaultValue={defaultValue}
|
||||
components={{
|
||||
DropdownIndicator: undefined,
|
||||
IndicatorSeparator: undefined,
|
||||
ClearIndicator: VerifyClearIndicator,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CopyButton
|
||||
textToCopy={value ?? ''}
|
||||
disabled={copyDisabled}
|
||||
toastMessage={intl.formatMessage(messages.copyBlacklistedTags)}
|
||||
tooltipContent={intl.formatMessage(
|
||||
copyDisabled
|
||||
? messages.copyBlacklistedTagsEmpty
|
||||
: messages.copyBlacklistedTagsTip
|
||||
)}
|
||||
tooltipConfig={{ followCursor: false }}
|
||||
/>
|
||||
<BlacklistedTagsImportButton setSelector={update} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type BaseSelectorMultiProps = {
|
||||
defaultValue?: string;
|
||||
value: MultiValue<SingleVal> | null;
|
||||
onChange: (value: MultiValue<SingleVal> | null) => void;
|
||||
components?: Partial<typeof components>;
|
||||
};
|
||||
|
||||
const ControlledKeywordSelector = ({
|
||||
defaultValue,
|
||||
onChange,
|
||||
components,
|
||||
value,
|
||||
}: BaseSelectorMultiProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
const loadDefaultKeywords = async (): Promise<void> => {
|
||||
if (!defaultValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keywords = await Promise.all(
|
||||
defaultValue.split(',').map(async (keywordId) => {
|
||||
const { data } = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return data;
|
||||
})
|
||||
);
|
||||
|
||||
onChange(
|
||||
keywords.map((keyword) => ({
|
||||
label: keyword.name,
|
||||
value: keyword.id,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
loadDefaultKeywords();
|
||||
}, [defaultValue, onChange]);
|
||||
|
||||
const loadKeywordOptions = async (inputValue: string) => {
|
||||
const { data } = await axios.get<TmdbKeywordSearchResponse>(
|
||||
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
|
||||
);
|
||||
|
||||
return data.results.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
key={`keyword-select-blacklistedTags`}
|
||||
inputId="data"
|
||||
isMulti
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
noOptionsMessage={({ inputValue }) =>
|
||||
inputValue === ''
|
||||
? intl.formatMessage(messages.starttyping)
|
||||
: intl.formatMessage(messages.nooptions)
|
||||
}
|
||||
value={value}
|
||||
loadOptions={loadKeywordOptions}
|
||||
placeholder={intl.formatMessage(messages.searchKeywords)}
|
||||
onChange={onChange}
|
||||
components={components}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type BlacklistedTagsImportButtonProps = {
|
||||
setSelector: (value: MultiValue<SingleVal>) => void;
|
||||
};
|
||||
|
||||
const BlacklistedTagsImportButton = ({
|
||||
setSelector,
|
||||
}: BlacklistedTagsImportButtonProps) => {
|
||||
const [show, setShow] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const intl = useIntl();
|
||||
|
||||
const onConfirm = useCallback(async () => {
|
||||
if (formRef.current) {
|
||||
if (await formRef.current.submitForm()) {
|
||||
setShow(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onClick = useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setShow(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
>
|
||||
<Modal
|
||||
title={intl.formatMessage(messages.blacklistedTagImportTitle)}
|
||||
okText="Confirm"
|
||||
onOk={onConfirm}
|
||||
onCancel={() => setShow(false)}
|
||||
>
|
||||
<BlacklistedTagImportForm ref={formRef} setSelector={setSelector} />
|
||||
</Modal>
|
||||
</Transition>
|
||||
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.importBlacklistedTagsTip)}
|
||||
tooltipConfig={{ followCursor: false }}
|
||||
>
|
||||
<button className="input-action" onClick={onClick} type="button">
|
||||
<ArrowDownIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps;
|
||||
|
||||
const BlacklistedTagImportForm = forwardRef<
|
||||
Partial<HTMLFormElement>,
|
||||
BlacklistedTagImportFormProps
|
||||
>((props, ref) => {
|
||||
const { setSelector } = props;
|
||||
const intl = useIntl();
|
||||
const [formValue, setFormValue] = useState('');
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
submitForm: handleSubmit,
|
||||
formValue,
|
||||
}));
|
||||
|
||||
const validate = async () => {
|
||||
if (formValue.length === 0) {
|
||||
setErrors([intl.formatMessage(messages.valueRequired)]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!/^(?:\d+,)*\d+$/.test(formValue)) {
|
||||
setErrors([intl.formatMessage(messages.noSpecialCharacters)]);
|
||||
return false;
|
||||
}
|
||||
|
||||
const keywords = await Promise.allSettled(
|
||||
formValue.split(',').map(async (keywordId) => {
|
||||
try {
|
||||
const { data } = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return {
|
||||
label: data.name,
|
||||
value: data.id,
|
||||
};
|
||||
} catch (err) {
|
||||
throw intl.formatMessage(messages.invalidKeyword, { keywordId });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const failures = keywords.filter(
|
||||
(res) => res.status === 'rejected'
|
||||
) as PromiseRejectedResult[];
|
||||
if (failures.length > 0) {
|
||||
setErrors(failures.map((failure) => `${failure.reason}`));
|
||||
return false;
|
||||
}
|
||||
|
||||
setSelector(
|
||||
(keywords as PromiseFulfilledResult<SingleVal>[]).map(
|
||||
(keyword) => keyword.value
|
||||
)
|
||||
);
|
||||
|
||||
setErrors([]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = validate;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="value">
|
||||
{intl.formatMessage(messages.blacklistedTagImportInstructions)}
|
||||
</label>
|
||||
<textarea
|
||||
id="value"
|
||||
value={formValue}
|
||||
onChange={(e) => setFormValue(e.target.value)}
|
||||
className="h-20"
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<div className="error">
|
||||
{errors.map((error) => (
|
||||
<div key={error}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
const VerifyClearIndicator = <
|
||||
Option,
|
||||
IsMuti extends boolean,
|
||||
Group extends GroupBase<Option>
|
||||
>(
|
||||
props: ClearIndicatorProps<Option, IsMuti, Group>
|
||||
) => {
|
||||
const { clearValue } = props;
|
||||
const [show, setShow] = useState(false);
|
||||
const intl = useIntl();
|
||||
|
||||
const openForm = useCallback(() => {
|
||||
setShow(true);
|
||||
}, [setShow]);
|
||||
|
||||
const openFormKey = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (show) return;
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Space') {
|
||||
setShow(true);
|
||||
}
|
||||
},
|
||||
[setShow, show]
|
||||
);
|
||||
|
||||
const acceptForm = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
clearValue();
|
||||
}
|
||||
},
|
||||
[clearValue]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
window.addEventListener('keydown', acceptForm);
|
||||
}
|
||||
|
||||
return () => window.removeEventListener('keydown', acceptForm);
|
||||
}, [show, acceptForm]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openForm}
|
||||
onKeyDown={openFormKey}
|
||||
className="react-select__indicator react-select__clear-indicator css-1xc3v61-indicatorContainer cursor-pointer"
|
||||
>
|
||||
<components.CrossIcon />
|
||||
</button>
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
>
|
||||
<Modal
|
||||
subTitle={intl.formatMessage(messages.clearBlacklistedTagsConfirm)}
|
||||
okText={intl.formatMessage(messages.yes)}
|
||||
cancelText={intl.formatMessage(messages.no)}
|
||||
onOk={clearValue}
|
||||
onCancel={() => setShow(false)}
|
||||
>
|
||||
<form />{' '}
|
||||
{/* Form prevents accidentally saving settings when pressing enter */}
|
||||
</Modal>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlacklistedTagsSelector;
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ClockIcon,
|
||||
EyeSlashIcon,
|
||||
MinusSmallIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
|
||||
@@ -60,10 +59,6 @@ const StatusBadgeMini = ({
|
||||
);
|
||||
indicatorIcon = <MinusSmallIcon />;
|
||||
break;
|
||||
case MediaStatus.DELETED:
|
||||
badgeStyle.push('bg-red-500 border-red-400 ring-red-400 text-red-100');
|
||||
indicatorIcon = <TrashIcon />;
|
||||
break;
|
||||
}
|
||||
|
||||
if (inProgress) {
|
||||
|
||||
@@ -152,9 +152,6 @@ const ManageSlideOver = ({
|
||||
if (data.mediaInfo) {
|
||||
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
||||
is4k,
|
||||
...(mediaType === 'tv' && {
|
||||
seasons: data.seasons.filter((season) => season.seasonNumber !== 0),
|
||||
}),
|
||||
});
|
||||
revalidate();
|
||||
}
|
||||
|
||||
@@ -206,11 +206,6 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
||||
{intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
)}
|
||||
{request.status === MediaRequestStatus.COMPLETED && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.completed)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
|
||||
|
||||
@@ -268,9 +268,7 @@ const RequestButton = ({
|
||||
|
||||
// Standard request button
|
||||
if (
|
||||
(!media ||
|
||||
media.status === MediaStatus.UNKNOWN ||
|
||||
(media.status === MediaStatus.DELETED && !activeRequest)) &&
|
||||
(!media || media.status === MediaStatus.UNKNOWN) &&
|
||||
hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
@@ -297,6 +295,7 @@ const RequestButton = ({
|
||||
type: 'or',
|
||||
}) &&
|
||||
media &&
|
||||
media.status !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.BLACKLISTED &&
|
||||
!isShowComplete
|
||||
) {
|
||||
@@ -313,9 +312,7 @@ const RequestButton = ({
|
||||
|
||||
// 4K request button
|
||||
if (
|
||||
(!media ||
|
||||
media.status4k === MediaStatus.UNKNOWN ||
|
||||
(media.status4k === MediaStatus.DELETED && !active4kRequest)) &&
|
||||
(!media || media.status4k === MediaStatus.UNKNOWN) &&
|
||||
hasPermission(
|
||||
[
|
||||
Permission.REQUEST_4K,
|
||||
@@ -344,7 +341,8 @@ const RequestButton = ({
|
||||
type: 'or',
|
||||
}) &&
|
||||
media &&
|
||||
media.status4k !== MediaStatus.BLACKLISTED &&
|
||||
media.status4k !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.BLACKLISTED &&
|
||||
!is4kShowComplete &&
|
||||
settings.currentSettings.series4kEnabled
|
||||
) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
TrashIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
@@ -440,15 +440,6 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
>
|
||||
{intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : requestData.status === MediaRequestStatus.PENDING &&
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.DELETED ? (
|
||||
<Badge
|
||||
badgeType="warning"
|
||||
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
|
||||
>
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
TrashIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
||||
@@ -509,15 +509,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
>
|
||||
{intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : requestData.status === MediaRequestStatus.PENDING &&
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.DELETED ? (
|
||||
<Badge
|
||||
badgeType="warning"
|
||||
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
|
||||
>
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={
|
||||
|
||||
@@ -39,8 +39,6 @@ enum Filter {
|
||||
AVAILABLE = 'available',
|
||||
UNAVAILABLE = 'unavailable',
|
||||
FAILED = 'failed',
|
||||
DELETED = 'deleted',
|
||||
COMPLETED = 'completed',
|
||||
}
|
||||
|
||||
type Sort = 'added' | 'modified';
|
||||
@@ -180,9 +178,6 @@ const RequestList = () => {
|
||||
<option value="approved">
|
||||
{intl.formatMessage(globalMessages.approved)}
|
||||
</option>
|
||||
<option value="completed">
|
||||
{intl.formatMessage(globalMessages.completed)}
|
||||
</option>
|
||||
<option value="processing">
|
||||
{intl.formatMessage(globalMessages.processing)}
|
||||
</option>
|
||||
@@ -195,9 +190,6 @@ const RequestList = () => {
|
||||
<option value="unavailable">
|
||||
{intl.formatMessage(globalMessages.unavailable)}
|
||||
</option>
|
||||
<option value="deleted">
|
||||
{intl.formatMessage(globalMessages.deleted)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||
|
||||
@@ -81,8 +81,7 @@ const CollectionRequestModal = ({
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
)
|
||||
.map((part) => part.id),
|
||||
];
|
||||
@@ -171,9 +170,7 @@ const CollectionRequestModal = ({
|
||||
|
||||
return (part?.mediaInfo?.requests ?? []).find(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
request.is4k === is4k && request.status !== MediaRequestStatus.DECLINED
|
||||
);
|
||||
};
|
||||
|
||||
@@ -371,9 +368,7 @@ const CollectionRequestModal = ({
|
||||
const partMedia =
|
||||
part.mediaInfo &&
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN &&
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.DELETED
|
||||
MediaStatus.UNKNOWN
|
||||
? part.mediaInfo
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -249,8 +249,7 @@ const TvRequestModal = ({
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
)
|
||||
.reduce((requestedSeasons, request) => {
|
||||
return [
|
||||
@@ -315,16 +314,12 @@ const TvRequestModal = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const standardUnrequestedSeasons = unrequestedSeasons.filter(
|
||||
(seasonNumber) => seasonNumber !== 0
|
||||
);
|
||||
|
||||
if (
|
||||
data &&
|
||||
selectedSeasons.length >= 0 &&
|
||||
selectedSeasons.length < standardUnrequestedSeasons.length
|
||||
selectedSeasons.length < unrequestedSeasons.length
|
||||
) {
|
||||
setSelectedSeasons(standardUnrequestedSeasons);
|
||||
setSelectedSeasons(unrequestedSeasons);
|
||||
} else {
|
||||
setSelectedSeasons([]);
|
||||
}
|
||||
@@ -335,9 +330,9 @@ const TvRequestModal = ({
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
selectedSeasons.filter((season) => season !== 0).length ===
|
||||
selectedSeasons.length ===
|
||||
getAllSeasons().filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season) && season !== 0
|
||||
(season) => !getAllRequestedSeasons().includes(season)
|
||||
).length
|
||||
);
|
||||
};
|
||||
@@ -352,8 +347,7 @@ const TvRequestModal = ({
|
||||
(data.mediaInfo.requests || []).filter(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
).length > 0
|
||||
) {
|
||||
data.mediaInfo.requests
|
||||
@@ -361,9 +355,7 @@ const TvRequestModal = ({
|
||||
.forEach((request) => {
|
||||
if (!seasonRequest) {
|
||||
seasonRequest = request.seasons.find(
|
||||
(season) =>
|
||||
season.seasonNumber === seasonNumber &&
|
||||
season.status !== MediaRequestStatus.COMPLETED
|
||||
(season) => season.seasonNumber === seasonNumber
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -585,9 +577,7 @@ const TvRequestModal = ({
|
||||
(sn) =>
|
||||
sn.seasonNumber === season.seasonNumber &&
|
||||
sn[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN &&
|
||||
sn[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.DELETED
|
||||
MediaStatus.UNKNOWN
|
||||
);
|
||||
return (
|
||||
<tr key={`season-${season.id}`}>
|
||||
|
||||
@@ -1,54 +1,40 @@
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ClipboardDocumentIcon } from '@heroicons/react/24/solid';
|
||||
import React, { useEffect } from 'react';
|
||||
import type { Config } from 'react-popper-tooltip';
|
||||
import { useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useClipboard from 'react-use-clipboard';
|
||||
|
||||
type CopyButtonProps = {
|
||||
textToCopy: string;
|
||||
disabled?: boolean;
|
||||
toastMessage?: string;
|
||||
const messages = defineMessages('components.Settings', {
|
||||
copied: 'Copied API key to clipboard.',
|
||||
});
|
||||
|
||||
tooltipContent?: React.ReactNode;
|
||||
tooltipConfig?: Partial<Config>;
|
||||
};
|
||||
|
||||
const CopyButton = ({
|
||||
textToCopy,
|
||||
disabled,
|
||||
toastMessage,
|
||||
tooltipContent,
|
||||
tooltipConfig,
|
||||
}: CopyButtonProps) => {
|
||||
const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
|
||||
const intl = useIntl();
|
||||
const [isCopied, setCopied] = useClipboard(textToCopy, {
|
||||
successDuration: 1000,
|
||||
});
|
||||
const { addToast } = useToasts();
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied && toastMessage) {
|
||||
addToast(toastMessage, {
|
||||
if (isCopied) {
|
||||
addToast(intl.formatMessage(messages.copied), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
}, [isCopied, addToast, toastMessage]);
|
||||
}, [isCopied, addToast, intl]);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent} tooltipConfig={tooltipConfig}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCopied();
|
||||
}}
|
||||
className="input-action"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ClipboardDocumentIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCopied();
|
||||
}}
|
||||
className="input-action"
|
||||
>
|
||||
<ClipboardDocumentIcon />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -68,14 +68,11 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
||||
'download-sync': 'Download Sync',
|
||||
'download-sync-reset': 'Download Sync Reset',
|
||||
'image-cache-cleanup': 'Image Cache Cleanup',
|
||||
'process-blacklisted-tags': 'Process Blacklisted Tags',
|
||||
editJobSchedule: 'Modify Job',
|
||||
jobScheduleEditSaved: 'Job edited successfully!',
|
||||
jobScheduleEditFailed: 'Something went wrong while saving the job.',
|
||||
editJobScheduleCurrent: 'Current Frequency',
|
||||
editJobSchedulePrompt: 'New Frequency',
|
||||
editJobScheduleSelectorDays:
|
||||
'Every {jobScheduleDays, plural, one {day} other {{jobScheduleDays} days}}',
|
||||
editJobScheduleSelectorHours:
|
||||
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
|
||||
editJobScheduleSelectorMinutes:
|
||||
@@ -95,7 +92,7 @@ interface Job {
|
||||
id: JobId;
|
||||
name: string;
|
||||
type: 'process' | 'command';
|
||||
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
|
||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||
cronSchedule: string;
|
||||
nextExecutionTime: string;
|
||||
running: boolean;
|
||||
@@ -104,20 +101,13 @@ interface Job {
|
||||
type JobModalState = {
|
||||
isOpen?: boolean;
|
||||
job?: Job;
|
||||
scheduleDays: number;
|
||||
scheduleHours: number;
|
||||
scheduleMinutes: number;
|
||||
scheduleSeconds: number;
|
||||
};
|
||||
|
||||
type JobModalAction =
|
||||
| {
|
||||
type: 'set';
|
||||
days?: number;
|
||||
hours?: number;
|
||||
minutes?: number;
|
||||
seconds?: number;
|
||||
}
|
||||
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
|
||||
| {
|
||||
type: 'close';
|
||||
}
|
||||
@@ -138,7 +128,6 @@ const jobModalReducer = (
|
||||
return {
|
||||
isOpen: true,
|
||||
job: action.job,
|
||||
scheduleDays: 1,
|
||||
scheduleHours: 1,
|
||||
scheduleMinutes: 5,
|
||||
scheduleSeconds: 30,
|
||||
@@ -147,7 +136,6 @@ const jobModalReducer = (
|
||||
case 'set':
|
||||
return {
|
||||
...state,
|
||||
scheduleDays: action.days ?? state.scheduleDays,
|
||||
scheduleHours: action.hours ?? state.scheduleHours,
|
||||
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
|
||||
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
|
||||
@@ -176,7 +164,6 @@ const SettingsJobs = () => {
|
||||
|
||||
const [jobModalState, dispatch] = useReducer(jobModalReducer, {
|
||||
isOpen: false,
|
||||
scheduleDays: 1,
|
||||
scheduleHours: 1,
|
||||
scheduleMinutes: 5,
|
||||
scheduleSeconds: 30,
|
||||
@@ -252,9 +239,6 @@ const SettingsJobs = () => {
|
||||
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
|
||||
} else if (jobModalState.job?.interval === 'hours') {
|
||||
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
|
||||
} else if (jobModalState.job?.interval === 'days') {
|
||||
jobScheduleCron[2] = '1';
|
||||
jobScheduleCron[3] = `*/${jobModalState.scheduleDays}`;
|
||||
} else {
|
||||
// jobs with interval: fixed should not be editable
|
||||
throw new Error();
|
||||
@@ -383,29 +367,6 @@ const SettingsJobs = () => {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : jobModalState.job?.interval === 'days' ? (
|
||||
<select
|
||||
name="jobScheduleDays"
|
||||
className="inline"
|
||||
value={jobModalState.scheduleDays}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: 'set',
|
||||
days: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 10, 14, 21].map((v) => (
|
||||
<option value={v} key={`jobScheduleDays-${v}`}>
|
||||
{intl.formatMessage(
|
||||
messages.editJobScheduleSelectorDays,
|
||||
{
|
||||
jobScheduleDays: v,
|
||||
}
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
name="jobScheduleHours"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import BlacklistedTagsSelector from '@app/components/BlacklistedTagsSelector';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
@@ -30,19 +29,12 @@ const messages = defineMessages('components.Settings.SettingsMain', {
|
||||
generalsettingsDescription:
|
||||
'Configure global and default settings for Jellyseerr.',
|
||||
apikey: 'API Key',
|
||||
apikeyCopied: 'Copied API key to clipboard.',
|
||||
applicationTitle: 'Application Title',
|
||||
applicationurl: 'Application URL',
|
||||
discoverRegion: 'Discover Region',
|
||||
discoverRegionTip: 'Filter content by regional availability',
|
||||
originallanguage: 'Discover Language',
|
||||
originallanguageTip: 'Filter content by original language',
|
||||
blacklistedTags: 'Blacklist Content with Tags',
|
||||
blacklistedTagsTip:
|
||||
'Automatically add content with tags to the blacklist using the "Process Blacklisted Tags" job',
|
||||
blacklistedTagsLimit: 'Limit Content Blacklisted per Tag',
|
||||
blacklistedTagsLimitTip:
|
||||
'The "Process Blacklisted Tags" job will blacklist this many pages into each sort. Larger numbers will create a more accurate blacklist, but use more space.',
|
||||
streamingRegion: 'Streaming Region',
|
||||
streamingRegionTip: 'Show streaming sites by regional availability',
|
||||
toastApiKeySuccess: 'New API key generated successfully!',
|
||||
@@ -89,17 +81,6 @@ const SettingsMain = () => {
|
||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
blacklistedTagsLimit: Yup.number()
|
||||
.test(
|
||||
'positive',
|
||||
'Number must be greater than 0.',
|
||||
(value) => (value ?? 0) >= 0
|
||||
)
|
||||
.test(
|
||||
'lte-250',
|
||||
'Number must be less than or equal to 250.',
|
||||
(value) => (value ?? 0) <= 250
|
||||
),
|
||||
});
|
||||
|
||||
const regenerate = async () => {
|
||||
@@ -149,8 +130,6 @@ const SettingsMain = () => {
|
||||
discoverRegion: data?.discoverRegion,
|
||||
originalLanguage: data?.originalLanguage,
|
||||
streamingRegion: data?.streamingRegion || 'US',
|
||||
blacklistedTags: data?.blacklistedTags,
|
||||
blacklistedTagsLimit: data?.blacklistedTagsLimit || 50,
|
||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
||||
cacheImages: data?.cacheImages,
|
||||
@@ -167,8 +146,6 @@ const SettingsMain = () => {
|
||||
discoverRegion: values.discoverRegion,
|
||||
streamingRegion: values.streamingRegion,
|
||||
originalLanguage: values.originalLanguage,
|
||||
blacklistedTags: values.blacklistedTags,
|
||||
blacklistedTagsLimit: values.blacklistedTagsLimit,
|
||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
||||
enableSpecialEpisodes: values.enableSpecialEpisodes,
|
||||
cacheImages: values.cacheImages,
|
||||
@@ -224,9 +201,6 @@ const SettingsMain = () => {
|
||||
/>
|
||||
<CopyButton
|
||||
textToCopy={data?.apiKey ?? ''}
|
||||
toastMessage={intl.formatMessage(
|
||||
messages.apikeyCopied
|
||||
)}
|
||||
key={data?.apiKey}
|
||||
/>
|
||||
<button
|
||||
@@ -235,7 +209,6 @@ const SettingsMain = () => {
|
||||
regenerate();
|
||||
}}
|
||||
className="input-action"
|
||||
type="button"
|
||||
>
|
||||
<ArrowPathIcon />
|
||||
</button>
|
||||
@@ -379,49 +352,6 @@ const SettingsMain = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="blacklistedTags" className="text-label">
|
||||
<span>{intl.formatMessage(messages.blacklistedTags)}</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.blacklistedTagsTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field relative z-10">
|
||||
<BlacklistedTagsSelector
|
||||
defaultValue={values.blacklistedTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="blacklistedTagsLimit" className="text-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.blacklistedTagsLimit)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.blacklistedTagsLimitTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
id="blacklistedTagsLimit"
|
||||
name="blacklistedTagsLimit"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="short"
|
||||
placeholder={50}
|
||||
/>
|
||||
{errors.blacklistedTagsLimit &&
|
||||
touched.blacklistedTagsLimit &&
|
||||
typeof errors.blacklistedTagsLimit === 'string' && (
|
||||
<div className="error">
|
||||
{errors.blacklistedTagsLimit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="hideAvailable" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
|
||||
@@ -371,17 +371,6 @@ const StatusBadge = ({
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case MediaStatus.DELETED:
|
||||
return (
|
||||
<Tooltip content={mediaLinkDescription}>
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
|
||||
status: intl.formatMessage(globalMessages.deleted),
|
||||
})}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -467,9 +467,7 @@ const TitleCard = ({
|
||||
<div
|
||||
className={`px-2 text-white ${
|
||||
!showRequestButton ||
|
||||
(currentStatus &&
|
||||
currentStatus !== MediaStatus.UNKNOWN &&
|
||||
currentStatus !== MediaStatus.DELETED)
|
||||
(currentStatus && currentStatus !== MediaStatus.UNKNOWN)
|
||||
? 'pb-2'
|
||||
: 'pb-11'
|
||||
}`}
|
||||
@@ -495,8 +493,7 @@ const TitleCard = ({
|
||||
WebkitLineClamp:
|
||||
!showRequestButton ||
|
||||
(currentStatus &&
|
||||
currentStatus !== MediaStatus.UNKNOWN &&
|
||||
currentStatus !== MediaStatus.DELETED)
|
||||
currentStatus !== MediaStatus.UNKNOWN)
|
||||
? 5
|
||||
: 3,
|
||||
display: '-webkit-box',
|
||||
@@ -513,9 +510,7 @@ const TitleCard = ({
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
|
||||
{showRequestButton &&
|
||||
(!currentStatus ||
|
||||
currentStatus === MediaStatus.UNKNOWN ||
|
||||
currentStatus === MediaStatus.DELETED) && (
|
||||
(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import AirDateBadge from '@app/components/AirDateBadge';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import type { SeasonWithEpisodes } from '@server/models/Tv';
|
||||
import Image from 'next/image';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -57,8 +57,7 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => {
|
||||
</div>
|
||||
{episode.stillPath && (
|
||||
<div className="relative aspect-video xl:h-32">
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
<Image
|
||||
className="rounded-lg object-contain"
|
||||
src={`https://image.tmdb.org/t/p/original/${episode.stillPath}`}
|
||||
alt=""
|
||||
|
||||
@@ -276,8 +276,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED &&
|
||||
request.status !== MediaRequestStatus.COMPLETED
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
)
|
||||
.reduce((requestedSeasons, request) => {
|
||||
return [
|
||||
@@ -810,30 +809,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
season.seasonNumber === s.seasonNumber &&
|
||||
s.status4k !== MediaStatus.UNKNOWN
|
||||
);
|
||||
const request = (data.mediaInfo?.requests ?? [])
|
||||
.filter(
|
||||
(r) =>
|
||||
!!r.seasons.find(
|
||||
(s) => s.seasonNumber === season.seasonNumber
|
||||
) && !r.is4k
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime()
|
||||
)[0];
|
||||
const request4k = (data.mediaInfo?.requests ?? [])
|
||||
.filter(
|
||||
(r) =>
|
||||
!!r.seasons.find(
|
||||
(s) => s.seasonNumber === season.seasonNumber
|
||||
) && r.is4k
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime()
|
||||
)[0];
|
||||
const request = (data.mediaInfo?.requests ?? []).find(
|
||||
(r) =>
|
||||
!!r.seasons.find(
|
||||
(s) => s.seasonNumber === season.seasonNumber
|
||||
) && !r.is4k
|
||||
);
|
||||
const request4k = (data.mediaInfo?.requests ?? []).find(
|
||||
(r) =>
|
||||
!!r.seasons.find(
|
||||
(s) => s.seasonNumber === season.seasonNumber
|
||||
) && r.is4k
|
||||
);
|
||||
|
||||
if (season.episodeCount === 0) {
|
||||
return null;
|
||||
@@ -866,9 +853,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</div>
|
||||
{((!mSeason &&
|
||||
request?.status === MediaRequestStatus.APPROVED) ||
|
||||
mSeason?.status === MediaStatus.PROCESSING ||
|
||||
(request?.status === MediaRequestStatus.APPROVED &&
|
||||
mSeason?.status === MediaStatus.DELETED)) && (
|
||||
mSeason?.status === MediaStatus.PROCESSING) && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="primary">
|
||||
@@ -927,28 +912,10 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mSeason?.status === MediaStatus.DELETED &&
|
||||
request?.status !== MediaRequestStatus.APPROVED && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(globalMessages.deleted)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.DELETED}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{((!mSeason4k &&
|
||||
request4k?.status ===
|
||||
MediaRequestStatus.APPROVED) ||
|
||||
mSeason4k?.status4k === MediaStatus.PROCESSING ||
|
||||
(request4k?.status ===
|
||||
MediaRequestStatus.APPROVED &&
|
||||
mSeason4k?.status4k === MediaStatus.DELETED)) &&
|
||||
mSeason4k?.status4k === MediaStatus.PROCESSING) &&
|
||||
show4k && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
@@ -1031,27 +998,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mSeason4k?.status4k === MediaStatus.DELETED &&
|
||||
request4k?.status !== MediaRequestStatus.APPROVED &&
|
||||
show4k && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(messages.status4k, {
|
||||
status: intl.formatMessage(
|
||||
globalMessages.deleted
|
||||
),
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.DELETED}
|
||||
is4k={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={`${
|
||||
open ? 'rotate-180' : ''
|
||||
|
||||
@@ -3,7 +3,6 @@ import defineMessages from '@app/utils/defineMessages';
|
||||
const globalMessages = defineMessages('i18n', {
|
||||
available: 'Available',
|
||||
partiallyavailable: 'Partially Available',
|
||||
deleted: 'Deleted',
|
||||
processing: 'Processing',
|
||||
unavailable: 'Unavailable',
|
||||
notrequested: 'Not Requested',
|
||||
@@ -15,7 +14,6 @@ const globalMessages = defineMessages('i18n', {
|
||||
pending: 'Pending',
|
||||
declined: 'Declined',
|
||||
approved: 'Approved',
|
||||
completed: 'Completed',
|
||||
movie: 'Movie',
|
||||
movies: 'Movies',
|
||||
collection: 'Collection',
|
||||
|
||||
@@ -10,12 +10,9 @@
|
||||
"components.Blacklist.blacklistdate": "date",
|
||||
"components.Blacklist.blacklistedby": "{date} by {user}",
|
||||
"components.Blacklist.blacklistsettings": "Blacklist Settings",
|
||||
"components.Blacklist.filterBlacklistedTags": "Blacklisted Tags",
|
||||
"components.Blacklist.filterManual": "Manual",
|
||||
"components.Blacklist.mediaName": "Name",
|
||||
"components.Blacklist.mediaTmdbId": "tmdb Id",
|
||||
"components.Blacklist.mediaType": "Type",
|
||||
"components.Blacklist.showAllBlacklisted": "Show All Blacklisted Media",
|
||||
"components.CollectionDetails.numberofmovies": "{count} Movies",
|
||||
"components.CollectionDetails.overview": "Overview",
|
||||
"components.CollectionDetails.requestcollection": "Request Collection",
|
||||
@@ -867,7 +864,6 @@
|
||||
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
|
||||
"components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Current Frequency",
|
||||
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
|
||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorDays": "Every {jobScheduleDays, plural, one {day} other {{jobScheduleDays} days}}",
|
||||
"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.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
|
||||
@@ -894,7 +890,6 @@
|
||||
"components.Settings.SettingsJobsCache.plex-refresh-token": "Plex Refresh Token",
|
||||
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync",
|
||||
"components.Settings.SettingsJobsCache.process": "Process",
|
||||
"components.Settings.SettingsJobsCache.process-blacklisted-tags": "Process Blacklisted Tags",
|
||||
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
|
||||
"components.Settings.SettingsJobsCache.runnow": "Run Now",
|
||||
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
|
||||
@@ -919,13 +914,8 @@
|
||||
"components.Settings.SettingsLogs.time": "Timestamp",
|
||||
"components.Settings.SettingsLogs.viewdetails": "View Details",
|
||||
"components.Settings.SettingsMain.apikey": "API Key",
|
||||
"components.Settings.SettingsMain.apikeyCopied": "Copied API key to clipboard.",
|
||||
"components.Settings.SettingsMain.applicationTitle": "Application Title",
|
||||
"components.Settings.SettingsMain.applicationurl": "Application URL",
|
||||
"components.Settings.SettingsMain.blacklistedTags": "Blacklist Content with Tags",
|
||||
"components.Settings.SettingsMain.blacklistedTagsLimit": "Limit Content Blacklisted per Tag",
|
||||
"components.Settings.SettingsMain.blacklistedTagsLimitTip": "The \"Process Blacklisted Tags\" job will blacklist this many pages into each sort. Larger numbers will create a more accurate blacklist, but use more space.",
|
||||
"components.Settings.SettingsMain.blacklistedTagsTip": "Automatically add content with tags to the blacklist using the \"Process Blacklisted Tags\" job",
|
||||
"components.Settings.SettingsMain.cacheImages": "Enable Image Caching",
|
||||
"components.Settings.SettingsMain.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)",
|
||||
"components.Settings.SettingsMain.discoverRegion": "Discover Region",
|
||||
@@ -948,6 +938,7 @@
|
||||
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
|
||||
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
|
||||
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||
"components.Settings.SettingsNetwork.advancedNetworkSettings": "Advanced Network Settings",
|
||||
"components.Settings.SettingsNetwork.csrfProtection": "Enable CSRF Protection",
|
||||
"components.Settings.SettingsNetwork.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
|
||||
"components.Settings.SettingsNetwork.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
|
||||
@@ -1051,14 +1042,8 @@
|
||||
"components.Settings.addsonarr": "Add Sonarr Server",
|
||||
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
|
||||
"components.Settings.apiKey": "API key",
|
||||
"components.Settings.blacklistedTagImportInstructions": "Paste blacklist tag configuration below.",
|
||||
"components.Settings.blacklistedTagImportTitle": "Import Blacklisted Tag Configuration",
|
||||
"components.Settings.blacklistedTagsText": "Blacklisted Tags",
|
||||
"components.Settings.cancelscan": "Cancel Scan",
|
||||
"components.Settings.clearBlacklistedTagsConfirm": "Are you sure you want to clear the blacklisted tags?",
|
||||
"components.Settings.copyBlacklistedTags": "Copied blacklisted tags to clipboard.",
|
||||
"components.Settings.copyBlacklistedTagsEmpty": "Nothing to copy",
|
||||
"components.Settings.copyBlacklistedTagsTip": "Copy blacklisted tag configuration",
|
||||
"components.Settings.copied": "Copied API key to clipboard.",
|
||||
"components.Settings.currentlibrary": "Current Library: {name}",
|
||||
"components.Settings.default": "Default",
|
||||
"components.Settings.default4k": "Default 4K",
|
||||
@@ -1069,8 +1054,6 @@
|
||||
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
|
||||
"components.Settings.externalUrl": "External URL",
|
||||
"components.Settings.hostname": "Hostname or IP Address",
|
||||
"components.Settings.importBlacklistedTagsTip": "Import blacklisted tag configuration",
|
||||
"components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.",
|
||||
"components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||
"components.Settings.is4k": "4K",
|
||||
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
|
||||
@@ -1102,12 +1085,9 @@
|
||||
"components.Settings.menuPlexSettings": "Plex",
|
||||
"components.Settings.menuServices": "Services",
|
||||
"components.Settings.menuUsers": "Users",
|
||||
"components.Settings.no": "No",
|
||||
"components.Settings.noDefault4kServer": "A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.",
|
||||
"components.Settings.noDefaultNon4kServer": "If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.",
|
||||
"components.Settings.noDefaultServer": "At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.",
|
||||
"components.Settings.noSpecialCharacters": "Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.",
|
||||
"components.Settings.nooptions": "No results.",
|
||||
"components.Settings.notificationAgentSettingsDescription": "Configure and enable notification agents.",
|
||||
"components.Settings.notifications": "Notifications",
|
||||
"components.Settings.notificationsettings": "Notification Settings",
|
||||
@@ -1127,7 +1107,6 @@
|
||||
"components.Settings.scan": "Sync Libraries",
|
||||
"components.Settings.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
|
||||
"components.Settings.scanning": "Syncing…",
|
||||
"components.Settings.searchKeywords": "Search keywords…",
|
||||
"components.Settings.serverLocal": "local",
|
||||
"components.Settings.serverRemote": "remote",
|
||||
"components.Settings.serverSecure": "secure",
|
||||
@@ -1141,7 +1120,6 @@
|
||||
"components.Settings.sonarrsettings": "Sonarr Settings",
|
||||
"components.Settings.ssl": "SSL",
|
||||
"components.Settings.startscan": "Start Scan",
|
||||
"components.Settings.starttyping": "Starting typing to search.",
|
||||
"components.Settings.syncJellyfin": "Sync Libraries",
|
||||
"components.Settings.syncing": "Syncing",
|
||||
"components.Settings.tautulliApiKey": "API Key",
|
||||
@@ -1165,12 +1143,10 @@
|
||||
"components.Settings.validationUrlBaseLeadingSlash": "URL base must have a leading slash",
|
||||
"components.Settings.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash",
|
||||
"components.Settings.validationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||
"components.Settings.valueRequired": "You must provide a value.",
|
||||
"components.Settings.webAppUrl": "<WebAppLink>Web App</WebAppLink> URL",
|
||||
"components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app",
|
||||
"components.Settings.webhook": "Webhook",
|
||||
"components.Settings.webpush": "Web Push",
|
||||
"components.Settings.yes": "Yes",
|
||||
"components.Setup.back": "Go back",
|
||||
"components.Setup.configemby": "Configure Emby",
|
||||
"components.Setup.configjellyfin": "Configure Jellyfin",
|
||||
@@ -1476,11 +1452,9 @@
|
||||
"i18n.canceling": "Canceling…",
|
||||
"i18n.close": "Close",
|
||||
"i18n.collection": "Collection",
|
||||
"i18n.completed": "Completed",
|
||||
"i18n.decline": "Decline",
|
||||
"i18n.declined": "Declined",
|
||||
"i18n.delete": "Delete",
|
||||
"i18n.deleted": "Deleted",
|
||||
"i18n.deleting": "Deleting…",
|
||||
"i18n.delimitedlist": "{a}, {b}",
|
||||
"i18n.edit": "Edit",
|
||||
|
||||
@@ -144,32 +144,18 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
|
||||
clearAppBadge?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const handleBadgeUpdate = () => {
|
||||
if ('setAppBadge' in newNavigator) {
|
||||
if (
|
||||
!router.pathname.match(/(login|setup|resetpassword)/) &&
|
||||
hasPermission(Permission.ADMIN)
|
||||
) {
|
||||
requestsCount().then((data) => {
|
||||
if (data.pending > 0) {
|
||||
newNavigator.setAppBadge?.(data.pending);
|
||||
} else {
|
||||
newNavigator.clearAppBadge?.();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
newNavigator.clearAppBadge?.();
|
||||
}
|
||||
if ('setAppBadge' in navigator) {
|
||||
if (
|
||||
!router.pathname.match(/(login|setup|resetpassword)/) &&
|
||||
hasPermission(Permission.ADMIN)
|
||||
) {
|
||||
requestsCount().then((data) =>
|
||||
newNavigator?.setAppBadge?.(data.pending)
|
||||
);
|
||||
} else {
|
||||
newNavigator?.clearAppBadge?.();
|
||||
}
|
||||
};
|
||||
|
||||
handleBadgeUpdate();
|
||||
|
||||
window.addEventListener('focus', handleBadgeUpdate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleBadgeUpdate);
|
||||
};
|
||||
}
|
||||
}, [hasPermission, router.pathname]);
|
||||
|
||||
if (router.pathname.match(/(login|setup|resetpassword)/)) {
|
||||
|
||||
@@ -342,15 +342,7 @@
|
||||
}
|
||||
|
||||
button.input-action {
|
||||
@apply relative -ml-px inline-flex items-center border border-gray-500 bg-indigo-600 bg-opacity-80 px-3 py-2 text-sm font-medium leading-5 text-white last:rounded-r-md sm:px-3.5;
|
||||
}
|
||||
|
||||
button.input-action[disabled] {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
button.input-action:not([disabled]) {
|
||||
@apply transition duration-150 ease-in-out hover:bg-opacity-100 active:bg-gray-100 active:text-gray-700;
|
||||
@apply relative -ml-px inline-flex items-center border border-gray-500 bg-indigo-600 bg-opacity-80 px-3 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out last:rounded-r-md hover:bg-opacity-100 active:bg-gray-100 active:text-gray-700 sm:px-3.5;
|
||||
}
|
||||
|
||||
.button-md :where(svg),
|
||||
@@ -390,10 +382,6 @@
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.react-select-container:has(+ .input-action) .react-select__control {
|
||||
@apply rounded-r-none border border-gray-500 bg-gray-700 text-white hover:border-gray-500;
|
||||
}
|
||||
|
||||
.react-select-container .react-select__control {
|
||||
@apply rounded-md border border-gray-500 bg-gray-700 text-white hover:border-gray-500;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user