diff --git a/.all-contributorsrc b/.all-contributorsrc index 3614dbd1..d0dda95f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -448,6 +448,114 @@ "contributions": [ "security" ] + }, + { + "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": "Zariel", + "name": "Chris Bannister", + "avatar_url": "https://avatars.githubusercontent.com/u/2213?v=4", + "profile": "https://github.com/Zariel", + "contributions": [ + "code" + ] + }, + { + "login": "C4J3", + "name": "Joe", + "avatar_url": "https://avatars.githubusercontent.com/u/13005453?v=4", + "profile": "https://github.com/C4J3", + "contributions": [ + "doc" + ] + }, + { + "login": "guillaumearnx", + "name": "Guillaume ARNOUX", + "avatar_url": "https://avatars.githubusercontent.com/u/37373941?v=4", + "profile": "https://me.garnx.fr", + "contributions": [ + "code" + ] + }, + { + "login": "dr-carrot", + "name": "dr-carrot", + "avatar_url": "https://avatars.githubusercontent.com/u/17272571?v=4", + "profile": "https://github.com/dr-carrot", + "contributions": [ + "code" + ] + }, + { + "login": "gageorsburn", + "name": "Gage Orsburn", + "avatar_url": "https://avatars.githubusercontent.com/u/4692734?v=4", + "profile": "https://github.com/gageorsburn", + "contributions": [ + "code" + ] } ] } diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 3f760018..e1268709 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -55,6 +55,14 @@ body: - tablet validations: required: true + - type: dropdown + id: database + attributes: + options: + - SQLite (default) + - PostgreSQL + label: Database + description: Which database backend are you using? - type: input id: device attributes: diff --git a/.github/workflows/lint-helm-charts.yml b/.github/workflows/lint-helm-charts.yml new file mode 100644 index 00000000..e46a11ee --- /dev/null +++ b/.github/workflows/lint-helm-charts.yml @@ -0,0 +1,33 @@ +name: Lint and Test Charts + +on: + pull_request: + branches: + - develop + paths: + - '.github/workflows/lint-helm-charts.yml' + - 'charts/**' +jobs: + lint-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Helm + uses: azure/setup-helm@v4.2.0 + - name: Ensure documentation is updated + uses: docker://jnorwood/helm-docs:v1.14.2 + - name: Set up chart-testing + uses: helm/chart-testing-action@v2.6.1 + - name: Run chart-testing (list-changed) + id: list-changed + run: | + changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }}) + if [[ -n "$changed" ]]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + - name: Run chart-testing + if: steps.list-changed.outputs.changed == 'true' + run: ct lint --target-branch ${{ github.event.repository.default_branch }} --validate-maintainers=false diff --git a/.github/workflows/test-docs-deploy.yml b/.github/workflows/test-docs-deploy.yml index bba30545..5526af09 100644 --- a/.github/workflows/test-docs-deploy.yml +++ b/.github/workflows/test-docs-deploy.yml @@ -4,7 +4,7 @@ on: pull_request: branches: - develop - path: + paths: - 'docs/**' - 'gen-docs/**' diff --git a/.prettierignore b/.prettierignore index e7f72ab6..c2e778c1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,6 @@ pnpm-lock.yaml src/assets/ public/ docs/ + +# helm charts +**/charts diff --git a/.prettierrc.js b/.prettierrc.js index 1de1f8bf..10da08eb 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -15,5 +15,11 @@ module.exports = { rangeEnd: 0, // default: Infinity }, }, + { + files: 'charts/**', + options: { + rangeEnd: 0, // default: Infinity + }, + }, ], }; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e76b2c14..ee7cf029 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,46 @@ We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-f Translation status +## Migrations + +If you are adding a new feature that requires a database migration, you will need to create 2 migrations: one for SQLite and one for PostgreSQL. Here is how you could do it: + +1. Create a PostgreSQL database or use an existing one: + +```bash +sudo docker run --name postgres-jellyseerr -e POSTGRES_PASSWORD=postgres -d -p 127.0.0.1:5432:5432/tcp postgres:latest +``` + +2. Reset the SQLite database and the PostgreSQL database: + +```bash +rm config/db/db.* +rm config/settings.* +PGPASSWORD=postgres sudo docker exec -it postgres-jellyseerr /usr/bin/psql -h 127.0.0.1 -U postgres -c "DROP DATABASE IF EXISTS jellyseerr;" +PGPASSWORD=postgres sudo docker exec -it postgres-jellyseerr /usr/bin/psql -h 127.0.0.1 -U postgres -c "CREATE DATABASE jellyseerr;" +``` + +3. Checkout the `develop` branch and create the original database for SQLite and PostgreSQL so that TypeORM can automatically generate the migrations: + +```bash +git checkout develop +pnpm i +rm -r .next dist; pnpm build +pnpm start +DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm start +``` + +(You can shutdown the server once the message "Server ready on 5055" appears) + +4. Let TypeORM generate the migrations: + +```bash +git checkout -b your-feature-branch +pnpm i +pnpm migration:generate server/migration/sqlite/YourMigrationName +DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm migration:generate server/migration/postgres/YourMigrationName +``` + ## Attribution This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Overseerr](https://github.com/sct/Overseerr) contribution guides. diff --git a/README.md b/README.md index fb6c8790..968930bf 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors **Jellyseerr** is a free and open source software application for managing requests for your media library. @@ -147,6 +147,22 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Francisco Sales
Francisco Sales

💻 Oliver Laing
Oliver Laing

💻 Ludovic Ortega
Ludovic Ortega

🛡️ + Joseph Risk
Joseph Risk

💻 + + + Loetwiek
Loetwiek

💻 + Fuochi
Fuochi

📖 + David Emrich
David Emrich

💻 + Max T. Kristiansen
Max T. Kristiansen

💻 + Damien Fajole
Damien Fajole

💻 + Ahmed Siddiqui
Ahmed Siddiqui

💻 + Chris Bannister
Chris Bannister

💻 + + + Joe
Joe

📖 + Guillaume ARNOUX
Guillaume ARNOUX

💻 + dr-carrot
dr-carrot

💻 + Gage Orsburn
Gage Orsburn

💻 @@ -291,6 +307,12 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Joseph Risk
Joseph Risk

💻 Loetwiek
Loetwiek

💻 Fuochi
Fuochi

📖 + David Emrich
David Emrich

💻 + Max T. Kristiansen
Max T. Kristiansen

💻 + Damien Fajole
Damien Fajole

💻 + + + Ahmed Siddiqui
Ahmed Siddiqui

💻 diff --git a/charts/jellyseerr/.helmignore b/charts/jellyseerr/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/charts/jellyseerr/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/jellyseerr/Chart.yaml b/charts/jellyseerr/Chart.yaml new file mode 100644 index 00000000..c06600e5 --- /dev/null +++ b/charts/jellyseerr/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +kubeVersion: ">=1.23.0-0" +name: Jellyseerr +description: Jellyseerr helm chart for Kubernetes +type: application +version: 1.1.0 +appVersion: "2.1.0" +maintainers: + - name: Jellyseerr + url: https://github.com/Fallenbagel/jellyseerr +sources: + - https://github.com/Fallenbagel/jellyseerr/tree/main/charts/jellyseerr +home: https://github.com/Fallenbagel/jellyseerr diff --git a/charts/jellyseerr/README.md b/charts/jellyseerr/README.md new file mode 100644 index 00000000..45d77c90 --- /dev/null +++ b/charts/jellyseerr/README.md @@ -0,0 +1,69 @@ +# Jellyseerr + +![Version: 1.1.0](https://img.shields.io/badge/Version-1.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.1.0](https://img.shields.io/badge/AppVersion-2.1.0-informational?style=flat-square) + +Jellyseerr helm chart for Kubernetes + +**Homepage:** + +## Maintainers + +| Name | Email | Url | +| ---- | ------ | --- | +| Jellyseerr | | | + +## Source Code + +* + +## Requirements + +Kubernetes: `>=1.23.0-0` + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | | +| autoscaling.enabled | bool | `false` | | +| autoscaling.maxReplicas | int | `100` | | +| autoscaling.minReplicas | int | `1` | | +| autoscaling.targetCPUUtilizationPercentage | int | `80` | | +| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration | +| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk | +| config.persistence.annotations | object | `{}` | Annotations for PVCs | +| config.persistence.name | string | `""` | Config name | +| config.persistence.size | string | `"5Gi"` | Size of persistent disk | +| config.persistence.volumeName | string | `""` | Name of the permanent volume to reference in the claim. Can be used to bind to existing volumes. | +| extraEnv | list | `[]` | Environment variables to add to the jellyseerr pods | +| extraEnvFrom | list | `[]` | Environment variables from secrets or configmaps to add to the jellyseerr pods | +| fullnameOverride | string | `""` | | +| image.pullPolicy | string | `"IfNotPresent"` | | +| image.registry | string | `"docker.io"` | | +| image.repository | string | `"fallenbagel/jellyseerr"` | | +| image.sha | string | `""` | | +| image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | +| imagePullSecrets | list | `[]` | | +| ingress.annotations | object | `{}` | | +| ingress.enabled | bool | `false` | | +| ingress.hosts[0].host | string | `"chart-example.local"` | | +| ingress.hosts[0].paths[0].path | string | `"/"` | | +| ingress.hosts[0].paths[0].pathType | string | `"ImplementationSpecific"` | | +| ingress.ingressClassName | string | `""` | | +| ingress.tls | list | `[]` | | +| nameOverride | string | `""` | | +| nodeSelector | object | `{}` | | +| podAnnotations | object | `{}` | | +| podLabels | object | `{}` | | +| podSecurityContext | object | `{}` | | +| replicaCount | int | `1` | | +| resources | object | `{}` | | +| securityContext | object | `{}` | | +| service.port | int | `80` | | +| service.type | string | `"ClusterIP"` | | +| serviceAccount.annotations | object | `{}` | Annotations to add to the service account | +| serviceAccount.automount | bool | `true` | Automatically mount a ServiceAccount's API credentials? | +| serviceAccount.create | bool | `true` | Specifies whether a service account should be created | +| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template | +| strategy | object | `{"type":"Recreate"}` | Deployment strategy | +| tolerations | list | `[]` | | diff --git a/charts/jellyseerr/README.md.gotmpl b/charts/jellyseerr/README.md.gotmpl new file mode 100644 index 00000000..c58fe7d5 --- /dev/null +++ b/charts/jellyseerr/README.md.gotmpl @@ -0,0 +1,17 @@ +{{ template "chart.header" . }} + +{{ template "chart.deprecationWarning" . }} + +{{ template "chart.badgesSection" . }} + +{{ template "chart.description" . }} + +{{ template "chart.homepageLine" . }} + +{{ template "chart.maintainersSection" . }} + +{{ template "chart.sourcesSection" . }} + +{{ template "chart.requirementsSection" . }} + +{{ template "chart.valuesSection" . }} diff --git a/charts/jellyseerr/templates/NOTES.txt b/charts/jellyseerr/templates/NOTES.txt new file mode 100644 index 00000000..aa8a44b6 --- /dev/null +++ b/charts/jellyseerr/templates/NOTES.txt @@ -0,0 +1,5 @@ +*********************************************************************** + Welcome to {{ .Chart.Name }} + Chart version: {{ .Chart.Version }} + App version: {{ .Chart.AppVersion }} +*********************************************************************** \ No newline at end of file diff --git a/charts/jellyseerr/templates/_helpers.tpl b/charts/jellyseerr/templates/_helpers.tpl new file mode 100644 index 00000000..bb4b4ef6 --- /dev/null +++ b/charts/jellyseerr/templates/_helpers.tpl @@ -0,0 +1,70 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "jellyseerr.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "jellyseerr.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "jellyseerr.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "jellyseerr.labels" -}} +helm.sh/chart: {{ include "jellyseerr.chart" . }} +{{ include "jellyseerr.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/part-of: {{ .Chart.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "jellyseerr.selectorLabels" -}} +app.kubernetes.io/name: {{ include "jellyseerr.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "jellyseerr.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "jellyseerr.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the name of the pvc config to use +*/}} +{{- define "jellyseerr.configPersistenceName" -}} +{{- default (printf "%s-config" (include "jellyseerr.fullname" .)) .Values.config.persistence.name }} +{{- end }} \ No newline at end of file diff --git a/charts/jellyseerr/templates/deployment.yaml b/charts/jellyseerr/templates/deployment.yaml new file mode 100644 index 00000000..8c9c57a1 --- /dev/null +++ b/charts/jellyseerr/templates/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "jellyseerr.fullname" . }} + labels: + {{- include "jellyseerr.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + strategy: + type: {{ .Values.strategy.type }} + selector: + matchLabels: + {{- include "jellyseerr.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "jellyseerr.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "jellyseerr.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + {{- if .Values.image.sha }} + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}@sha256:{{ .Values.image.sha }}" + {{- else }} + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + {{- end }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 5055 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.extraEnv }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.extraEnvFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /app/config + volumes: + - name: config + persistentVolumeClaim: + claimName: {{ include "jellyseerr.configPersistenceName" . }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/jellyseerr/templates/hpa.yaml b/charts/jellyseerr/templates/hpa.yaml new file mode 100644 index 00000000..291f83de --- /dev/null +++ b/charts/jellyseerr/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "jellyseerr.fullname" . }} + labels: + {{- include "jellyseerr.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "jellyseerr.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/charts/jellyseerr/templates/ingress.yaml b/charts/jellyseerr/templates/ingress.yaml new file mode 100644 index 00000000..85f1125a --- /dev/null +++ b/charts/jellyseerr/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "jellyseerr.fullname" . }} + labels: + {{- include "jellyseerr.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.ingressClassName }} + ingressClassName: {{ .Values.ingress.ingressClassName }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "jellyseerr.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/jellyseerr/templates/persistentvolumeclaim.yaml b/charts/jellyseerr/templates/persistentvolumeclaim.yaml new file mode 100644 index 00000000..bf0d6422 --- /dev/null +++ b/charts/jellyseerr/templates/persistentvolumeclaim.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "jellyseerr.configPersistenceName" . }} + labels: + {{- include "jellyseerr.labels" . | nindent 4 }} +spec: + {{- with .Values.config.persistence.accessModes }} + accessModes: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if .Values.config.persistence.volumeName }} + volumeName: {{ .Values.config.persistence.volumeName }} + {{- end }} + {{- with .Values.config.persistence.storageClass }} + storageClassName: {{ if (eq "-" .) }}""{{ else }}{{ . }}{{ end }} + {{- end }} + resources: + requests: + storage: "{{ .Values.config.persistence.size }}" \ No newline at end of file diff --git a/charts/jellyseerr/templates/service.yaml b/charts/jellyseerr/templates/service.yaml new file mode 100644 index 00000000..5c915e3b --- /dev/null +++ b/charts/jellyseerr/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "jellyseerr.fullname" . }} + labels: + {{- include "jellyseerr.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "jellyseerr.selectorLabels" . | nindent 4 }} + ipFamilyPolicy: PreferDualStack diff --git a/charts/jellyseerr/templates/serviceaccount.yaml b/charts/jellyseerr/templates/serviceaccount.yaml new file mode 100644 index 00000000..6a2dcfd0 --- /dev/null +++ b/charts/jellyseerr/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "jellyseerr.serviceAccountName" . }} + labels: + {{- include "jellyseerr.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/jellyseerr/templates/tests/test-connection.yaml b/charts/jellyseerr/templates/tests/test-connection.yaml new file mode 100644 index 00000000..6adc5d30 --- /dev/null +++ b/charts/jellyseerr/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "jellyseerr.fullname" . }}-test-connection" + labels: + {{- include "jellyseerr.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "jellyseerr.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/charts/jellyseerr/values.yaml b/charts/jellyseerr/values.yaml new file mode 100644 index 00000000..50c7865f --- /dev/null +++ b/charts/jellyseerr/values.yaml @@ -0,0 +1,108 @@ +replicaCount: 1 + +image: + registry: docker.io + repository: fallenbagel/jellyseerr + pullPolicy: IfNotPresent + # -- Overrides the image tag whose default is the chart appVersion. + tag: "" + sha: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# -- Deployment strategy +strategy: + type: Recreate + +# -- Environment variables to add to the jellyseerr pods +extraEnv: [] +# -- Environment variables from secrets or configmaps to add to the jellyseerr pods +extraEnvFrom: [] + +serviceAccount: + # -- Specifies whether a service account should be created + create: true + # -- Automatically mount a ServiceAccount's API credentials? + automount: true + # -- Annotations to add to the service account + annotations: {} + # -- The name of the service account to use. + # -- If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +# -- Creating PVC to store configuration +config: + persistence: + # -- Size of persistent disk + size: 5Gi + # -- Annotations for PVCs + annotations: {} + # -- Access modes of persistent disk + accessModes: + - ReadWriteOnce + # -- Config name + name: "" + # -- Name of the permanent volume to reference in the claim. + # Can be used to bind to existing volumes. + volumeName: "" + +ingress: + enabled: false + ingressClassName: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - 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 + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index 45e38a29..e3d31cc1 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -16,11 +16,13 @@ "hideAvailable": false, "localLogin": true, "newPlexLogin": true, - "region": "", + "discoverRegion": "", + "streamingRegion": "", "originalLanguage": "", "trustProxy": false, "mediaServerType": 1, "partialRequestsEnabled": true, + "enableSpecialEpisodes": false, "locale": "en" }, "plex": { @@ -75,6 +77,7 @@ "types": 0, "options": { "webhookUrl": "", + "webhookRoleId": "", "enableMentions": true } }, @@ -98,6 +101,7 @@ "options": { "botAPI": "", "chatId": "", + "messageThreadId": "", "sendSilently": false } }, diff --git a/docker-compose.postgres.yaml b/docker-compose.postgres.yaml new file mode 100644 index 00000000..2ed270f3 --- /dev/null +++ b/docker-compose.postgres.yaml @@ -0,0 +1,38 @@ +--- +version: '3.8' +services: + jellyseerr: + build: + context: . + dockerfile: Dockerfile.local + ports: + - '5055:5055' + environment: + DB_TYPE: 'postgres' # Which DB engine to use. The default is "sqlite". To use postgres, this needs to be set to "postgres" + DB_HOST: 'postgres' # The host (url) of the database + DB_PORT: '5432' # The port to connect to + DB_USER: 'jellyseerr' # Username used to connect to the database + DB_PASS: 'jellyseerr' # Password of the user used to connect to the database + DB_NAME: 'jellyseerr' # The name of the database to connect to + DB_LOG_QUERIES: 'false' # Whether to log the DB queries for debugging + DB_USE_SSL: 'false' # Whether to enable ssl for database connection + volumes: + - .:/app:rw,cached + - /app/node_modules + - /app/.next + depends_on: + - postgres + links: + - postgres + postgres: + image: postgres + environment: + POSTGRES_USER: jellyseerr + POSTGRES_PASSWORD: jellyseerr + POSTGRES_DB: jellyseerr + ports: + - '5432:5432' + volumes: + - postgres:/var/lib/postgresql/data +volumes: + postgres: diff --git a/docs/README.md b/docs/README.md index a441434d..0d0129c1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Welcome to the Jellyseerr Documentation. - **Mobile-friendly design**, for when you need to approve requests on the go. - Granular permission system. - Localization into other languages. +- Support for PostgreSQL and SQLite databases. - More features to come! ## Motivation diff --git a/docs/extending-jellyseerr/database-config.mdx b/docs/extending-jellyseerr/database-config.mdx new file mode 100644 index 00000000..f7ad3165 --- /dev/null +++ b/docs/extending-jellyseerr/database-config.mdx @@ -0,0 +1,60 @@ +--- +title: Configuring the Database (Advanced) +description: Configure the database for Jellyseerr +sidebar_position: 2 +--- +# Configuring the Database + +:::important +Postgres is not supported on **latest** yet. (It is currently only available in **develop**) +::: + +Jellyseerr supports SQLite and PostgreSQL. The database connection can be configured using the following environment variables: + +## SQLite Options + +```dotenv +DB_TYPE="sqlite" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite". +CONFIG_DIRECTORY="config" # (optional) The path to the config directory where the db file is stored. The default is "config". +DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false". +``` + +## PostgreSQL Options + +```dotenv +DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite". To use postgres, this needs to be set to "postgres" +DB_HOST="localhost" # (optional) The host (url) of the database. The default is "localhost". +DB_PORT="5432" # (optional) The port to connect to. The default is "5432". +DB_USER= # (required) Username used to connect to the database +DB_PASS= # (required) Password of the user used to connect to the database +DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr". +DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false". +``` + +### SSL configuration +The following options can be used to further configure ssl. Certificates can be provided as a string or a file path, with the string version taking precedence. + +```dotenv +DB_USE_SSL="false" # (optional) Whether to enable ssl for database connection. This must be "true" to use the other ssl options. The default is "false". +DB_SSL_REJECT_UNAUTHORIZED="true" # (optional) Whether to reject ssl connections with unverifiable certificates i.e. self-signed certificates without providing the below settings. The default is "true". +DB_SSL_CA= # (optional) The CA certificate to verify the connection, provided as a string. The default is "". +DB_SSL_CA_FILE= # (optional) The path to a CA certificate to verify the connection. The default is "". +DB_SSL_KEY= # (optional) The private key for the connection in PEM format, provided as a string. The default is "". +DB_SSL_KEY_FILE= # (optinal) Path to the private key for the connection in PEM format. The default is "". +DB_SSL_CERT= # (optional) Certificate chain in pem format for the private key, provided as a string. The default is "". +DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the private key. The default is "". +``` + +### Migrating from SQLite to PostgreSQL + +1. Set up your PostgreSQL database and configure Jellyseerr to use it +2. Run Jellyseerr to create the tables in the PostgreSQL database +3. Stop Jellyseerr +4. Run the following command to export the data from the SQLite database and import it into the PostgreSQL database: +- Edit the postgres connection string to match your setup +- WARNING: The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue. +- "I don't have or don't want to use docker" - You can build the working pgloader version [in this PR](https://github.com/dimitri/pgloader/pull/1531) from source and use the same options as below. +```bash +docker run --rm -v config/db.sqlite3:/db.sqlite3:ro -v pgloader/pgloader.load:/pgloader.load ghcr.io/ralgar/pgloader:pr-1531 pgloader --with "quote identifiers" --with "data only" /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}} + ``` +5. Start Jellyseerr diff --git a/docs/extending-jellyseerr/reverse-proxy.mdx b/docs/extending-jellyseerr/reverse-proxy.mdx index c78ae915..505389ac 100644 --- a/docs/extending-jellyseerr/reverse-proxy.mdx +++ b/docs/extending-jellyseerr/reverse-proxy.mdx @@ -95,6 +95,8 @@ location ^~ /jellyseerr { sub_filter '/api/v1' '/$app/api/v1'; sub_filter '/login/plex/loading' '/$app/login/plex/loading'; sub_filter '/images/' '/$app/images/'; + sub_filter '/imageproxy/' '/$app/imageproxy/'; + sub_filter '/avatarproxy/' '/$app/avatarproxy/'; sub_filter '/android-' '/$app/android-'; sub_filter '/apple-' '/$app/apple-'; sub_filter '/favicon' '/$app/favicon'; diff --git a/docs/getting-started/buildfromsource.mdx b/docs/getting-started/buildfromsource.mdx index c22ff23a..f1de1a6d 100644 --- a/docs/getting-started/buildfromsource.mdx +++ b/docs/getting-started/buildfromsource.mdx @@ -26,7 +26,7 @@ sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr ```bash git clone https://github.com/Fallenbagel/jellyseerr.git cd jellyseerr -git checkout develop # by default, you are on the develop branch so this step is not necessary +git checkout main ``` 3. Install the dependencies: ```bash @@ -58,9 +58,6 @@ PORT=5055 ## specify on which interface to listen, by default jellyseerr listens on all interfaces #HOST=127.0.0.1 -## Uncomment if your media server is emby instead of jellyfin. -# JELLYFIN_TYPE=emby - ## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only) # FORCE_IPV4_FIRST=true ``` @@ -203,7 +200,7 @@ cd C:\jellyseerr 2. Clone the Jellyseerr repository and checkout the develop branch: ```powershell git clone https://github.com/Fallenbagel/jellyseerr.git . -git checkout develop # by default, you are on the develop branch so this step is not necessary +git checkout main ``` 3. Install the dependencies: ```powershell diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx new file mode 100644 index 00000000..2f3fed66 --- /dev/null +++ b/docs/troubleshooting.mdx @@ -0,0 +1,158 @@ +--- +title: Troubleshooting +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## [TMDB] failed to retrieve/fetch XXX + +### Option 1: Change your DNS servers + +This error often comes from your Internet Service Provider (ISP) blocking TMDB API. The ISP may block the DNS resolution to the TMDB API hostname. + +To fix this, you can change your DNS servers to a public DNS service like Google's DNS or Cloudflare's DNS: + + + + +Add the following to your `docker run` command to use Google's DNS: +```bash +--dns=8.8.8.8 +``` +or for Cloudflare's DNS: +```bash +--dns=1.1.1.1 +``` + + + + + +Add the following to your `compose.yaml` to use Google's DNS: +```yaml +--- +services: + jellyseerr: + dns: + - 8.8.8.8 +``` +or for Cloudflare's DNS: +```yaml +--- +services: + jellyseerr: + dns: + - 1.1.1.1 +``` + + + + + +1. Open the Control Panel. +2. Click on Network and Internet. +3. Click on Network and Sharing Center. +4. Click on Change adapter settings. +5. Right-click the network interface connected to the internet and select Properties. +6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties. +7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS. + + + + + +1. Open a terminal. +2. Edit the `/etc/resolv.conf` file with your favorite text editor. +3. Add the following line to use Google's DNS: + ```bash + nameserver 8.8.8.8 + ``` + or for Cloudflare's DNS: + + ```bash + nameserver 1.1.1.1 + ``` + + + + +### Option 2: Force IPV4 resolution first + +Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly. + +You can try to force the resolution to use IPV4 first by setting the `FORCE_IPV4_FIRST` environment variable to `true`: + + + + +Add the following to your `docker run` command: +```bash +-e "FORCE_IPV4_FIRST=true" +``` + + + + + +Add the following to your `compose.yaml`: +```yaml +--- +services: + jellyseerr: + environment: + - FORCE_IPV4_FIRST=true +``` + + + + +### Option 3: Use Jellyseerr through a proxy + +If you can't change your DNS servers or force IPV4 resolution, you can use Jellyseerr through a proxy. + +In some places (like China), the ISP blocks not only the DNS resolution but also the connection to the TMDB API. + +You can configure Jellyseerr to use a proxy with the [HTTP(S) Proxy](/using-jellyseerr/settings/general#https-proxy) setting. + +### Option 4: Check that your server can reach TMDB API + +Make sure that your server can reach the TMDB API by running the following command: + + + + +```bash +docker exec -it jellyseerr sh -c "apk update && apk add curl && curl -L https://api.themoviedb.org" +``` + + + + + +```bash +docker compose exec jellyseerr sh -c "apk update && apk add curl && curl -L https://api.themoviedb.org" +``` + + + + +In a terminal: +```bash +curl -L https://api.themoviedb.org +``` + + + + +In a PowerShell window: +```powershell +(Invoke-WebRequest -Uri "https://api.themoviedb.org" -Method Get).Content +``` + + + + + +If you can't get a response, then your server can't reach the TMDB API. +This is usually due to a network configuration issue or a firewall blocking the connection. diff --git a/docs/using-jellyseerr/notifications/discord.md b/docs/using-jellyseerr/notifications/discord.md index 016de30e..b39e283a 100644 --- a/docs/using-jellyseerr/notifications/discord.md +++ b/docs/using-jellyseerr/notifications/discord.md @@ -18,6 +18,10 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**. +### Notification Role ID (optional) + +If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID). + ### Bot Username (optional) If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like! diff --git a/docs/using-jellyseerr/settings/general.md b/docs/using-jellyseerr/settings/general.md index 61991ed0..08601d2a 100644 --- a/docs/using-jellyseerr/settings/general.md +++ b/docs/using-jellyseerr/settings/general.md @@ -58,9 +58,9 @@ You should enable this if you are having issues with loading images directly fro Set the default display language for Jellyseerr. Users can override this setting in their user settings. -## Discover Region & Discover Language +## Discover Region, Discover Language & Streaming Region -These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. Users can override these global settings by configuring these same options in their user settings. +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. ## Hide Available Media diff --git a/docs/using-jellyseerr/users/editing-users.md b/docs/using-jellyseerr/users/editing-users.md index fb2b80ab..8a04c3ad 100644 --- a/docs/using-jellyseerr/users/editing-users.md +++ b/docs/using-jellyseerr/users/editing-users.md @@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene ### Discover Region & Discover Language -Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region--discover-language) to suit their own preferences. +Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-discover-language--streaming-region) to suit their own preferences. ### Movie Request Limit & Series Request Limit diff --git a/next.config.js b/next.config.js index 43aa421d..7bb57730 100644 --- a/next.config.js +++ b/next.config.js @@ -10,6 +10,7 @@ module.exports = { remotePatterns: [ { hostname: 'gravatar.com' }, { hostname: 'image.tmdb.org' }, + { hostname: 'artworks.thetvdb.com' }, ], }, webpack(config) { diff --git a/overseerr-api.yml b/overseerr-api.yml index 9e2505f4..06a7523d 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -143,10 +143,12 @@ components: properties: locale: type: string - region: + discoverRegion: type: string originalLanguage: type: string + streamingRegion: + type: string MainSettings: type: object properties: @@ -186,6 +188,9 @@ components: defaultPermissions: type: number example: 32 + enableSpecialEpisodes: + type: boolean + example: false PlexLibrary: type: object properties: @@ -1273,6 +1278,8 @@ components: type: string webhookUrl: type: string + webhookRoleId: + type: string enableMentions: type: boolean SlackSettings: @@ -1334,6 +1341,8 @@ components: type: string chatId: type: string + messageThreadId: + type: string sendSilently: type: boolean PushbulletSettings: @@ -1817,6 +1826,9 @@ components: telegramChatId: type: string nullable: true + telegramMessageThreadId: + type: string + nullable: true telegramSendSilently: type: boolean nullable: true @@ -1930,6 +1942,11 @@ components: type: string native_name: type: string + OverrideRule: + type: object + properties: + id: + type: string securitySchemes: cookieAuth: type: apiKey @@ -3753,6 +3770,11 @@ paths: type: string enum: [created, updated, requests, displayname] default: created + - in: query + name: q + required: false + schema: + type: string responses: '200': description: A JSON array of all users @@ -3869,7 +3891,7 @@ paths: schema: type: object properties: - jellyfinIds: + jellyfinUserIds: type: array items: type: string @@ -5434,6 +5456,13 @@ paths: type: string enum: [added, modified] default: added + - in: query + name: sortDirection + schema: + type: string + enum: [asc, desc] + nullable: true + default: desc - in: query name: requestedBy schema: @@ -5484,7 +5513,7 @@ paths: - type: array items: type: number - minimum: 1 + minimum: 0 - type: string enum: [all] is4k: @@ -5590,7 +5619,7 @@ paths: type: array items: type: number - minimum: 1 + minimum: 0 is4k: type: boolean example: false @@ -6954,6 +6983,68 @@ paths: type: array items: $ref: '#/components/schemas/WatchProviderDetails' + /overrideRule: + get: + summary: Get override rules + description: Returns a list of all override rules with their conditions and settings + tags: + - overriderule + responses: + '200': + description: Override rules returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + post: + summary: Create override rule + description: Creates a new Override Rule from the request body. + tags: + - overriderule + responses: + '200': + description: 'Values were successfully created' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + /overrideRule/{ruleId}: + put: + summary: Update override rule + description: Updates an Override Rule from the request body. + tags: + - overriderule + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + delete: + summary: Delete override rule by ID + description: Deletes the override rule with the provided ruleId. + tags: + - overriderule + parameters: + - in: path + name: ruleId + required: true + schema: + type: number + responses: + '200': + description: Override rule successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/OverrideRule' security: - cookieAuth: [] - apiKey: [] diff --git a/package.json b/package.json index 7c9b3c90..a7bab892 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "node-schedule": "2.1.1", "nodemailer": "6.9.1", "openpgp": "5.7.0", + "pg": "8.11.0", "plex-api": "5.3.2", "pug": "3.0.2", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b68e8b5..016a4b2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 2.11.0 connect-typeorm: specifier: 1.1.4 - version: 1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))) + version: 1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))) cookie-parser: specifier: 1.4.6 version: 1.4.6 @@ -119,6 +119,9 @@ importers: openpgp: specifier: 5.7.0 version: 5.7.0 + pg: + specifier: 8.11.0 + version: 8.11.0 plex-api: specifier: 5.3.2 version: 5.3.2 @@ -193,7 +196,7 @@ importers: version: 2.2.5(react@18.3.1) typeorm: specifier: 0.3.11 - version: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)) + version: 0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)) undici: specifier: ^6.20.1 version: 6.20.1 @@ -3530,6 +3533,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-writer@2.0.0: + resolution: {integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==} + engines: {node: '>=4'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -7050,6 +7057,9 @@ packages: resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==} engines: {node: '>=8'} + packet-reader@1.0.0: + resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7141,6 +7151,40 @@ packages: performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-cloudflare@1.1.1: + resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} + + pg-connection-string@2.7.0: + resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.7.0: + resolution: {integrity: sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.7.0: + resolution: {integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.11.0: + resolution: {integrity: sha512-meLUVPn2TWgJyLmy7el3fQQVwft4gU5NGyvV0XbD41iU9Jbg8lCH4zexhIkihDzVHJStlt6r088G6/fWeNjhXA==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} @@ -7246,6 +7290,22 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -8156,6 +8216,10 @@ packages: split2@3.2.2: resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} @@ -13429,6 +13493,8 @@ snapshots: buffer-from@1.1.2: {} + buffer-writer@2.0.0: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -13819,13 +13885,13 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 - connect-typeorm@1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))): + connect-typeorm@1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))): dependencies: '@types/debug': 0.0.31 '@types/express-session': 1.17.6 debug: 4.3.5(supports-color@8.1.1) express-session: 1.18.0 - typeorm: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)) + typeorm: 0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)) transitivePeerDependencies: - supports-color @@ -17578,6 +17644,8 @@ snapshots: dependencies: p-timeout: 3.2.0 + packet-reader@1.0.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -17656,6 +17724,43 @@ snapshots: performance-now@2.1.0: {} + pg-cloudflare@1.1.1: + optional: true + + pg-connection-string@2.7.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.7.0(pg@8.11.0): + dependencies: + pg: 8.11.0 + + pg-protocol@1.7.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.11.0: + dependencies: + buffer-writer: 2.0.0 + packet-reader: 1.0.0 + pg-connection-string: 2.7.0 + pg-pool: 3.7.0(pg@8.11.0) + pg-protocol: 1.7.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.1.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.0.1: {} picomatch@2.3.1: {} @@ -17753,6 +17858,16 @@ snapshots: picocolors: 1.0.1 source-map-js: 1.2.0 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -18882,6 +18997,8 @@ snapshots: dependencies: readable-stream: 3.6.2 + split2@4.2.0: {} + split@1.0.1: dependencies: through: 2.3.8 @@ -19418,7 +19535,7 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)): + typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)): dependencies: '@sqltools/formatter': 1.2.5 app-root-path: 3.1.0 @@ -19438,6 +19555,7 @@ snapshots: xml2js: 0.4.23 yargs: 17.7.2 optionalDependencies: + pg: 8.11.0 sqlite3: 5.1.4(encoding@0.1.13) ts-node: 10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5) transitivePeerDependencies: diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 0dc1f967..f27752d4 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -1,3 +1,5 @@ +import { MediaServerType } from '@server/constants/server'; +import { getSettings } from '@server/lib/settings'; import type { RateLimitOptions } from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit'; import type NodeCache from 'node-cache'; @@ -34,6 +36,8 @@ class ExternalAPI { const url = new URL(baseUrl); + const settings = getSettings(); + this.defaultHeaders = { 'Content-Type': 'application/json', Accept: 'application/json', @@ -42,6 +46,9 @@ class ExternalAPI { `${url.username}:${url.password}` ).toString('base64')}`, }), + ...(settings.main.mediaServerType === MediaServerType.EMBY && { + 'Accept-Encoding': 'gzip', + }), ...options.headers, }; diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 27bed196..92bffa80 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -3,6 +3,7 @@ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { randomUUID } from 'node:crypto'; import xml2js from 'xml2js'; interface PlexAccountResponse { @@ -127,6 +128,11 @@ export interface PlexWatchlistItem { title: string; } +export interface PlexWatchlistCache { + etag: string; + response: WatchlistResponse; +} + class PlexTvAPI extends ExternalAPI { private authToken: string; @@ -261,6 +267,11 @@ class PlexTvAPI extends ExternalAPI { items: PlexWatchlistItem[]; }> { try { + const watchlistCache = cacheManager.getCache('plexwatchlist'); + let cachedWatchlist = watchlistCache.data.get( + this.authToken + ); + const params = new URLSearchParams({ 'X-Plex-Container-Start': offset.toString(), 'X-Plex-Container-Size': size.toString(), @@ -268,42 +279,62 @@ class PlexTvAPI extends ExternalAPI { const response = await this.fetch( `https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`, { - headers: this.defaultHeaders, + headers: { + ...this.defaultHeaders, + ...(cachedWatchlist?.etag + ? { 'If-None-Match': cachedWatchlist.etag } + : {}), + }, } ); const data = (await response.json()) as WatchlistResponse; + // If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache. + if (response.status >= 200 && response.status <= 299) { + cachedWatchlist = { + etag: response.headers.get('etag') ?? '', + response: data, + }; + + watchlistCache.data.set( + this.authToken, + cachedWatchlist + ); + } + const watchlistDetails = await Promise.all( - (data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => { - const detailedResponse = await this.getRolling( - `/library/metadata/${watchlistItem.ratingKey}`, - {}, - undefined, - {}, - 'https://metadata.provider.plex.tv' - ); + (cachedWatchlist?.response.MediaContainer.Metadata ?? []).map( + async (watchlistItem) => { + const detailedResponse = await this.getRolling( + `/library/metadata/${watchlistItem.ratingKey}`, + {}, + undefined, + {}, + 'https://metadata.provider.plex.tv' + ); - const metadata = detailedResponse.MediaContainer.Metadata[0]; + const metadata = detailedResponse.MediaContainer.Metadata[0]; - const tmdbString = metadata.Guid.find((guid) => - guid.id.startsWith('tmdb') - ); - const tvdbString = metadata.Guid.find((guid) => - guid.id.startsWith('tvdb') - ); + const tmdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tmdb') + ); + const tvdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tvdb') + ); - return { - ratingKey: metadata.ratingKey, - // This should always be set? But I guess it also cannot be? - // We will filter out the 0's afterwards - tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, - tvdbId: tvdbString - ? Number(tvdbString.id.split('//')[1]) - : undefined, - title: metadata.title, - type: metadata.type, - }; - }) + return { + ratingKey: metadata.ratingKey, + // This should always be set? But I guess it also cannot be? + // We will filter out the 0's afterwards + tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, + tvdbId: tvdbString + ? Number(tvdbString.id.split('//')[1]) + : undefined, + title: metadata.title, + type: metadata.type, + }; + } + ) ); const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); @@ -311,7 +342,7 @@ class PlexTvAPI extends ExternalAPI { return { offset, size, - totalSize: data.MediaContainer.totalSize, + totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0, items: filteredList, }; } catch (e) { @@ -327,6 +358,29 @@ class PlexTvAPI extends ExternalAPI { }; } } + + public async pingToken() { + try { + const data: { pong: unknown } = await this.get( + '/api/v2/ping', + {}, + undefined, + { + headers: { + 'X-Plex-Client-Identifier': randomUUID(), + }, + } + ); + if (!data?.pong) { + throw new Error('No pong response'); + } + } catch (e) { + logger.error('Failed to ping token', { + label: 'Plex Refresh Token', + errorMessage: e.message, + }); + } + } } export default PlexTvAPI; diff --git a/server/api/rating/rottentomatoes.ts b/server/api/rating/rottentomatoes.ts index f4fbe12b..170cbb64 100644 --- a/server/api/rating/rottentomatoes.ts +++ b/server/api/rating/rottentomatoes.ts @@ -128,7 +128,7 @@ class RottenTomatoes extends ExternalAPI { movie = contentResults.hits.find((movie) => movie.title === name); } - if (!movie) { + if (!movie?.rottenTomatoes) { return null; } diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 6f13ec08..016da27a 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -99,12 +99,12 @@ interface DiscoverTvOptions { } class TheMovieDb extends ExternalAPI { - private region?: string; + private discoverRegion?: string; private originalLanguage?: string; constructor({ - region, + discoverRegion, originalLanguage, - }: { region?: string; originalLanguage?: string } = {}) { + }: { discoverRegion?: string; originalLanguage?: string } = {}) { super( 'https://api.themoviedb.org/3', { @@ -118,7 +118,7 @@ class TheMovieDb extends ExternalAPI { }, } ); - this.region = region; + this.discoverRegion = discoverRegion; this.originalLanguage = originalLanguage; } @@ -469,7 +469,7 @@ class TheMovieDb extends ExternalAPI { page: page.toString(), include_adult: includeAdult ? 'true' : 'false', language, - region: this.region || '', + region: this.discoverRegion || '', with_original_language: originalLanguage && originalLanguage !== 'all' ? originalLanguage @@ -541,7 +541,7 @@ class TheMovieDb extends ExternalAPI { sort_by: sortBy, page: page.toString(), language, - region: this.region || '', + region: this.discoverRegion || '', // Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set! 'first_air_date.gte': @@ -594,7 +594,7 @@ class TheMovieDb extends ExternalAPI { { page: page.toString(), language, - region: this.region || '', + region: this.discoverRegion || '', originalLanguage: this.originalLanguage || '', } ); @@ -620,7 +620,7 @@ class TheMovieDb extends ExternalAPI { { page: page.toString(), language, - region: this.region || '', + region: this.discoverRegion || '', } ); diff --git a/server/datasource.ts b/server/datasource.ts index d4eadaa1..8a03c4ae 100644 --- a/server/datasource.ts +++ b/server/datasource.ts @@ -1,7 +1,43 @@ -import 'reflect-metadata'; +import fs from 'fs'; +import type { TlsOptions } from 'tls'; import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm'; import { DataSource } from 'typeorm'; +const DB_SSL_PREFIX = 'DB_SSL_'; + +function boolFromEnv(envVar: string, defaultVal = false) { + if (process.env[envVar]) { + return process.env[envVar]?.toLowerCase() === 'true'; + } + return defaultVal; +} + +function stringOrReadFileFromEnv(envVar: string): Buffer | string | undefined { + if (process.env[envVar]) { + return process.env[envVar]; + } + const filePath = process.env[`${envVar}_FILE`]; + if (filePath) { + return fs.readFileSync(filePath); + } + return undefined; +} + +function buildSslConfig(): TlsOptions | undefined { + if (process.env.DB_USE_SSL?.toLowerCase() !== 'true') { + return undefined; + } + return { + rejectUnauthorized: boolFromEnv( + `${DB_SSL_PREFIX}REJECT_UNAUTHORIZED`, + true + ), + ca: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CA`), + key: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}KEY`), + cert: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CERT`), + }; +} + const devConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY @@ -9,10 +45,10 @@ const devConfig: DataSourceOptions = { : 'config/db/db.sqlite3', synchronize: true, migrationsRun: false, - logging: false, + logging: boolFromEnv('DB_LOG_QUERIES'), enableWAL: true, entities: ['server/entity/**/*.ts'], - migrations: ['server/migration/**/*.ts'], + migrations: ['server/migration/sqlite/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'], }; @@ -23,16 +59,56 @@ const prodConfig: DataSourceOptions = { : 'config/db/db.sqlite3', synchronize: false, migrationsRun: false, - logging: false, + logging: boolFromEnv('DB_LOG_QUERIES'), enableWAL: true, entities: ['dist/entity/**/*.js'], - migrations: ['dist/migration/**/*.js'], + migrations: ['dist/migration/sqlite/**/*.js'], subscribers: ['dist/subscriber/**/*.js'], }; -const dataSource = new DataSource( - process.env.NODE_ENV !== 'production' ? devConfig : prodConfig -); +const postgresDevConfig: DataSourceOptions = { + type: 'postgres', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT ?? '5432'), + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME ?? 'jellyseerr', + ssl: buildSslConfig(), + synchronize: false, + migrationsRun: true, + logging: boolFromEnv('DB_LOG_QUERIES'), + entities: ['server/entity/**/*.ts'], + migrations: ['server/migration/postgres/**/*.ts'], + subscribers: ['server/subscriber/**/*.ts'], +}; + +const postgresProdConfig: DataSourceOptions = { + type: 'postgres', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT ?? '5432'), + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME ?? 'jellyseerr', + ssl: buildSslConfig(), + synchronize: false, + migrationsRun: false, + logging: boolFromEnv('DB_LOG_QUERIES'), + entities: ['dist/entity/**/*.js'], + migrations: ['dist/migration/postgres/**/*.js'], + subscribers: ['dist/subscriber/**/*.js'], +}; + +export const isPgsql = process.env.DB_TYPE === 'postgres'; + +function getDataSource(): DataSourceOptions { + if (process.env.NODE_ENV === 'production') { + return isPgsql ? postgresProdConfig : prodConfig; + } else { + return isPgsql ? postgresDevConfig : devConfig; + } +} + +const dataSource = new DataSource(getDataSource()); export const getRepository = ( target: EntityTarget diff --git a/server/entity/Media.ts b/server/entity/Media.ts index a9991dc4..1941162a 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -10,6 +10,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { getHostname } from '@server/utils/getHostname'; import { AfterLoad, @@ -42,6 +43,10 @@ class Media { finalIds = tmdbIds; } + if (finalIds.length === 0) { + return []; + } + const media = await mediaRepository .createQueryBuilder('media') .leftJoinAndSelect( @@ -127,10 +132,23 @@ class Media { @UpdateDateColumn() public updatedAt: Date; - @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + /** + * The `lastSeasonChange` column stores the date and time when the media was added to the library. + * It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`. + */ + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public lastSeasonChange: Date; - @Column({ type: 'datetime', nullable: true }) + /** + * The `mediaAddedAt` column stores the date and time when the media was added to the library. + * It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`. + * This column is nullable because it can be null when the media is not yet synced to the library. + */ + @DbAwareColumn({ + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + nullable: true, + }) public mediaAddedAt: Date; @Column({ nullable: true, type: 'int' }) diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 6b2c7b56..6cc808c3 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -13,6 +13,7 @@ import { MediaType, } from '@server/constants/media'; import { getRepository } from '@server/datasource'; +import OverrideRule from '@server/entity/OverrideRule'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; @@ -57,6 +58,7 @@ export class MediaRequest { const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); + const settings = getSettings(); let requestUser = user; @@ -257,9 +259,11 @@ export class MediaRequest { >; const requestedSeasons = requestBody.seasons === 'all' - ? tmdbMediaShow.seasons - .map((season) => season.season_number) - .filter((sn) => sn > 0) + ? settings.main.enableSpecialEpisodes + ? tmdbMediaShow.seasons.map((season) => season.season_number) + : tmdbMediaShow.seasons + .map((season) => season.season_number) + .filter((sn) => sn > 0) : (requestBody.seasons as number[]); let existingSeasons: number[] = []; @@ -713,48 +717,6 @@ export class MediaRequest { 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, @@ -775,6 +737,151 @@ export class MediaRequest { return; } + let rootFolder = radarrSettings.activeDirectory; + let qualityProfile = radarrSettings.activeProfileId; + let tags = radarrSettings.tags ? [...radarrSettings.tags] : []; + + const overrideRuleRepository = getRepository(OverrideRule); + const overrideRules = await overrideRuleRepository.find({ + where: { radarrServiceId: radarrSettings.id }, + }); + const appliedOverrideRules = overrideRules.filter((rule) => { + if ( + rule.users && + !rule.users + .split(',') + .some((userId) => Number(userId) === this.requestedBy.id) + ) { + return false; + } + if ( + rule.genre && + !rule.genre + .split(',') + .some((genreId) => + movie.genres.map((genre) => genre.id).includes(Number(genreId)) + ) + ) { + return false; + } + if ( + rule.language && + !rule.language + .split('|') + .some((languageId) => languageId === movie.original_language) + ) { + return false; + } + if ( + rule.keywords && + !rule.keywords + .split(',') + .some((keywordId) => + movie.keywords.keywords + .map((keyword) => keyword.id) + .includes(Number(keywordId)) + ) + ) { + return false; + } + return true; + }); + + if ( + this.rootFolder && + this.rootFolder !== '' && + this.rootFolder !== radarrSettings.activeDirectory + ) { + rootFolder = this.rootFolder; + logger.info( + `Request has a manually overriden root folder: ${rootFolder}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } else { + const overrideRootFolder = appliedOverrideRules.find( + (rule) => rule.rootFolder + )?.rootFolder; + if (overrideRootFolder) { + rootFolder = overrideRootFolder; + this.rootFolder = rootFolder; + logger.info( + `Request has an override root folder from override rules: ${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 a manually overriden quality profile ID: ${qualityProfile}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } else { + const overrideProfileId = appliedOverrideRules.find( + (rule) => rule.profileId + )?.profileId; + if (overrideProfileId) { + qualityProfile = overrideProfileId; + this.profileId = qualityProfile; + logger.info( + `Request has an override quality profile ID from override rules: ${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 manually overriden tags`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + tagIds: tags, + }); + } else { + const overrideTags = appliedOverrideRules.find( + (rule) => rule.tags + )?.tags; + if (overrideTags) { + tags = [ + ...new Set([ + ...tags, + ...overrideTags.split(',').map((tag) => Number(tag)), + ]), + ]; + this.tags = tags; + logger.info(`Request has override tags from override rules`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + tagIds: tags, + }); + } + } + + const requestRepository = getRepository(MediaRequest); + requestRepository.save(this); + if (radarrSettings.tagRequests) { let userTag = (await radarr.getTags()).find((v) => v.label.startsWith(this.requestedBy.id + ' - ') @@ -816,7 +923,6 @@ export class MediaRequest { mediaId: this.media.id, }); - const requestRepository = getRepository(MediaRequest); this.status = MediaRequestStatus.APPROVED; await requestRepository.save(this); return; @@ -856,10 +962,8 @@ export class MediaRequest { await mediaRepository.save(media); }) .catch(async () => { - const requestRepository = getRepository(MediaRequest); - this.status = MediaRequestStatus.FAILED; - requestRepository.save(this); + await requestRepository.save(this); logger.warn( 'Something went wrong sending movie request to Radarr, marking status as FAILED', @@ -957,6 +1061,7 @@ export class MediaRequest { throw new Error('Media data not found'); } + const requestRepository = getRepository(MediaRequest); if ( media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ) { @@ -966,7 +1071,6 @@ export class MediaRequest { mediaId: this.media.id, }); - const requestRepository = getRepository(MediaRequest); this.status = MediaRequestStatus.APPROVED; await requestRepository.save(this); return; @@ -981,7 +1085,6 @@ export class MediaRequest { 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'); @@ -1019,29 +1122,110 @@ export class MediaRequest { ? [...sonarrSettings.tags] : []; + const overrideRuleRepository = getRepository(OverrideRule); + const overrideRules = await overrideRuleRepository.find({ + where: { sonarrServiceId: sonarrSettings.id }, + }); + const appliedOverrideRules = overrideRules.filter((rule) => { + if ( + rule.users && + !rule.users + .split(',') + .some((userId) => Number(userId) === this.requestedBy.id) + ) { + return false; + } + if ( + rule.genre && + !rule.genre + .split(',') + .some((genreId) => + series.genres.map((genre) => genre.id).includes(Number(genreId)) + ) + ) { + return false; + } + if ( + rule.language && + !rule.language + .split('|') + .some((languageId) => languageId === series.original_language) + ) { + return false; + } + if ( + rule.keywords && + !rule.keywords + .split(',') + .some((keywordId) => + series.keywords.results + .map((keyword) => keyword.id) + .includes(Number(keywordId)) + ) + ) { + return false; + } + return true; + }); + 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}`, + `Request has a manually overriden root folder: ${rootFolder}`, { label: 'Media Request', requestId: this.id, mediaId: this.media.id, } ); + } else { + const overrideRootFolder = appliedOverrideRules.find( + (rule) => rule.rootFolder + )?.rootFolder; + if (overrideRootFolder) { + rootFolder = overrideRootFolder; + this.rootFolder = rootFolder; + logger.info( + `Request has an override root folder from override rules: ${rootFolder}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } + } + + if (this.profileId && this.profileId !== qualityProfile) { + qualityProfile = this.profileId; + logger.info( + `Request has a manually overriden quality profile ID: ${qualityProfile}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } else { + const overrideProfileId = appliedOverrideRules.find( + (rule) => rule.profileId + )?.profileId; + if (overrideProfileId) { + qualityProfile = overrideProfileId; + this.profileId = qualityProfile; + logger.info( + `Request has an override quality profile ID from override rules: ${qualityProfile}`, + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + } + ); + } } if ( @@ -1061,12 +1245,31 @@ export class MediaRequest { if (this.tags && !isEqual(this.tags, tags)) { tags = this.tags; - logger.info(`Request has override tags`, { + logger.info(`Request has manually overriden tags`, { label: 'Media Request', requestId: this.id, mediaId: this.media.id, tagIds: tags, }); + } else { + const overrideTags = appliedOverrideRules.find( + (rule) => rule.tags + )?.tags; + if (overrideTags) { + tags = [ + ...new Set([ + ...tags, + ...overrideTags.split(',').map((tag) => Number(tag)), + ]), + ]; + this.tags = tags; + logger.info(`Request has override tags from override rules`, { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + tagIds: tags, + }); + } } if (sonarrSettings.tagRequests) { @@ -1101,6 +1304,8 @@ export class MediaRequest { } } + requestRepository.save(this); + const sonarrSeriesOptions: AddSeriesOptions = { profileId: qualityProfile, languageProfileId: languageProfile, @@ -1134,13 +1339,12 @@ export class MediaRequest { media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = sonarrSeries.titleSlug; media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id; + await mediaRepository.save(media); }) .catch(async () => { - const requestRepository = getRepository(MediaRequest); - this.status = MediaRequestStatus.FAILED; - requestRepository.save(this); + await requestRepository.save(this); logger.warn( 'Something went wrong sending series request to Sonarr, marking status as FAILED', diff --git a/server/entity/OverrideRule.ts b/server/entity/OverrideRule.ts new file mode 100644 index 00000000..bf137343 --- /dev/null +++ b/server/entity/OverrideRule.ts @@ -0,0 +1,52 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +class OverrideRule { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ type: 'int', nullable: true }) + public radarrServiceId?: number; + + @Column({ type: 'int', nullable: true }) + public sonarrServiceId?: number; + + @Column({ nullable: true }) + public users?: string; + + @Column({ nullable: true }) + public genre?: string; + + @Column({ nullable: true }) + public language?: string; + + @Column({ nullable: true }) + public keywords?: string; + + @Column({ type: 'int', nullable: true }) + public profileId?: number; + + @Column({ nullable: true }) + public rootFolder?: string; + + @Column({ nullable: true }) + public tags?: string; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default OverrideRule; diff --git a/server/entity/Season.ts b/server/entity/Season.ts index 44a83d97..d488a5c1 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -23,7 +23,9 @@ class Season { @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status4k: MediaStatus; - @ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' }) + @ManyToOne(() => Media, (media) => media.seasons, { + onDelete: 'CASCADE', + }) public media: Promise; @CreateDateColumn() diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index ea4a7d33..82671fe3 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -31,7 +31,10 @@ export class UserSettings { public locale?: string; @Column({ nullable: true }) - public region?: string; + public discoverRegion?: string; + + @Column({ nullable: true }) + public streamingRegion?: string; @Column({ nullable: true }) public originalLanguage?: string; @@ -57,6 +60,9 @@ export class UserSettings { @Column({ nullable: true }) public telegramChatId?: string; + @Column({ nullable: true }) + public telegramMessageThreadId?: string; + @Column({ nullable: true }) public telegramSendSilently?: boolean; diff --git a/server/index.ts b/server/index.ts index cd65d566..64233c0e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,5 +1,5 @@ import PlexAPI from '@server/api/plexapi'; -import dataSource, { getRepository } from '@server/datasource'; +import dataSource, { getRepository, isPgsql } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; import { Session } from '@server/entity/Session'; import { User } from '@server/entity/User'; @@ -66,9 +66,13 @@ app // Run migrations in production if (process.env.NODE_ENV === 'production') { - await dbConnection.query('PRAGMA foreign_keys=OFF'); - await dbConnection.runMigrations(); - await dbConnection.query('PRAGMA foreign_keys=ON'); + if (isPgsql) { + await dbConnection.runMigrations(); + } else { + await dbConnection.query('PRAGMA foreign_keys=OFF'); + await dbConnection.runMigrations(); + await dbConnection.query('PRAGMA foreign_keys=ON'); + } } // Load Settings diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index 89cb7426..6738cbb5 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -5,6 +5,7 @@ export interface GenreSliderItem { } export interface WatchlistItem { + id: number; ratingKey: string; tmdbId: number; mediaType: 'movie' | 'tv'; diff --git a/server/interfaces/api/overrideRuleInterfaces.ts b/server/interfaces/api/overrideRuleInterfaces.ts new file mode 100644 index 00000000..5ae61a68 --- /dev/null +++ b/server/interfaces/api/overrideRuleInterfaces.ts @@ -0,0 +1,3 @@ +import type OverrideRule from '@server/entity/OverrideRule'; + +export type OverrideRuleResultsResponse = OverrideRule[]; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 579f1109..017eef85 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -32,10 +32,12 @@ export interface PublicSettingsResponse { localLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; - region: string; + discoverRegion: string; + streamingRegion: string; originalLanguage: string; mediaServerType: number; partialRequestsEnabled: boolean; + enableSpecialEpisodes: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 53b6729c..32776461 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -5,7 +5,8 @@ export interface UserSettingsGeneralResponse { email?: string; discordId?: string; locale?: string; - region?: string; + discoverRegion?: string; + streamingRegion?: string; originalLanguage?: string; movieQuotaLimit?: number; movieQuotaDays?: number; @@ -33,6 +34,7 @@ export interface UserSettingsNotificationsResponse { telegramEnabled?: boolean; telegramBotUsername?: string; telegramChatId?: string; + telegramMessageThreadId?: string; telegramSendSilently?: boolean; webPushEnabled?: boolean; notificationTypes: Partial; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index a210988e..ffc19daa 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -2,6 +2,7 @@ import { MediaServerType } from '@server/constants/server'; import availabilitySync from '@server/lib/availabilitySync'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; +import refreshToken from '@server/lib/refreshToken'; import { jellyfinFullScanner, jellyfinRecentScanner, @@ -13,7 +14,6 @@ import type { JobId } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import watchlistSync from '@server/lib/watchlistsync'; import logger from '@server/logger'; -import random from 'lodash/random'; import schedule from 'node-schedule'; interface ScheduledJob { @@ -113,30 +113,20 @@ export const startJobs = (): void => { } // Watchlist Sync - const watchlistSyncJob: ScheduledJob = { + scheduledJobs.push({ id: 'plex-watchlist-sync', name: 'Plex Watchlist Sync', type: 'process', - interval: 'fixed', + interval: 'seconds', cronSchedule: jobs['plex-watchlist-sync'].schedule, - job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => { + job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => { logger.info('Starting scheduled job: Plex Watchlist Sync', { label: 'Jobs', }); watchlistSync.syncWatchlist(); }), - }; - - // To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule - // after each run - watchlistSyncJob.job.on('run', () => { - watchlistSyncJob.job.schedule( - new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true))) - ); }); - scheduledJobs.push(watchlistSyncJob); - // Run full radarr scan every 24 hours scheduledJobs.push({ id: 'radarr-scan', @@ -233,5 +223,19 @@ export const startJobs = (): void => { }), }); + scheduledJobs.push({ + id: 'plex-refresh-token', + name: 'Plex Refresh Token', + type: 'process', + interval: 'fixed', + cronSchedule: jobs['plex-refresh-token'].schedule, + job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => { + logger.info('Starting scheduled job: Plex Refresh Token', { + label: 'Jobs', + }); + refreshToken.run(); + }), + }); + logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 011205e7..51d0e08f 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -8,7 +8,8 @@ export type AvailableCacheIds = | 'imdb' | 'github' | 'plexguid' - | 'plextv'; + | 'plextv' + | 'plexwatchlist'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -68,6 +69,7 @@ class CacheManager { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60, }), + plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index e949e3e1..8eb1d99d 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -291,6 +291,10 @@ class DiscordAgent } } + if (settings.options.webhookRoleId) { + userMentions.push(`<@&${settings.options.webhookRoleId}>`); + } + const response = await fetch(settings.options.webhookUrl, { method: 'POST', headers: { diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index a66f9710..db12b494 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -17,6 +17,7 @@ interface TelegramMessagePayload { text: string; parse_mode: string; chat_id: string; + message_thread_id: string; disable_notification: boolean; } @@ -25,6 +26,7 @@ interface TelegramPhotoPayload { caption: string; parse_mode: string; chat_id: string; + message_thread_id: string; disable_notification: boolean; } @@ -182,6 +184,7 @@ class TelegramAgent body: JSON.stringify({ ...notificationPayload, chat_id: settings.options.chatId, + message_thread_id: settings.options.messageThreadId, disable_notification: !!settings.options.sendSilently, } as TelegramMessagePayload | TelegramPhotoPayload), }); @@ -233,6 +236,8 @@ class TelegramAgent body: JSON.stringify({ ...notificationPayload, chat_id: payload.notifyUser.settings.telegramChatId, + message_thread_id: + payload.notifyUser.settings.telegramMessageThreadId, disable_notification: !!payload.notifyUser.settings.telegramSendSilently, } as TelegramMessagePayload | TelegramPhotoPayload), @@ -296,6 +301,7 @@ class TelegramAgent body: JSON.stringify({ ...notificationPayload, chat_id: user.settings.telegramChatId, + message_thread_id: user.settings.telegramMessageThreadId, disable_notification: !!user.settings?.telegramSendSilently, } as TelegramMessagePayload | TelegramPhotoPayload), }); diff --git a/server/lib/refreshToken.ts b/server/lib/refreshToken.ts new file mode 100644 index 00000000..ac7bd346 --- /dev/null +++ b/server/lib/refreshToken.ts @@ -0,0 +1,37 @@ +import PlexTvAPI from '@server/api/plextv'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import logger from '@server/logger'; + +class RefreshToken { + public async run() { + const userRepository = getRepository(User); + + const users = await userRepository + .createQueryBuilder('user') + .addSelect('user.plexToken') + .where("user.plexToken != ''") + .getMany(); + + for (const user of users) { + await this.refreshUserToken(user); + } + } + + private async refreshUserToken(user: User) { + if (!user.plexToken) { + logger.warn('Skipping user refresh token for user without plex token', { + label: 'Plex Refresh Token', + user: user.displayName, + }); + return; + } + + const plexTvApi = new PlexTvAPI(user.plexToken); + plexTvApi.pingToken(); + } +} + +const refreshToken = new RefreshToken(); + +export default refreshToken; diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index f48de70e..b4816ae5 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -210,14 +210,27 @@ class JellyfinScanner { return; } - if (metadata.ProviderIds.Tvdb) { - tvShow = await this.tmdb.getShowByTvdbId({ - tvdbId: Number(metadata.ProviderIds.Tvdb), - }); - } else if (metadata.ProviderIds.Tmdb) { - tvShow = await this.tmdb.getTvShow({ - tvId: Number(metadata.ProviderIds.Tmdb), - }); + if (metadata.ProviderIds.Tmdb) { + try { + tvShow = await this.tmdb.getTvShow({ + tvId: Number(metadata.ProviderIds.Tmdb), + }); + } catch { + this.log('Unable to find TMDb ID for this title.', 'debug', { + jellyfinitem, + }); + } + } + if (!tvShow && metadata.ProviderIds.Tvdb) { + try { + tvShow = await this.tmdb.getShowByTvdbId({ + tvdbId: Number(metadata.ProviderIds.Tvdb), + }); + } catch { + this.log('Unable to find TVDb ID for this title.', 'debug', { + jellyfinitem, + }); + } } if (tvShow) { @@ -491,7 +504,13 @@ class JellyfinScanner { } }); } else { - this.log(`failed show: ${metadata.Name}`); + this.log( + `No information found for the show: ${metadata.Name}`, + 'debug', + { + jellyfinitem, + } + ); } } catch (e) { this.log( diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index f6049630..9dee904a 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -277,8 +277,11 @@ class PlexScanner const seasons = tvShow.seasons; const processableSeasons: ProcessableSeason[] = []; + const settings = getSettings(); - const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0); + const filteredSeasons = settings.main.enableSpecialEpisodes + ? seasons + : seasons.filter((sn) => sn.season_number !== 0); for (const season of filteredSeasons) { const matchedPlexSeason = metadata.Children?.Metadata.find( diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 3256c948..88f6a324 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -102,11 +102,12 @@ class SonarrScanner } const tmdbId = tvShow.id; + const settings = getSettings(); const filteredSeasons = sonarrSeries.seasons.filter( (sn) => - sn.seasonNumber !== 0 && - tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) + tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) && + (!settings.main.partialRequestsEnabled ? sn.seasonNumber !== 0 : true) ); for (const season of filteredSeasons) { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 29447f53..cd8ebb97 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -76,6 +76,7 @@ export interface DVRSettings { syncEnabled: boolean; preventSearch: boolean; tagRequests: boolean; + overrideRule: number[]; } export interface RadarrSettings extends DVRSettings { @@ -124,11 +125,13 @@ export interface MainSettings { hideAvailable: boolean; localLogin: boolean; newPlexLogin: boolean; - region: string; + discoverRegion: string; + streamingRegion: string; originalLanguage: string; trustProxy: boolean; mediaServerType: number; partialRequestsEnabled: boolean; + enableSpecialEpisodes: boolean; locale: string; proxy: ProxySettings; } @@ -144,13 +147,15 @@ interface FullPublicSettings extends PublicSettings { localLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; - region: string; + discoverRegion: string; + streamingRegion: string; originalLanguage: string; mediaServerType: number; jellyfinExternalHost?: string; jellyfinForgotPasswordUrl?: string; jellyfinServerName?: string; partialRequestsEnabled: boolean; + enableSpecialEpisodes: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; @@ -170,6 +175,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig { botUsername?: string; botAvatarUrl?: string; webhookUrl: string; + webhookRoleId?: string; enableMentions: boolean; }; } @@ -210,6 +216,7 @@ export interface NotificationAgentTelegram extends NotificationAgentConfig { botUsername?: string; botAPI: string; chatId: string; + messageThreadId: string; sendSilently: boolean; }; } @@ -281,6 +288,7 @@ export type JobId = | 'plex-recently-added-scan' | 'plex-full-scan' | 'plex-watchlist-sync' + | 'plex-refresh-token' | 'radarr-scan' | 'sonarr-scan' | 'download-sync' @@ -331,11 +339,13 @@ class Settings { hideAvailable: false, localLogin: true, newPlexLogin: true, - region: '', + discoverRegion: '', + streamingRegion: '', originalLanguage: '', trustProxy: false, mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, + enableSpecialEpisodes: false, locale: 'en', proxy: { enabled: false, @@ -394,6 +404,7 @@ class Settings { types: 0, options: { webhookUrl: '', + webhookRoleId: '', enableMentions: true, }, }, @@ -417,6 +428,7 @@ class Settings { options: { botAPI: '', chatId: '', + messageThreadId: '', sendSilently: false, }, }, @@ -467,7 +479,10 @@ class Settings { schedule: '0 0 3 * * *', }, 'plex-watchlist-sync': { - schedule: '0 */10 * * * *', + schedule: '0 */3 * * * *', + }, + 'plex-refresh-token': { + schedule: '0 0 5 * * *', }, 'radarr-scan': { schedule: '0 0 4 * * *', @@ -570,10 +585,12 @@ class Settings { series4kEnabled: this.data.sonarr.some( (sonarr) => sonarr.is4k && sonarr.isDefault ), - region: this.data.main.region, + discoverRegion: this.data.main.discoverRegion, + streamingRegion: this.data.main.streamingRegion, originalLanguage: this.data.main.originalLanguage, mediaServerType: this.main.mediaServerType, partialRequestsEnabled: this.data.main.partialRequestsEnabled, + enableSpecialEpisodes: this.data.main.enableSpecialEpisodes, cacheImages: this.data.main.cacheImages, vapidPublic: this.vapidPublic, enablePushRegistration: this.data.notifications.agents.webpush.enabled, @@ -682,10 +699,9 @@ class Settings { } public async save(): Promise { - await fs.writeFile( - SETTINGS_PATH, - JSON.stringify(this.data, undefined, ' ') - ); + const tmp = SETTINGS_PATH + '.tmp'; + await fs.writeFile(tmp, JSON.stringify(this.data, undefined, ' ')); + await fs.rename(tmp, SETTINGS_PATH); } } diff --git a/server/lib/settings/migrations/0004_migrate_region_setting.ts b/server/lib/settings/migrations/0004_migrate_region_setting.ts new file mode 100644 index 00000000..2039e6fc --- /dev/null +++ b/server/lib/settings/migrations/0004_migrate_region_setting.ts @@ -0,0 +1,17 @@ +import type { AllSettings } from '@server/lib/settings'; + +const migrateRegionSetting = (settings: any): AllSettings => { + const oldRegion = settings.main.region; + if (oldRegion) { + settings.main.discoverRegion = oldRegion; + settings.main.streamingRegion = oldRegion; + } else { + settings.main.discoverRegion = ''; + settings.main.streamingRegion = 'US'; + } + delete settings.main.region; + + return settings; +}; + +export default migrateRegionSetting; diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 2d198451..4919bf70 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -62,7 +62,7 @@ class WatchlistSync { const plexTvApi = new PlexTvAPI(user.plexToken); - const response = await plexTvApi.getWatchlist({ size: 200 }); + const response = await plexTvApi.getWatchlist({ size: 20 }); const mediaItems = await Media.getRelatedMedia( user, diff --git a/server/migration/postgres/1734786061496-InitialMigration.ts b/server/migration/postgres/1734786061496-InitialMigration.ts new file mode 100644 index 00000000..592c56fb --- /dev/null +++ b/server/migration/postgres/1734786061496-InitialMigration.ts @@ -0,0 +1,195 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitialMigration1734786061496 implements MigrationInterface { + name = 'InitialMigration1734786061496'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "blacklist" ("id" SERIAL NOT NULL, "mediaType" character varying NOT NULL, "title" character varying, "tmdbId" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "PK_04dc42a96bf0914cda31b579702" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE TABLE "season_request" ("id" SERIAL NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "requestId" integer, CONSTRAINT "PK_4811e502081543bf620f1fa4328" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" SERIAL NOT NULL, "status" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "type" character varying NOT NULL, "is4k" boolean NOT NULL DEFAULT false, "serverId" integer, "profileId" integer, "rootFolder" character varying, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT false, "mediaId" integer NOT NULL, "requestedById" integer, "modifiedById" integer, CONSTRAINT "PK_f8334500e8e12db87536558c66c" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "season" ("id" SERIAL NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "status4k" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "mediaId" integer NOT NULL, CONSTRAINT "PK_8ac0d081dbdb7ab02d166bcda9f" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "media" ("id" SERIAL NOT NULL, "mediaType" character varying NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" character varying, "status" integer NOT NULL DEFAULT '1', "status4k" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "lastSeasonChange" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "mediaAddedAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" character varying, "externalServiceSlug4k" character varying, "ratingKey" character varying, "ratingKey4k" character varying, "jellyfinMediaId" character varying, "jellyfinMediaId4k" character varying, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "PK_f4e0fcac36e050de337b670d8bd" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE TABLE "watchlist" ("id" SERIAL NOT NULL, "ratingKey" character varying NOT NULL, "mediaType" character varying NOT NULL, "title" character varying NOT NULL, "tmdbId" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "requestedById" integer, "mediaId" integer NOT NULL, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "PK_0c8c0dbcc8d379117138e71ad5b" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" SERIAL NOT NULL, "endpoint" character varying NOT NULL, "p256dh" character varying NOT NULL, "auth" character varying NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "PK_397020e7be9a4086cc798e0bb63" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" SERIAL NOT NULL, "locale" character varying NOT NULL DEFAULT '', "discoverRegion" character varying, "streamingRegion" character varying, "originalLanguage" character varying, "pgpKey" character varying, "discordId" character varying, "pushbulletAccessToken" character varying, "pushoverApplicationToken" character varying, "pushoverUserKey" character varying, "pushoverSound" character varying, "telegramChatId" character varying, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "PK_00f004f5922a0744d174530d639" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "user" ("id" SERIAL NOT NULL, "email" character varying NOT NULL, "plexUsername" character varying, "jellyfinUsername" character varying, "username" character varying, "password" character varying, "resetPasswordGuid" character varying, "recoveryLinkExpirationDate" date, "userType" integer NOT NULL DEFAULT '1', "plexId" integer, "jellyfinUserId" character varying, "jellyfinDeviceId" character varying, "jellyfinAuthToken" character varying, "plexToken" character varying, "permissions" integer NOT NULL DEFAULT '0', "avatar" character varying NOT NULL, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "issue_comment" ("id" SERIAL NOT NULL, "message" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, "issueId" integer, CONSTRAINT "PK_2ad05784e2ae661fa409e5e0248" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "issue" ("id" SERIAL NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "problemSeason" integer NOT NULL DEFAULT '0', "problemEpisode" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "PK_f80e086c249b9f3f3ff2fd321b7" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "discover_slider" ("id" SERIAL NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT false, "enabled" boolean NOT NULL DEFAULT true, "title" character varying, "data" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_20a71a098d04bae448e4d51db23" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "session" ("expiredAt" bigint NOT NULL, "id" character varying(255) NOT NULL, "json" text NOT NULL, CONSTRAINT "PK_f55da76ac1c3ac420f444d2ff11" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_28c5d1d16da7908c97c9bc2f74" ON "session" ("expiredAt") ` + ); + await queryRunner.query( + `ALTER TABLE "blacklist" ADD CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "blacklist" ADD CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "season_request" ADD CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "media_request" ADD CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "media_request" ADD CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "media_request" ADD CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "season" ADD CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" ADD CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" ADD CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "issue_comment" ADD CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "issue_comment" ADD CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "issue" ADD CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "issue" ADD CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "issue" ADD CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "issue" DROP CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5"` + ); + await queryRunner.query( + `ALTER TABLE "issue" DROP CONSTRAINT "FK_10b17b49d1ee77e7184216001e0"` + ); + await queryRunner.query( + `ALTER TABLE "issue" DROP CONSTRAINT "FK_276e20d053f3cff1645803c95d8"` + ); + await queryRunner.query( + `ALTER TABLE "issue_comment" DROP CONSTRAINT "FK_180710fead1c94ca499c57a7d42"` + ); + await queryRunner.query( + `ALTER TABLE "issue_comment" DROP CONSTRAINT "FK_707b033c2d0653f75213614789d"` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" DROP CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78"` + ); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" DROP CONSTRAINT "FK_03f7958328e311761b0de675fbe"` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" DROP CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7"` + ); + await queryRunner.query( + `ALTER TABLE "season" DROP CONSTRAINT "FK_087099b39600be695591da9a49c"` + ); + await queryRunner.query( + `ALTER TABLE "media_request" DROP CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15"` + ); + await queryRunner.query( + `ALTER TABLE "media_request" DROP CONSTRAINT "FK_6997bee94720f1ecb7f31137095"` + ); + await queryRunner.query( + `ALTER TABLE "media_request" DROP CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0"` + ); + await queryRunner.query( + `ALTER TABLE "season_request" DROP CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a"` + ); + await queryRunner.query( + `ALTER TABLE "blacklist" DROP CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99"` + ); + await queryRunner.query( + `ALTER TABLE "blacklist" DROP CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_28c5d1d16da7908c97c9bc2f74"` + ); + await queryRunner.query(`DROP TABLE "session"`); + await queryRunner.query(`DROP TABLE "discover_slider"`); + await queryRunner.query(`DROP TABLE "issue"`); + await queryRunner.query(`DROP TABLE "issue_comment"`); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_939f205946256cc0d2a1ac51a8"` + ); + await queryRunner.query(`DROP TABLE "watchlist"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_7ff2d11f6a83cb52386eaebe74"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_41a289eb1fa489c1bc6f38d9c3"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_7157aad07c73f6a6ae3bbd5ef5"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`DROP TABLE "season"`); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query(`DROP TABLE "season_request"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_6bbafa28411e6046421991ea21"` + ); + await queryRunner.query(`DROP TABLE "blacklist"`); + } +} diff --git a/server/migration/postgres/1734786596045-AddTelegramMessageThreadId.ts b/server/migration/postgres/1734786596045-AddTelegramMessageThreadId.ts new file mode 100644 index 00000000..a1b89b12 --- /dev/null +++ b/server/migration/postgres/1734786596045-AddTelegramMessageThreadId.ts @@ -0,0 +1,19 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTelegramMessageThreadId1734786596045 + implements MigrationInterface +{ + name = 'AddTelegramMessageThreadId1734786596045'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "telegramMessageThreadId" character varying` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "telegramMessageThreadId"` + ); + } +} diff --git a/server/migration/postgres/1734805738349-AddOverrideRules.ts b/server/migration/postgres/1734805738349-AddOverrideRules.ts new file mode 100644 index 00000000..b9cc4721 --- /dev/null +++ b/server/migration/postgres/1734805738349-AddOverrideRules.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOverrideRules1734805738349 implements MigrationInterface { + name = 'AddOverrideRules1734805738349'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "override_rule" ("id" SERIAL NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" character varying, "genre" character varying, "language" character varying, "keywords" character varying, "profileId" integer, "rootFolder" character varying, "tags" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_657f810c7b20a4fce45aee8f182" PRIMARY KEY ("id"))` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "override_rule"`); + } +} diff --git a/server/migration/postgres/1734809898562-FixNullFields.ts b/server/migration/postgres/1734809898562-FixNullFields.ts new file mode 100644 index 00000000..b36cbac9 --- /dev/null +++ b/server/migration/postgres/1734809898562-FixNullFields.ts @@ -0,0 +1,65 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FixNullFields1734809898562 implements MigrationInterface { + name = 'FixNullFields1734809898562'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ALTER COLUMN "mediaId" DROP NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "media_request" DROP CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0"` + ); + await queryRunner.query( + `ALTER TABLE "media_request" ALTER COLUMN "mediaId" DROP NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "season" DROP CONSTRAINT "FK_087099b39600be695591da9a49c"` + ); + await queryRunner.query( + `ALTER TABLE "season" ALTER COLUMN "mediaId" DROP NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "media_request" ADD CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "season" ADD CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "season" DROP CONSTRAINT "FK_087099b39600be695591da9a49c"` + ); + await queryRunner.query( + `ALTER TABLE "media_request" DROP CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0"` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"` + ); + await queryRunner.query( + `ALTER TABLE "season" ALTER COLUMN "mediaId" SET NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "season" ADD CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "media_request" ALTER COLUMN "mediaId" SET NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "media_request" ADD CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ALTER COLUMN "mediaId" SET NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } +} diff --git a/server/migration/1603944374840-InitialMigration.ts b/server/migration/sqlite/1603944374840-InitialMigration.ts similarity index 100% rename from server/migration/1603944374840-InitialMigration.ts rename to server/migration/sqlite/1603944374840-InitialMigration.ts diff --git a/server/migration/1605085519544-SeasonStatus.ts b/server/migration/sqlite/1605085519544-SeasonStatus.ts similarity index 100% rename from server/migration/1605085519544-SeasonStatus.ts rename to server/migration/sqlite/1605085519544-SeasonStatus.ts diff --git a/server/migration/1606730060700-CascadeMigration.ts b/server/migration/sqlite/1606730060700-CascadeMigration.ts similarity index 100% rename from server/migration/1606730060700-CascadeMigration.ts rename to server/migration/sqlite/1606730060700-CascadeMigration.ts diff --git a/server/migration/1607928251245-DropImdbIdConstraint.ts b/server/migration/sqlite/1607928251245-DropImdbIdConstraint.ts similarity index 100% rename from server/migration/1607928251245-DropImdbIdConstraint.ts rename to server/migration/sqlite/1607928251245-DropImdbIdConstraint.ts diff --git a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts b/server/migration/sqlite/1608217312474-AddUserRequestDeleteCascades.ts similarity index 100% rename from server/migration/1608217312474-AddUserRequestDeleteCascades.ts rename to server/migration/sqlite/1608217312474-AddUserRequestDeleteCascades.ts diff --git a/server/migration/1608477467935-AddLastSeasonChangeMedia.ts b/server/migration/sqlite/1608477467935-AddLastSeasonChangeMedia.ts similarity index 100% rename from server/migration/1608477467935-AddLastSeasonChangeMedia.ts rename to server/migration/sqlite/1608477467935-AddLastSeasonChangeMedia.ts diff --git a/server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts b/server/migration/sqlite/1608477467936-ForceDropImdbUniqueConstraint.ts similarity index 100% rename from server/migration/1608477467936-ForceDropImdbUniqueConstraint.ts rename to server/migration/sqlite/1608477467936-ForceDropImdbUniqueConstraint.ts diff --git a/server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts b/server/migration/sqlite/1609236552057-RemoveTmdbIdUniqueConstraint.ts similarity index 100% rename from server/migration/1609236552057-RemoveTmdbIdUniqueConstraint.ts rename to server/migration/sqlite/1609236552057-RemoveTmdbIdUniqueConstraint.ts diff --git a/server/migration/1610070934506-LocalUsers.ts b/server/migration/sqlite/1610070934506-LocalUsers.ts similarity index 100% rename from server/migration/1610070934506-LocalUsers.ts rename to server/migration/sqlite/1610070934506-LocalUsers.ts diff --git a/server/migration/1610370640747-Add4kStatusFields.ts b/server/migration/sqlite/1610370640747-Add4kStatusFields.ts similarity index 100% rename from server/migration/1610370640747-Add4kStatusFields.ts rename to server/migration/sqlite/1610370640747-Add4kStatusFields.ts diff --git a/server/migration/1610522845513-AddMediaAddedFieldToMedia.ts b/server/migration/sqlite/1610522845513-AddMediaAddedFieldToMedia.ts similarity index 100% rename from server/migration/1610522845513-AddMediaAddedFieldToMedia.ts rename to server/migration/sqlite/1610522845513-AddMediaAddedFieldToMedia.ts diff --git a/server/migration/1611508672722-AddDisplayNameToUser.ts b/server/migration/sqlite/1611508672722-AddDisplayNameToUser.ts similarity index 100% rename from server/migration/1611508672722-AddDisplayNameToUser.ts rename to server/migration/sqlite/1611508672722-AddDisplayNameToUser.ts diff --git a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts b/server/migration/sqlite/1611757511674-SonarrRadarrSyncServiceFields.ts similarity index 100% rename from server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts rename to server/migration/sqlite/1611757511674-SonarrRadarrSyncServiceFields.ts diff --git a/server/migration/1611801511397-AddRatingKeysToMedia.ts b/server/migration/sqlite/1611801511397-AddRatingKeysToMedia.ts similarity index 100% rename from server/migration/1611801511397-AddRatingKeysToMedia.ts rename to server/migration/sqlite/1611801511397-AddRatingKeysToMedia.ts diff --git a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts b/server/migration/sqlite/1612482778137-AddResetPasswordGuidAndExpiryDate.ts similarity index 100% rename from server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts rename to server/migration/sqlite/1612482778137-AddResetPasswordGuidAndExpiryDate.ts diff --git a/server/migration/1612571545781-AddLanguageProfileId.ts b/server/migration/sqlite/1612571545781-AddLanguageProfileId.ts similarity index 100% rename from server/migration/1612571545781-AddLanguageProfileId.ts rename to server/migration/sqlite/1612571545781-AddLanguageProfileId.ts diff --git a/server/migration/1613379909641-AddJellyfinUserParams.ts b/server/migration/sqlite/1613379909641-AddJellyfinUserParams.ts similarity index 100% rename from server/migration/1613379909641-AddJellyfinUserParams.ts rename to server/migration/sqlite/1613379909641-AddJellyfinUserParams.ts diff --git a/server/migration/1613412948344-ServerTypeEnum.ts b/server/migration/sqlite/1613412948344-ServerTypeEnum.ts similarity index 100% rename from server/migration/1613412948344-ServerTypeEnum.ts rename to server/migration/sqlite/1613412948344-ServerTypeEnum.ts diff --git a/server/migration/1613615266968-CreateUserSettings.ts b/server/migration/sqlite/1613615266968-CreateUserSettings.ts similarity index 100% rename from server/migration/1613615266968-CreateUserSettings.ts rename to server/migration/sqlite/1613615266968-CreateUserSettings.ts diff --git a/server/migration/1613670041760-AddJellyfinDeviceId.ts b/server/migration/sqlite/1613670041760-AddJellyfinDeviceId.ts similarity index 100% rename from server/migration/1613670041760-AddJellyfinDeviceId.ts rename to server/migration/sqlite/1613670041760-AddJellyfinDeviceId.ts diff --git a/server/migration/1613955393450-UpdateUserSettingsRegions.ts b/server/migration/sqlite/1613955393450-UpdateUserSettingsRegions.ts similarity index 100% rename from server/migration/1613955393450-UpdateUserSettingsRegions.ts rename to server/migration/sqlite/1613955393450-UpdateUserSettingsRegions.ts diff --git a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts b/server/migration/sqlite/1614334195680-AddTelegramSettingsToUserSettings.ts similarity index 100% rename from server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts rename to server/migration/sqlite/1614334195680-AddTelegramSettingsToUserSettings.ts diff --git a/server/migration/1615333940450-AddPGPToUserSettings.ts b/server/migration/sqlite/1615333940450-AddPGPToUserSettings.ts similarity index 100% rename from server/migration/1615333940450-AddPGPToUserSettings.ts rename to server/migration/sqlite/1615333940450-AddPGPToUserSettings.ts diff --git a/server/migration/1616576677254-AddUserQuotaFields.ts b/server/migration/sqlite/1616576677254-AddUserQuotaFields.ts similarity index 100% rename from server/migration/1616576677254-AddUserQuotaFields.ts rename to server/migration/sqlite/1616576677254-AddUserQuotaFields.ts diff --git a/server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts b/server/migration/sqlite/1617624225464-CreateTagsFieldonMediaRequest.ts similarity index 100% rename from server/migration/1617624225464-CreateTagsFieldonMediaRequest.ts rename to server/migration/sqlite/1617624225464-CreateTagsFieldonMediaRequest.ts diff --git a/server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts b/server/migration/sqlite/1617730837489-AddUserSettingsNotificationAgentsField.ts similarity index 100% rename from server/migration/1617730837489-AddUserSettingsNotificationAgentsField.ts rename to server/migration/sqlite/1617730837489-AddUserSettingsNotificationAgentsField.ts diff --git a/server/migration/1618912653565-CreateUserPushSubscriptions.ts b/server/migration/sqlite/1618912653565-CreateUserPushSubscriptions.ts similarity index 100% rename from server/migration/1618912653565-CreateUserPushSubscriptions.ts rename to server/migration/sqlite/1618912653565-CreateUserPushSubscriptions.ts diff --git a/server/migration/1619239659754-AddUserSettingsLocale.ts b/server/migration/sqlite/1619239659754-AddUserSettingsLocale.ts similarity index 100% rename from server/migration/1619239659754-AddUserSettingsLocale.ts rename to server/migration/sqlite/1619239659754-AddUserSettingsLocale.ts diff --git a/server/migration/1619339817343-AddUserSettingsNotificationTypes.ts b/server/migration/sqlite/1619339817343-AddUserSettingsNotificationTypes.ts similarity index 100% rename from server/migration/1619339817343-AddUserSettingsNotificationTypes.ts rename to server/migration/sqlite/1619339817343-AddUserSettingsNotificationTypes.ts diff --git a/server/migration/1634904083966-AddIssues.ts b/server/migration/sqlite/1634904083966-AddIssues.ts similarity index 100% rename from server/migration/1634904083966-AddIssues.ts rename to server/migration/sqlite/1634904083966-AddIssues.ts diff --git a/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts b/server/migration/sqlite/1635079863457-AddPushbulletPushoverUserSettings.ts similarity index 100% rename from server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts rename to server/migration/sqlite/1635079863457-AddPushbulletPushoverUserSettings.ts diff --git a/server/migration/1660632269368-AddWatchlistSyncUserSetting.ts b/server/migration/sqlite/1660632269368-AddWatchlistSyncUserSetting.ts similarity index 100% rename from server/migration/1660632269368-AddWatchlistSyncUserSetting.ts rename to server/migration/sqlite/1660632269368-AddWatchlistSyncUserSetting.ts diff --git a/server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts b/server/migration/sqlite/1660714479373-AddMediaRequestIsAutoRequestedField.ts similarity index 100% rename from server/migration/1660714479373-AddMediaRequestIsAutoRequestedField.ts rename to server/migration/sqlite/1660714479373-AddMediaRequestIsAutoRequestedField.ts diff --git a/server/migration/1672041273674-AddDiscoverSlider.ts b/server/migration/sqlite/1672041273674-AddDiscoverSlider.ts similarity index 100% rename from server/migration/1672041273674-AddDiscoverSlider.ts rename to server/migration/sqlite/1672041273674-AddDiscoverSlider.ts diff --git a/server/migration/1682608634546-AddWatchlists.ts b/server/migration/sqlite/1682608634546-AddWatchlists.ts similarity index 100% rename from server/migration/1682608634546-AddWatchlists.ts rename to server/migration/sqlite/1682608634546-AddWatchlists.ts diff --git a/server/migration/1697393491630-AddUserPushoverSound.ts b/server/migration/sqlite/1697393491630-AddUserPushoverSound.ts similarity index 100% rename from server/migration/1697393491630-AddUserPushoverSound.ts rename to server/migration/sqlite/1697393491630-AddUserPushoverSound.ts diff --git a/server/migration/1699901142442-AddBlacklist.ts b/server/migration/sqlite/1699901142442-AddBlacklist.ts similarity index 100% rename from server/migration/1699901142442-AddBlacklist.ts rename to server/migration/sqlite/1699901142442-AddBlacklist.ts diff --git a/server/migration/sqlite/1727907530757-AddUserSettingsStreamingRegion.ts b/server/migration/sqlite/1727907530757-AddUserSettingsStreamingRegion.ts new file mode 100644 index 00000000..bd7a183b --- /dev/null +++ b/server/migration/sqlite/1727907530757-AddUserSettingsStreamingRegion.ts @@ -0,0 +1,53 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserSettingsStreamingRegion1727907530757 + implements MigrationInterface +{ + name = 'AddUserSettingsStreamingRegion1727907530757'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, "discoverRegion" varchar, "streamingRegion" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, "region" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/migration/sqlite/1734287582736-AddTelegramMessageThreadId.ts b/server/migration/sqlite/1734287582736-AddTelegramMessageThreadId.ts new file mode 100644 index 00000000..94a76b99 --- /dev/null +++ b/server/migration/sqlite/1734287582736-AddTelegramMessageThreadId.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTelegramMessageThreadId1734287582736 + implements MigrationInterface +{ + name = 'AddTelegramMessageThreadId1734287582736'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/migration/sqlite/1734805733535-AddOverrideRules.ts b/server/migration/sqlite/1734805733535-AddOverrideRules.ts new file mode 100644 index 00000000..692dc875 --- /dev/null +++ b/server/migration/sqlite/1734805733535-AddOverrideRules.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOverrideRules1734805733535 implements MigrationInterface { + name = 'AddOverrideRules1734805733535'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "override_rule"`); + } +} diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts index 2d72e2f1..5938fa94 100644 --- a/server/routes/avatarproxy.ts +++ b/server/routes/avatarproxy.ts @@ -54,9 +54,15 @@ router.get('/:jellyfinUserId', async (req, res) => { default: 'mm', size: 200, }); - const jellyfinAvatarUrl = `${getHostname()}/UserImage?UserId=${ - req.params.jellyfinUserId - }`; + + const setttings = getSettings(); + const jellyfinAvatarUrl = + setttings.main.mediaServerType === MediaServerType.JELLYFIN + ? `${getHostname()}/UserImage?UserId=${req.params.jellyfinUserId}` + : `${getHostname()}/Users/${ + req.params.jellyfinUserId + }/Images/Primary?quality=90`; + let imageData = await avatarImageCache.getImage( jellyfinAvatarUrl, fallbackUrl diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 55a844ad..4bb12740 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -29,12 +29,12 @@ import { z } from 'zod'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const settings = getSettings(); - const region = - user?.settings?.region === 'all' + const discoverRegion = + user?.settings?.streamingRegion === 'all' ? '' - : user?.settings?.region - ? user?.settings?.region - : settings.main.region; + : user?.settings?.streamingRegion + ? user?.settings?.streamingRegion + : settings.main.discoverRegion; const originalLanguage = user?.settings?.originalLanguage === 'all' @@ -44,7 +44,7 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { : settings.main.originalLanguage; return new TheMovieDb({ - region, + discoverRegion, originalLanguage, }); }; @@ -875,6 +875,7 @@ discoverRoutes.get, WatchlistResponse>( totalPages: Math.ceil(watchlist.totalSize / itemsPerPage), totalResults: watchlist.totalSize, results: watchlist.items.map((item) => ({ + id: item.tmdbId, ratingKey: item.ratingKey, title: item.title, mediaType: item.type === 'show' ? 'tv' : 'movie', diff --git a/server/routes/index.ts b/server/routes/index.ts index 120e2e86..f064e603 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -15,6 +15,7 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth'; import { mapWatchProviderDetails } from '@server/models/common'; import { mapProductionCompany } from '@server/models/Movie'; import { mapNetwork } from '@server/models/Tv'; +import overrideRuleRoutes from '@server/routes/overrideRule'; import settingsRoutes from '@server/routes/settings'; import watchlistRoutes from '@server/routes/watchlist'; import { @@ -160,6 +161,11 @@ router.use('/service', isAuthenticated(), serviceRoutes); router.use('/issue', isAuthenticated(), issueRoutes); router.use('/issueComment', isAuthenticated(), issueCommentRoutes); router.use('/auth', authRoutes); +router.use( + '/overrideRule', + isAuthenticated(Permission.ADMIN), + overrideRuleRoutes +); router.get('/regions', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); diff --git a/server/routes/overrideRule.ts b/server/routes/overrideRule.ts new file mode 100644 index 00000000..912a68aa --- /dev/null +++ b/server/routes/overrideRule.ts @@ -0,0 +1,136 @@ +import { getRepository } from '@server/datasource'; +import OverrideRule from '@server/entity/OverrideRule'; +import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; +import { Permission } from '@server/lib/permissions'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; + +const overrideRuleRoutes = Router(); + +overrideRuleRoutes.get( + '/', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const overrideRuleRepository = getRepository(OverrideRule); + + try { + const rules = await overrideRuleRepository.find({}); + + return res.status(200).json(rules as OverrideRuleResultsResponse); + } catch (e) { + next({ status: 404, message: e.message }); + } + } +); + +overrideRuleRoutes.post< + Record, + OverrideRule, + { + users?: string; + genre?: string; + language?: string; + keywords?: string; + profileId?: number; + rootFolder?: string; + tags?: string; + radarrServiceId?: number; + sonarrServiceId?: number; + } +>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => { + const overrideRuleRepository = getRepository(OverrideRule); + + try { + const rule = new OverrideRule({ + users: req.body.users, + genre: req.body.genre, + language: req.body.language, + keywords: req.body.keywords, + profileId: req.body.profileId, + rootFolder: req.body.rootFolder, + tags: req.body.tags, + radarrServiceId: req.body.radarrServiceId, + sonarrServiceId: req.body.sonarrServiceId, + }); + + const newRule = await overrideRuleRepository.save(rule); + + return res.status(200).json(newRule); + } catch (e) { + next({ status: 404, message: e.message }); + } +}); + +overrideRuleRoutes.put< + { ruleId: string }, + OverrideRule, + { + users?: string; + genre?: string; + language?: string; + keywords?: string; + profileId?: number; + rootFolder?: string; + tags?: string; + radarrServiceId?: number; + sonarrServiceId?: number; + } +>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => { + const overrideRuleRepository = getRepository(OverrideRule); + + try { + const rule = await overrideRuleRepository.findOne({ + where: { + id: Number(req.params.ruleId), + }, + }); + + if (!rule) { + return next({ status: 404, message: 'Override Rule not found.' }); + } + + rule.users = req.body.users; + rule.genre = req.body.genre; + rule.language = req.body.language; + rule.keywords = req.body.keywords; + rule.profileId = req.body.profileId; + rule.rootFolder = req.body.rootFolder; + rule.tags = req.body.tags; + rule.radarrServiceId = req.body.radarrServiceId; + rule.sonarrServiceId = req.body.sonarrServiceId; + + const newRule = await overrideRuleRepository.save(rule); + + return res.status(200).json(newRule); + } catch (e) { + next({ status: 404, message: e.message }); + } +}); + +overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>( + '/:ruleId', + isAuthenticated(Permission.ADMIN), + async (req, res, next) => { + const overrideRuleRepository = getRepository(OverrideRule); + + try { + const rule = await overrideRuleRepository.findOne({ + where: { + id: Number(req.params.ruleId), + }, + }); + + if (!rule) { + return next({ status: 404, message: 'Override Rule not found.' }); + } + + await overrideRuleRepository.remove(rule); + + return res.status(200).json(rule); + } catch (e) { + next({ status: 404, message: e.message }); + } + } +); + +export default overrideRuleRoutes; diff --git a/server/routes/request.ts b/server/routes/request.ts index 320f149b..89e5352f 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -94,6 +94,7 @@ requestRoutes.get, RequestResultsResponse>( } let sortFilter: string; + let sortDirection: 'ASC' | 'DESC'; switch (req.query.sort) { case 'modified': @@ -103,6 +104,14 @@ requestRoutes.get, RequestResultsResponse>( sortFilter = 'request.id'; } + switch (req.query.sortDirection) { + case 'asc': + sortDirection = 'ASC'; + break; + default: + sortDirection = 'DESC'; + } + let query = getRepository(MediaRequest) .createQueryBuilder('request') .leftJoinAndSelect('request.media', 'media') @@ -113,7 +122,7 @@ requestRoutes.get, RequestResultsResponse>( requestStatus: statusFilter, }) .andWhere( - '((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))', + '((request.is4k = false AND media.status IN (:...mediaStatus)) OR (request.is4k = true AND media.status4k IN (:...mediaStatus)))', { mediaStatus: mediaStatusFilter, } @@ -142,7 +151,7 @@ requestRoutes.get, RequestResultsResponse>( } const [requests, requestCount] = await query - .orderBy(sortFilter, 'DESC') + .orderBy(sortFilter, sortDirection) .take(pageSize) .skip(skip) .getManyAndCount(); @@ -159,7 +168,7 @@ requestRoutes.get, RequestResultsResponse>( return { id: sonarrSetting.id, - profiles: await sonarr.getProfiles(), + profiles: await sonarr.getProfiles().catch(() => undefined), }; }) ); @@ -174,7 +183,7 @@ requestRoutes.get, RequestResultsResponse>( return { id: radarrSetting.id, - profiles: await radarr.getProfiles(), + profiles: await radarr.getProfiles().catch(() => undefined), }; }) ); @@ -185,7 +194,7 @@ requestRoutes.get, RequestResultsResponse>( case MediaType.MOVIE: { const profileName = radarrServers .find((serverr) => serverr.id === r.serverId) - ?.profiles.find((profile) => profile.id === r.profileId)?.name; + ?.profiles?.find((profile) => profile.id === r.profileId)?.name; return { ...r, @@ -197,7 +206,7 @@ requestRoutes.get, RequestResultsResponse>( ...r, profileName: sonarrServers .find((serverr) => serverr.id === r.serverId) - ?.profiles.find((profile) => profile.id === r.profileId)?.name, + ?.profiles?.find((profile) => profile.id === r.profileId)?.name, }; } } diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 2a29c037..6c6f7515 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -34,8 +34,16 @@ router.get('/', async (req, res, next) => { try { const pageSize = req.query.take ? Number(req.query.take) : 10; const skip = req.query.skip ? Number(req.query.skip) : 0; + const q = req.query.q ? req.query.q.toString().toLowerCase() : ''; let query = getRepository(User).createQueryBuilder('user'); + if (q) { + query = query.where( + 'LOWER(user.username) LIKE :q OR LOWER(user.email) LIKE :q OR LOWER(user.plexUsername) LIKE :q OR LOWER(user.jellyfinUsername) LIKE :q', + { q: `%${q}%` } + ); + } + switch (req.query.sort) { case 'updated': query = query.orderBy('user.updatedAt', 'DESC'); @@ -45,7 +53,7 @@ router.get('/', async (req, res, next) => { `CASE WHEN (user.username IS NULL OR user.username = '') THEN ( CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN ( CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN - user.email + "user"."email" ELSE LOWER(user.jellyfinUsername) END) @@ -764,6 +772,7 @@ router.get<{ id: string }, WatchlistResponse>( totalPages: Math.ceil(watchlist.totalSize / itemsPerPage), totalResults: watchlist.totalSize, results: watchlist.items.map((item) => ({ + id: item.tmdbId, ratingKey: item.ratingKey, title: item.title, mediaType: item.type === 'show' ? 'tv' : 'movie', diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 11cbd666..24ca976b 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,4 +1,5 @@ import { ApiErrorCode } from '@server/constants/error'; +import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { UserSettings } from '@server/entity/UserSettings'; @@ -56,7 +57,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( email: user.email, discordId: user.settings?.discordId, locale: user.settings?.locale, - region: user.settings?.region, + discoverRegion: user.settings?.discoverRegion, + streamingRegion: user.settings?.streamingRegion, originalLanguage: user.settings?.originalLanguage, movieQuotaLimit: user.movieQuotaLimit, movieQuotaDays: user.movieQuotaDays, @@ -99,11 +101,29 @@ userSettingsRoutes.post< }); } - user.username = req.body.username; const oldEmail = user.email; + const oldUsername = user.username; + user.username = req.body.username; if (user.jellyfinUsername) { user.email = req.body.email || user.jellyfinUsername || user.email; } + // Edge case for local users, because they have no Jellyfin username to fall back on + // if the email is not provided + if (user.userType === UserType.LOCAL) { + if (req.body.email) { + user.email = req.body.email; + if ( + !user.username && + user.email !== oldEmail && + !oldEmail.includes('@') + ) { + user.username = oldEmail; + } + } else if (req.body.username) { + user.email = oldUsername || user.email; + user.username = req.body.username; + } + } const existingUser = await userRepository.findOne({ where: { email: user.email }, @@ -128,7 +148,8 @@ userSettingsRoutes.post< user: req.user, discordId: req.body.discordId, locale: req.body.locale, - region: req.body.region, + discoverRegion: req.body.discoverRegion, + streamingRegion: req.body.streamingRegion, originalLanguage: req.body.originalLanguage, watchlistSyncMovies: req.body.watchlistSyncMovies, watchlistSyncTv: req.body.watchlistSyncTv, @@ -136,7 +157,8 @@ userSettingsRoutes.post< } else { user.settings.discordId = req.body.discordId; user.settings.locale = req.body.locale; - user.settings.region = req.body.region; + user.settings.discoverRegion = req.body.discoverRegion; + user.settings.streamingRegion = req.body.streamingRegion; user.settings.originalLanguage = req.body.originalLanguage; user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies; user.settings.watchlistSyncTv = req.body.watchlistSyncTv; @@ -148,7 +170,8 @@ userSettingsRoutes.post< username: savedUser.username, discordId: savedUser.settings?.discordId, locale: savedUser.settings?.locale, - region: savedUser.settings?.region, + discoverRegion: savedUser.settings?.discoverRegion, + streamingRegion: savedUser.settings?.streamingRegion, originalLanguage: savedUser.settings?.originalLanguage, watchlistSyncMovies: savedUser.settings?.watchlistSyncMovies, watchlistSyncTv: savedUser.settings?.watchlistSyncTv, @@ -300,6 +323,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( telegramEnabled: settings.telegram.enabled, telegramBotUsername: settings.telegram.options.botUsername, telegramChatId: user.settings?.telegramChatId, + telegramMessageThreadId: user.settings?.telegramMessageThreadId, telegramSendSilently: user.settings?.telegramSendSilently, webPushEnabled: settings.webpush.enabled, notificationTypes: user.settings?.notificationTypes ?? {}, @@ -342,6 +366,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( pushoverApplicationToken: req.body.pushoverApplicationToken, pushoverUserKey: req.body.pushoverUserKey, telegramChatId: req.body.telegramChatId, + telegramMessageThreadId: req.body.telegramMessageThreadId, telegramSendSilently: req.body.telegramSendSilently, notificationTypes: req.body.notificationTypes, }); @@ -354,6 +379,8 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( user.settings.pushoverUserKey = req.body.pushoverUserKey; user.settings.pushoverSound = req.body.pushoverSound; user.settings.telegramChatId = req.body.telegramChatId; + user.settings.telegramMessageThreadId = + req.body.telegramMessageThreadId; user.settings.telegramSendSilently = req.body.telegramSendSilently; user.settings.notificationTypes = Object.assign( {}, @@ -372,6 +399,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( pushoverUserKey: user.settings.pushoverUserKey, pushoverSound: user.settings.pushoverSound, telegramChatId: user.settings.telegramChatId, + telegramMessageThreadId: user.settings.telegramMessageThreadId, telegramSendSilently: user.settings.telegramSendSilently, notificationTypes: user.settings.notificationTypes, }); diff --git a/server/utils/DbColumnHelper.ts b/server/utils/DbColumnHelper.ts new file mode 100644 index 00000000..1c030b92 --- /dev/null +++ b/server/utils/DbColumnHelper.ts @@ -0,0 +1,20 @@ +import { isPgsql } from '@server/datasource'; +import type { ColumnOptions, ColumnType } from 'typeorm'; +import { Column } from 'typeorm'; +const pgTypeMapping: { [key: string]: ColumnType } = { + datetime: 'timestamp with time zone', +}; + +export function resolveDbType(pgType: ColumnType): ColumnType { + if (isPgsql && pgType.toString() in pgTypeMapping) { + return pgTypeMapping[pgType.toString()]; + } + return pgType; +} + +export function DbAwareColumn(columnOptions: ColumnOptions) { + if (columnOptions.type) { + columnOptions.type = resolveDbType(columnOptions.type); + } + return Column(columnOptions); +} diff --git a/src/components/BlacklistModal/index.tsx b/src/components/BlacklistModal/index.tsx index def49c13..4ef1a7b6 100644 --- a/src/components/BlacklistModal/index.tsx +++ b/src/components/BlacklistModal/index.tsx @@ -4,8 +4,8 @@ import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; +import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; -import useSWR from 'swr'; interface BlacklistModalProps { tmdbId: number; @@ -21,7 +21,7 @@ const messages = defineMessages('component.BlacklistModal', { }); const isMovie = ( - movie: MovieDetails | TvDetails | undefined + movie: MovieDetails | TvDetails | null ): movie is MovieDetails => { if (!movie) return false; return (movie as MovieDetails).title !== undefined; @@ -36,10 +36,25 @@ const BlacklistModal = ({ isUpdating, }: BlacklistModalProps) => { const intl = useIntl(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); - const { data, error } = useSWR( - show ? `/api/v1/${type}/${tmdbId}` : null - ); + useEffect(() => { + (async () => { + if (!show) return; + try { + setError(null); + const response = await fetch(`/api/v1/${type}/${tmdbId}`); + if (!response.ok) { + throw new Error(); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError(err); + } + })(); + }, [show, tmdbId, type]); return ( { @@ -35,23 +39,33 @@ const DropdownItem = ({ ); }; -interface ButtonWithDropdownProps - extends ButtonHTMLAttributes { +interface ButtonWithDropdownProps { text: React.ReactNode; dropdownIcon?: React.ReactNode; buttonType?: 'primary' | 'ghost'; } +interface ButtonProps + extends ButtonHTMLAttributes, + ButtonWithDropdownProps { + as?: 'button'; +} +interface AnchorProps + extends AnchorHTMLAttributes, + ButtonWithDropdownProps { + as: 'a'; +} const ButtonWithDropdown = ({ + as, text, children, dropdownIcon, className, buttonType = 'primary', ...props -}: ButtonWithDropdownProps) => { +}: ButtonProps | AnchorProps) => { const [isOpen, setIsOpen] = useState(false); - const buttonRef = useRef(null); + const buttonRef = useRef(null); useClickOutside(buttonRef, () => setIsOpen(false)); const styleClasses = { @@ -78,16 +92,28 @@ const ButtonWithDropdown = ({ return ( - + {as === 'a' ? ( + } + {...(props as AnchorHTMLAttributes)} + > + {text} + + ) : ( + + )} {children && ( + diff --git a/src/components/RequestModal/SearchByNameModal/index.tsx b/src/components/RequestModal/SearchByNameModal/index.tsx index 0ef7e55b..1b86b614 100644 --- a/src/components/RequestModal/SearchByNameModal/index.tsx +++ b/src/components/RequestModal/SearchByNameModal/index.tsx @@ -88,14 +88,14 @@ const SearchByNameModal = ({ tvdbId === item.tvdbId ? 'ring ring-indigo-500' : '' } `} > -
+
{item.title}
diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 71750678..18579d64 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -42,7 +42,6 @@ const messages = defineMessages('components.RequestModal', { season: 'Season', numberofepisodes: '# of Episodes', seasonnumber: 'Season {number}', - extras: 'Extras', errorediting: 'Something went wrong while editing the request.', requestedited: 'Request for {title} edited successfully!', requestApproved: 'Request for {title} approved!', @@ -254,11 +253,13 @@ const TvRequestModal = ({ }; const getAllSeasons = (): number[] => { - return (data?.seasons ?? []) - .filter( - (season) => season.seasonNumber !== 0 && season.episodeCount !== 0 - ) - .map((season) => season.seasonNumber); + let allSeasons = (data?.seasons ?? []).filter( + (season) => season.episodeCount !== 0 + ); + if (!settings.currentSettings.partialRequestsEnabled) { + allSeasons = allSeasons.filter((season) => season.seasonNumber !== 0); + } + return allSeasons.map((season) => season.seasonNumber); }; const getAllRequestedSeasons = (): number[] => { @@ -582,7 +583,9 @@ const TvRequestModal = ({ {data?.seasons .filter( (season) => - season.seasonNumber !== 0 && season.episodeCount !== 0 + (!settings.currentSettings.enableSpecialEpisodes + ? season.seasonNumber !== 0 + : true) && season.episodeCount !== 0 ) .map((season) => { const seasonRequest = getSeasonRequest( @@ -660,7 +663,7 @@ const TvRequestModal = ({ {season.seasonNumber === 0 - ? intl.formatMessage(messages.extras) + ? intl.formatMessage(globalMessages.specials) : intl.formatMessage(messages.seasonnumber, { number: season.seasonNumber, })} diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index db959170..6c831909 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -13,6 +13,7 @@ import type { TmdbKeywordSearchResponse, } from '@server/api/themoviedb/interfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; +import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; import type { Keyword, ProductionCompany, @@ -29,6 +30,7 @@ const messages = defineMessages('components.Selector', { searchKeywords: 'Search keywords…', searchGenres: 'Select genres…', searchStudios: 'Search studios…', + searchUsers: 'Select users…', starttyping: 'Starting typing to search.', nooptions: 'No results.', showmore: 'Show More', @@ -374,7 +376,11 @@ export const WatchProviderSelector = ({ const { currentSettings } = useSettings(); const [showMore, setShowMore] = useState(false); const [watchRegion, setWatchRegion] = useState( - region ? region : currentSettings.region ? currentSettings.region : 'US' + region + ? region + : currentSettings.discoverRegion + ? currentSettings.discoverRegion + : 'US' ); const [activeProvider, setActiveProvider] = useState( activeProviders ?? [] @@ -437,7 +443,7 @@ export const WatchProviderSelector = ({ key={`prodiver-${provider.id}`} >
- +
+ +
{isActive && (
@@ -483,7 +486,7 @@ export const WatchProviderSelector = ({ key={`prodiver-${provider.id}`} >
- +
+ +
{isActive && (
@@ -548,3 +548,77 @@ export const WatchProviderSelector = ({ ); }; + +export const UserSelector = ({ + isMulti, + defaultValue, + onChange, +}: BaseSelectorMultiProps | BaseSelectorSingleProps) => { + const intl = useIntl(); + const [defaultDataValue, setDefaultDataValue] = useState< + { label: string; value: number }[] | null + >(null); + + useEffect(() => { + const loadUsers = async (): Promise => { + if (!defaultValue) { + return; + } + + const users = defaultValue.split(','); + + const res = await fetch(`/api/v1/user`); + if (!res.ok) { + throw new Error('Network response was not ok'); + } + const response: UserResultsResponse = await res.json(); + + const genreData = users + .filter((u) => response.results.find((user) => user.id === Number(u))) + .map((u) => response.results.find((user) => user.id === Number(u))) + .map((u) => ({ + label: u?.displayName ?? '', + value: u?.id ?? 0, + })); + + setDefaultDataValue(genreData); + }; + + loadUsers(); + }, [defaultValue]); + + const loadUserOptions = async (inputValue: string) => { + const res = await fetch( + `/api/v1/user${inputValue ? `?q=${encodeURIComponent(inputValue)}` : ''}` + ); + if (!res.ok) throw new Error(); + const results: UserResultsResponse = await res.json(); + + return results.results + .map((result) => ({ + label: result.displayName, + value: result.id, + })) + .filter(({ label }) => + label.toLowerCase().includes(inputValue.toLowerCase()) + ); + }; + + return ( + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange(value as any); + }} + /> + ); +}; diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx index b62263fb..82ac6840 100644 --- a/src/components/Settings/Notifications/NotificationsDiscord.tsx +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -19,12 +19,16 @@ const messages = defineMessages('components.Settings.Notifications', { webhookUrl: 'Webhook URL', webhookUrlTip: 'Create a webhook integration in your server', + webhookRoleId: 'Notification Role ID', + webhookRoleIdTip: + 'The role ID to mention in the webhook message. Leave empty to disable mentions', discordsettingssaved: 'Discord notification settings saved successfully!', discordsettingsfailed: 'Discord notification settings failed to save.', toastDiscordTestSending: 'Sending Discord test notification…', toastDiscordTestSuccess: 'Discord test notification sent!', toastDiscordTestFailed: 'Discord test notification failed to send.', validationUrl: 'You must provide a valid URL', + validationWebhookRoleId: 'You must provide a valid Discord Role ID', validationTypes: 'You must select at least one notification type', enableMentions: 'Enable Mentions', }); @@ -53,6 +57,12 @@ const NotificationsDiscord = () => { otherwise: Yup.string().nullable(), }) .url(intl.formatMessage(messages.validationUrl)), + webhookRoleId: Yup.string() + .nullable() + .matches( + /^\d{17,19}$/, + intl.formatMessage(messages.validationWebhookRoleId) + ), }); if (!data && !error) { @@ -67,6 +77,7 @@ const NotificationsDiscord = () => { botUsername: data?.options.botUsername, botAvatarUrl: data?.options.botAvatarUrl, webhookUrl: data.options.webhookUrl, + webhookRoleId: data?.options.webhookRoleId, enableMentions: data?.options.enableMentions, }} validationSchema={NotificationsDiscordSchema} @@ -84,6 +95,7 @@ const NotificationsDiscord = () => { botUsername: values.botUsername, botAvatarUrl: values.botAvatarUrl, webhookUrl: values.webhookUrl, + webhookRoleId: values.webhookRoleId, enableMentions: values.enableMentions, }, }), @@ -141,6 +153,7 @@ const NotificationsDiscord = () => { botUsername: values.botUsername, botAvatarUrl: values.botAvatarUrl, webhookUrl: values.webhookUrl, + webhookRoleId: values.webhookRoleId, enableMentions: values.enableMentions, }, }), @@ -254,6 +267,21 @@ const NotificationsDiscord = () => { )}
+
+ +
+
+ +
+ {errors.webhookRoleId && + touched.webhookRoleId && + typeof errors.webhookRoleId === 'string' && ( +
{errors.webhookRoleId}
+ )} +
+
+
+ +
+
+ +
+ {errors.messageThreadId && + touched.messageThreadId && + typeof errors.messageThreadId === 'string' && ( +
{errors.messageThreadId}
+ )} +
+
+

+ {intl.formatMessage(messages.overrideRules)} +

+
    + {rules && ( + + )} +
  • +
    + +
    +
  • +
); }} diff --git a/src/components/Settings/SettingsJellyfin.tsx b/src/components/Settings/SettingsJellyfin.tsx index 316dc48e..7c6d02d8 100644 --- a/src/components/Settings/SettingsJellyfin.tsx +++ b/src/components/Settings/SettingsJellyfin.tsx @@ -139,7 +139,10 @@ const SettingsJellyfin: React.FC = ({ ), jellyfinExternalUrl: Yup.string() .nullable() - .url(intl.formatMessage(messages.validationUrl)) + .matches( + /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i, + intl.formatMessage(messages.validationUrl) + ) .test( 'no-trailing-slash', intl.formatMessage(messages.validationUrlTrailingSlash), @@ -147,7 +150,10 @@ const SettingsJellyfin: React.FC = ({ ), jellyfinForgotPasswordUrl: Yup.string() .nullable() - .url(intl.formatMessage(messages.validationUrl)) + .matches( + /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i, + intl.formatMessage(messages.validationUrl) + ) .test( 'no-trailing-slash', intl.formatMessage(messages.validationUrlTrailingSlash), diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 975de36c..aeba1531 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -58,6 +58,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages( 'plex-recently-added-scan': 'Plex Recently Added Scan', 'plex-full-scan': 'Plex Full Library Scan', 'plex-watchlist-sync': 'Plex Watchlist Sync', + 'plex-refresh-token': 'Plex Refresh Token', 'jellyfin-full-scan': 'Jellyfin Full Library Scan', 'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan', 'availability-sync': 'Media Availability Sync', diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 2d1e0219..8020b9fe 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -31,10 +31,12 @@ const messages = defineMessages('components.Settings.SettingsMain', { apikey: 'API Key', applicationTitle: 'Application Title', applicationurl: 'Application URL', - region: 'Discover Region', - regionTip: 'Filter content by regional availability', + discoverRegion: 'Discover Region', + discoverRegionTip: 'Filter content by regional availability', originallanguage: 'Discover Language', originallanguageTip: 'Filter content by original language', + streamingRegion: 'Streaming Region', + streamingRegionTip: 'Show streaming sites by regional availability', toastApiKeySuccess: 'New API key generated successfully!', toastApiKeyFailure: 'Something went wrong while generating a new API key.', toastSettingsSuccess: 'Settings saved successfully!', @@ -54,6 +56,7 @@ const messages = defineMessages('components.Settings.SettingsMain', { validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', partialRequestsEnabled: 'Allow Partial Series Requests', + enableSpecialEpisodes: 'Allow Special Episodes Requests', locale: 'Display Language', proxyEnabled: 'HTTP(S) Proxy', proxyHostname: 'Proxy Hostname', @@ -87,7 +90,10 @@ const SettingsMain = () => { intl.formatMessage(messages.validationApplicationTitle) ), applicationUrl: Yup.string() - .url(intl.formatMessage(messages.validationApplicationUrl)) + .matches( + /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i, + intl.formatMessage(messages.validationApplicationUrl) + ) .test( 'no-trailing-slash', intl.formatMessage(messages.validationApplicationUrlTrailingSlash), @@ -149,9 +155,11 @@ const SettingsMain = () => { csrfProtection: data?.csrfProtection, hideAvailable: data?.hideAvailable, locale: data?.locale ?? 'en', - region: data?.region, + discoverRegion: data?.discoverRegion, originalLanguage: data?.originalLanguage, + streamingRegion: data?.streamingRegion, partialRequestsEnabled: data?.partialRequestsEnabled, + enableSpecialEpisodes: data?.enableSpecialEpisodes, trustProxy: data?.trustProxy, cacheImages: data?.cacheImages, proxyEnabled: data?.proxy?.enabled, @@ -178,9 +186,11 @@ const SettingsMain = () => { csrfProtection: values.csrfProtection, hideAvailable: values.hideAvailable, locale: values.locale, - region: values.region, + discoverRegion: values.discoverRegion, + streamingRegion: values.streamingRegion, originalLanguage: values.originalLanguage, partialRequestsEnabled: values.partialRequestsEnabled, + enableSpecialEpisodes: values.enableSpecialEpisodes, trustProxy: values.trustProxy, cacheImages: values.cacheImages, proxy: { @@ -399,17 +409,17 @@ const SettingsMain = () => {
-
+
+ +
+
+ +
+
+
+
+ +
+ { + setFieldValue( + 'enableSpecialEpisodes', + !values.enableSpecialEpisodes + ); + }} + /> +
+
+
+
+ + + +
+
{values.proxyEnabled && ( <> -
-