Merge branch 'develop'
This commit is contained in:
40
.github/stale.yml
vendored
40
.github/stale.yml
vendored
@@ -1,18 +1,44 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
|
||||
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- dependencies
|
||||
# Label to use when marking an issue as stale
|
||||
- never-stale
|
||||
- priority:high
|
||||
- priority:medium
|
||||
|
||||
# Label to use when marking as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
|
||||
# Comment to post when removing the stale label.
|
||||
# unmarkComment: >
|
||||
# Your comment here.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
# closeComment: >
|
||||
# Your comment here.
|
||||
|
||||
# Limit to only `issues` or `pulls`
|
||||
# only: issues
|
||||
|
||||
# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls':
|
||||
pulls:
|
||||
markComment: >
|
||||
This pull request has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
|
||||
@@ -7,8 +7,10 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
|
||||
RUN \
|
||||
case "${TARGETPLATFORM}" in \
|
||||
'linux/arm64') apk add --no-cache python make g++ ;; \
|
||||
'linux/arm/v7') apk add --no-cache python make g++ ;; \
|
||||
'linux/arm64' | 'linux/arm/v7') \
|
||||
apk add --no-cache python3 make g++ && \
|
||||
ln -s /usr/bin/python3 /usr/bin/python \
|
||||
;; \
|
||||
esac
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Third-Party Integrations
|
||||
|
||||
{% hint style="warning" %}
|
||||
We do not officially support these third-party integrations. If you run into any issues, please seek help on the appropriate support channels for the integration itself!
|
||||
**We do not officially support these third-party integrations.** If you run into any issues, please seek help on the appropriate support channels for the integration itself!
|
||||
{% endhint %}
|
||||
|
||||
- [Organizr](https://organizr.app/), a HTPC/homelab services organizer
|
||||
@@ -9,6 +9,7 @@ We do not officially support these third-party integrations. If you run into any
|
||||
- [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS
|
||||
- [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot
|
||||
- [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot
|
||||
- [Overseerr Assistant](https://github.com/RemiRigal/Overseerr-Assistant), a browser extension for requesting directly from TMDb and IMDb
|
||||
- [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component
|
||||
- [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool
|
||||
- [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter
|
||||
|
||||
@@ -20,6 +20,12 @@ A more advanced, user-friendly, and secure (if using SSL) method is to set up a
|
||||
|
||||
The most secure method (but also the most inconvenient method) is to set up a VPN tunnel to your home server. You would then be able to access Overseerr as if you were on your local network, via `http://LOCAL-IP-ADDRESS:5055`.
|
||||
|
||||
### Are there mobile apps for Overseerr?
|
||||
|
||||
Since Overseerr has an almost native app experience when installed as a Progressive Web App (PWA), there are no plans to develop mobile apps for Overseerr.
|
||||
|
||||
Out of the box, Overseerr already fulfills most of the [PWA install criteria](https://web.dev/install-criteria/). You simply need to make sure that your Overseerr instance is being served over HTTPS (e.g., via a [reverse proxy](../extending-overseerr/reverse-proxy.md)).
|
||||
|
||||
### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations?
|
||||
|
||||
You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Pushbullet
|
||||
|
||||
{% hint style="info" %}
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
{% endhint %}
|
||||
|
||||
## Configuration
|
||||
|
||||
### Access Token
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Pushover
|
||||
|
||||
{% hint style="info" %}
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
{% endhint %}
|
||||
|
||||
## Configuration
|
||||
|
||||
### Application/API Token
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Telegram
|
||||
|
||||
{% hint style="info" %}
|
||||
Users can optionally configure their own notifications in their user settings.
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
{% endhint %}
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -24,23 +24,28 @@ Customize the JSON payload to suit your needs. Overseerr provides several [templ
|
||||
|
||||
### General
|
||||
|
||||
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
|
||||
- `{{subject}}` The notification subject message. (For request notifications, this is the media title)
|
||||
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis)
|
||||
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
|
||||
| Variable | Value |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
|
||||
| `{{event}}` | A friendly description of the notification event |
|
||||
| `{{subject}}` | The notification subject (typically the media title) |
|
||||
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
|
||||
| `{{image}}` | The notification image (typically the media poster) |
|
||||
|
||||
### User
|
||||
### Notify User
|
||||
|
||||
These variables are for the target recipient of the notification.
|
||||
|
||||
- `{{notifyuser_username}}` Target user's username.
|
||||
- `{{notifyuser_email}}` Target user's email address.
|
||||
- `{{notifyuser_avatar}}` Target user's avatar URL.
|
||||
- `{{notifyuser_settings_discordId}}` Target user's Discord ID (if one is set).
|
||||
- `{{notifyuser_settings_telegramChatId}}` Target user's Telegram Chat ID (if one is set).
|
||||
| Variable | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------------------- |
|
||||
| `{{notifyuser_username}}` | The target notification recipient's username |
|
||||
| `{{notifyuser_email}}` | The target notification recipient's email address |
|
||||
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
|
||||
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
|
||||
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
|
||||
|
||||
{% hint style="info" %}
|
||||
The `notifyuser` variables are not set for the following notification types, as they are intended for application administrators rather than end users:
|
||||
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
|
||||
|
||||
- Media Requested
|
||||
- Media Automatically Approved
|
||||
@@ -59,28 +64,69 @@ If you would like to use the requesting user's information in your webhook, plea
|
||||
|
||||
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
|
||||
|
||||
- `{{request}}` This object will be `null` if there is no relevant request object for the notification.
|
||||
- `{{media}}` This object will be `null` if there is no relevant media object for the notification.
|
||||
- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications.
|
||||
| Variable | Value |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `{{media}}` | The relevant media object |
|
||||
| `{{request}}` | The relevant request object |
|
||||
| `{{issue}}` | The relevant issue object |
|
||||
| `{{comment}}` | The relevant issue comment object |
|
||||
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
|
||||
|
||||
#### Media
|
||||
|
||||
These `{{media}}` special variables are only included in media-related notifications, such as requests.
|
||||
The `{{media}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
- `{{media_type}}` Media type (`movie` or `tv`).
|
||||
- `{{media_tmdbid}}` Media's TMDb ID.
|
||||
- `{{media_imdbid}}` Media's IMDb ID.
|
||||
- `{{media_tvdbid}}` Media's TVDB ID.
|
||||
- `{{media_status}}` Media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`).
|
||||
- `{{media_status4k}}` Media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`)
|
||||
These following special variables are only included in media-related notifications, such as requests.
|
||||
|
||||
| Variable | Value |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{media_type}}` | The media type (`movie` or `tv`) |
|
||||
| `{{media_tmdbid}}` | The media's TMDb ID |
|
||||
| `{{media_tvdbid}}` | The media's TheTVDB ID |
|
||||
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
|
||||
#### Request
|
||||
|
||||
The `{{request}}` special variables are only included in request-related notifications.
|
||||
The `{{request}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
- `{{request_id}}` Request ID.
|
||||
- `{{requestedBy_username}}` Requesting user's username.
|
||||
- `{{requestedBy_email}}` Requesting user's email address.
|
||||
- `{{requestedBy_avatar}}` Requesting user's avatar URL.
|
||||
- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if set).
|
||||
- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if set).
|
||||
The following special variables are only included in request-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------------------------- | ----------------------------------------------- |
|
||||
| `{{request_id}}` | The request ID |
|
||||
| `{{requestedBy_username}}` | The requesting user's username |
|
||||
| `{{requestedBy_email}}` | The requesting user's email address |
|
||||
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
|
||||
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||
|
||||
#### Issue
|
||||
|
||||
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in issue-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------- |
|
||||
| `{{issue_id}}` | The issue ID |
|
||||
| `{{reportedBy_username}}` | The requesting user's username |
|
||||
| `{{reportedBy_email}}` | The requesting user's email address |
|
||||
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
|
||||
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||
|
||||
#### Comment
|
||||
|
||||
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in issue comment-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------------------------- | ----------------------------------------------- |
|
||||
| `{{comment_message}}` | The comment message |
|
||||
| `{{commentedBy_username}}` | The commenting user's username |
|
||||
| `{{commentedBy_email}}` | The commenting user's email address |
|
||||
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
|
||||
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
|
||||
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |
|
||||
|
||||
@@ -909,6 +909,15 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProductionCompany'
|
||||
productionCountries:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
iso_3166_1:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
spokenLanguages:
|
||||
type: array
|
||||
items:
|
||||
@@ -1630,6 +1639,15 @@ components:
|
||||
discordId:
|
||||
type: string
|
||||
nullable: true
|
||||
pushbulletAccessToken:
|
||||
type: string
|
||||
nullable: true
|
||||
pushoverApplicationToken:
|
||||
type: string
|
||||
nullable: true
|
||||
pushoverUserKey:
|
||||
type: string
|
||||
nullable: true
|
||||
telegramEnabled:
|
||||
type: boolean
|
||||
telegramBotUsername:
|
||||
@@ -1687,6 +1705,36 @@ components:
|
||||
type: number
|
||||
name:
|
||||
type: string
|
||||
Issue:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
issueType:
|
||||
type: number
|
||||
example: 1
|
||||
media:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
createdBy:
|
||||
$ref: '#/components/schemas/User'
|
||||
modifiedBy:
|
||||
$ref: '#/components/schemas/User'
|
||||
comments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/IssueComment'
|
||||
IssueComment:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
user:
|
||||
$ref: '#/components/schemas/User'
|
||||
message:
|
||||
type: string
|
||||
example: A comment
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
@@ -5183,7 +5231,251 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
/issue:
|
||||
get:
|
||||
summary: Get all issues
|
||||
description: |
|
||||
Returns a list of issues in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: query
|
||||
name: take
|
||||
schema:
|
||||
type: number
|
||||
nullable: true
|
||||
example: 20
|
||||
- in: query
|
||||
name: skip
|
||||
schema:
|
||||
type: number
|
||||
nullable: true
|
||||
example: 0
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
type: string
|
||||
enum: [added, modified]
|
||||
default: added
|
||||
- in: query
|
||||
name: filter
|
||||
schema:
|
||||
type: string
|
||||
enum: [all, open, resolved]
|
||||
default: open
|
||||
- in: query
|
||||
name: requestedBy
|
||||
schema:
|
||||
type: number
|
||||
nullable: true
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Issues returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
pageInfo:
|
||||
$ref: '#/components/schemas/PageInfo'
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
post:
|
||||
summary: Create new issue
|
||||
description: |
|
||||
Creates a new issue
|
||||
tags:
|
||||
- issue
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
issueType:
|
||||
type: number
|
||||
message:
|
||||
type: string
|
||||
mediaId:
|
||||
type: number
|
||||
responses:
|
||||
'201':
|
||||
description: Succesfully created the issue
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
/issue/{issueId}:
|
||||
get:
|
||||
summary: Get issue
|
||||
description: |
|
||||
Returns a single issue in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: issueId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Issues returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
delete:
|
||||
summary: Delete issue
|
||||
description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: issueId
|
||||
description: Issue ID
|
||||
required: true
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed issue
|
||||
/issue/{issueId}/comment:
|
||||
post:
|
||||
summary: Create a comment
|
||||
description: |
|
||||
Creates a comment and returns associated issue in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: issueId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- message
|
||||
responses:
|
||||
'200':
|
||||
description: Issue returned with new comment
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
/issueComment/{commentId}:
|
||||
get:
|
||||
summary: Get issue comment
|
||||
description: |
|
||||
Returns a single issue comment in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: commentId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Comment returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/IssueComment'
|
||||
put:
|
||||
summary: Update issue comment
|
||||
description: |
|
||||
Updates and returns a single issue comment in JSON format.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: commentId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Comment updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/IssueComment'
|
||||
delete:
|
||||
summary: Delete issue comment
|
||||
description: |
|
||||
Deletes an issue comment. Only users with `MANAGE_ISSUES` or the user who created the comment can perform this action.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: commentId
|
||||
description: Issue Comment ID
|
||||
required: true
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed issue comment
|
||||
/issue/{issueId}/{status}:
|
||||
post:
|
||||
summary: Update an issue's status
|
||||
description: |
|
||||
Updates an issue's status to approved or declined. Also returns the issue in a JSON object.
|
||||
|
||||
Requires the `MANAGE_ISSUES` permission or `ADMIN`.
|
||||
tags:
|
||||
- issue
|
||||
parameters:
|
||||
- in: path
|
||||
name: issueId
|
||||
description: Issue ID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: '1'
|
||||
- in: path
|
||||
name: status
|
||||
description: New status
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [open, resolved]
|
||||
responses:
|
||||
'200':
|
||||
description: Issue status changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sct/overseerr.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.4.1",
|
||||
|
||||
16
public/sw.js
16
public/sw.js
@@ -90,8 +90,8 @@ self.addEventListener('push', (event) => {
|
||||
if (payload.actionUrl){
|
||||
options.actions.push(
|
||||
{
|
||||
action: 'viewmedia',
|
||||
title: 'View Media',
|
||||
action: 'view',
|
||||
title: payload.actionUrlTitle ?? 'View',
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -119,21 +119,17 @@ self.addEventListener('notificationclick', (event) => {
|
||||
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'viewmedia') {
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
} else if (event.action === 'approve') {
|
||||
if (event.action === 'approve') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
} else if (event.action === 'decline') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
} else if (notificationData.actionUrl) {
|
||||
}
|
||||
|
||||
if (notificationData.actionUrl) {
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
}
|
||||
}, false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import NodePlexAPI from 'plex-api';
|
||||
import { getSettings, Library, PlexSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
@@ -145,28 +146,40 @@ class PlexAPI {
|
||||
public async syncLibraries(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
|
||||
const libraries = await this.getLibraries();
|
||||
try {
|
||||
const libraries = await this.getLibraries();
|
||||
|
||||
const newLibraries: Library[] = libraries
|
||||
// Remove libraries that are not movie or show
|
||||
.filter((library) => library.type === 'movie' || library.type === 'show')
|
||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||
.map((library) => {
|
||||
const existing = settings.plex.libraries.find(
|
||||
(l) => l.id === library.key && l.name === library.title
|
||||
);
|
||||
const newLibraries: Library[] = libraries
|
||||
// Remove libraries that are not movie or show
|
||||
.filter(
|
||||
(library) => library.type === 'movie' || library.type === 'show'
|
||||
)
|
||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||
.map((library) => {
|
||||
const existing = settings.plex.libraries.find(
|
||||
(l) => l.id === library.key && l.name === library.title
|
||||
);
|
||||
|
||||
return {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
type: library.type,
|
||||
lastScan: existing?.lastScan,
|
||||
};
|
||||
return {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
type: library.type,
|
||||
lastScan: existing?.lastScan,
|
||||
};
|
||||
});
|
||||
|
||||
settings.plex.libraries = newLibraries;
|
||||
} catch (e) {
|
||||
logger.error('Failed to fetch Plex libraries', {
|
||||
label: 'Plex API',
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
settings.plex.libraries = newLibraries;
|
||||
settings.plex.libraries = [];
|
||||
}
|
||||
|
||||
settings.save();
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,10 @@ export interface TmdbTvDetails {
|
||||
name: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
production_countries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
spoken_languages: {
|
||||
english_name: string;
|
||||
iso_639_1: string;
|
||||
|
||||
18
server/constants/issue.ts
Normal file
18
server/constants/issue.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export enum IssueType {
|
||||
VIDEO = 1,
|
||||
AUDIO = 2,
|
||||
SUBTITLES = 3,
|
||||
OTHER = 4,
|
||||
}
|
||||
|
||||
export enum IssueStatus {
|
||||
OPEN = 1,
|
||||
RESOLVED = 2,
|
||||
}
|
||||
|
||||
export const IssueTypeName = {
|
||||
[IssueType.AUDIO]: 'Audio',
|
||||
[IssueType.VIDEO]: 'Video',
|
||||
[IssueType.SUBTITLES]: 'Subtitle',
|
||||
[IssueType.OTHER]: 'Other',
|
||||
};
|
||||
68
server/entity/Issue.ts
Normal file
68
server/entity/Issue.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { IssueStatus, IssueType } from '../constants/issue';
|
||||
import IssueComment from './IssueComment';
|
||||
import Media from './Media';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
class Issue {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
public issueType: IssueType;
|
||||
|
||||
@Column({ type: 'int', default: IssueStatus.OPEN })
|
||||
public status: IssueStatus;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
public problemSeason: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
public problemEpisode: number;
|
||||
|
||||
@ManyToOne(() => Media, (media) => media.issues, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.createdIssues, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public createdBy: User;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
public modifiedBy?: User;
|
||||
|
||||
@OneToMany(() => IssueComment, (comment) => comment.issue, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
})
|
||||
public comments: IssueComment[];
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<Issue>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default Issue;
|
||||
42
server/entity/IssueComment.ts
Normal file
42
server/entity/IssueComment.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import Issue from './Issue';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
class IssueComment {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public user: User;
|
||||
|
||||
@ManyToOne(() => Issue, (issue) => issue.comments, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public issue: Issue;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
public message: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<IssueComment>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default IssueComment;
|
||||
@@ -16,6 +16,7 @@ import { MediaStatus, MediaType } from '../constants/media';
|
||||
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Issue from './Issue';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import Season from './Season';
|
||||
|
||||
@@ -54,7 +55,7 @@ class Media {
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: id, mediaType },
|
||||
relations: ['requests'],
|
||||
relations: ['requests', 'issues'],
|
||||
});
|
||||
|
||||
return media;
|
||||
@@ -97,6 +98,9 @@ class Media {
|
||||
})
|
||||
public seasons: Season[];
|
||||
|
||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||
public issues: Issue[];
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ export class MediaRequest {
|
||||
if (this.type === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
|
||||
event: `New ${this.is4k ? '4K ' : ''}Movie Request`,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -153,12 +154,14 @@ export class MediaRequest {
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
media,
|
||||
request: this,
|
||||
notifyAdmin: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.type === MediaType.TV) {
|
||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
|
||||
event: `New ${this.is4k ? '4K ' : ''}Series Request`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -171,13 +174,14 @@ export class MediaRequest {
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
name: 'Requested Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: this,
|
||||
notifyAdmin: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -222,6 +226,13 @@ export class MediaRequest {
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
event: `${this.is4k ? '4K ' : ''}Movie Request ${
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? autoApproved
|
||||
? 'Automatically Approved'
|
||||
: 'Approved'
|
||||
: 'Declined'
|
||||
}`,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -231,6 +242,7 @@ export class MediaRequest {
|
||||
omission: '…',
|
||||
}),
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
notifyAdmin: autoApproved,
|
||||
notifyUser: autoApproved ? undefined : this.requestedBy,
|
||||
media,
|
||||
request: this,
|
||||
@@ -245,6 +257,13 @@ export class MediaRequest {
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
event: `${this.is4k ? '4K ' : ''}Series Request ${
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? autoApproved
|
||||
? 'Automatically Approved'
|
||||
: 'Approved'
|
||||
: 'Declined'
|
||||
}`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -254,11 +273,12 @@ export class MediaRequest {
|
||||
omission: '…',
|
||||
}),
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
notifyAdmin: autoApproved,
|
||||
notifyUser: autoApproved ? undefined : this.requestedBy,
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
name: 'Requested Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
@@ -508,6 +528,7 @@ export class MediaRequest {
|
||||
);
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
event: `${this.is4k ? '4K ' : ''}Movie Request Failed`,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -519,6 +540,7 @@ export class MediaRequest {
|
||||
media,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: this,
|
||||
notifyAdmin: true,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Radarr', { label: 'Media Request' });
|
||||
@@ -722,6 +744,7 @@ export class MediaRequest {
|
||||
);
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
|
||||
event: `${this.is4k ? '4K ' : ''}Series Request Failed`,
|
||||
subject: `${series.name}${
|
||||
series.first_air_date
|
||||
? ` (${series.first_air_date.slice(0, 4)})`
|
||||
@@ -736,13 +759,14 @@ export class MediaRequest {
|
||||
media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
name: 'Requested Seasons',
|
||||
value: this.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: this,
|
||||
notifyAdmin: true,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Sonarr', { label: 'Media Request' });
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../lib/permissions';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import logger from '../logger';
|
||||
import Issue from './Issue';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import SeasonRequest from './SeasonRequest';
|
||||
import { UserPushSubscription } from './UserPushSubscription';
|
||||
@@ -115,6 +116,9 @@ export class User {
|
||||
@OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user)
|
||||
public pushSubscriptions: UserPushSubscription[];
|
||||
|
||||
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
|
||||
public createdIssues: Issue[];
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
|
||||
@@ -42,6 +42,15 @@ export class UserSettings {
|
||||
@Column({ nullable: true })
|
||||
public discordId?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public pushbulletAccessToken?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public pushoverApplicationToken?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public pushoverUserKey?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public telegramChatId?: string;
|
||||
|
||||
|
||||
@@ -63,11 +63,12 @@ app
|
||||
});
|
||||
|
||||
if (admin) {
|
||||
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
|
||||
await plexapi.syncLibraries();
|
||||
logger.info('Migrating libraries to include media type', {
|
||||
logger.info('Migrating Plex libraries to include media type', {
|
||||
label: 'Settings',
|
||||
});
|
||||
|
||||
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
|
||||
await plexapi.syncLibraries();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +139,9 @@ app
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
httpOnly: true,
|
||||
sameSite: true,
|
||||
secure: 'auto',
|
||||
},
|
||||
store: new TypeormStore({
|
||||
cleanupLimit: 2,
|
||||
|
||||
6
server/interfaces/api/issueInterfaces.ts
Normal file
6
server/interfaces/api/issueInterfaces.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import Issue from '../../entity/Issue';
|
||||
import { PaginatedResponse } from './common';
|
||||
|
||||
export interface IssueResultsResponse extends PaginatedResponse {
|
||||
results: Issue[];
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { PaginatedResponse } from './common';
|
||||
export type LogMessage = {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface UserSettingsNotificationsResponse {
|
||||
discordEnabled?: boolean;
|
||||
discordEnabledTypes?: number;
|
||||
discordId?: string;
|
||||
pushbulletAccessToken?: string;
|
||||
pushoverApplicationToken?: string;
|
||||
pushoverUserKey?: string;
|
||||
telegramEnabled?: boolean;
|
||||
telegramBotUsername?: string;
|
||||
telegramChatId?: string;
|
||||
|
||||
@@ -40,7 +40,7 @@ class Cache {
|
||||
|
||||
class CacheManager {
|
||||
private availableCaches: Record<AvailableCacheIds, Cache> = {
|
||||
tmdb: new Cache('tmdb', 'TMDb API', {
|
||||
tmdb: new Cache('tmdb', 'The Movie Database API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
@@ -54,7 +54,7 @@ class CacheManager {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
plexguid: new Cache('plexguid', 'Plex GUID Cache', {
|
||||
plexguid: new Cache('plexguid', 'Plex GUID', {
|
||||
stdTtl: 86400 * 7, // 1 week cache
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
|
||||
@@ -76,23 +76,32 @@ class DownloadTracker {
|
||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const queueItems = await radarr.getQueue();
|
||||
try {
|
||||
const queueItems = await radarr.getQueue();
|
||||
|
||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.movieId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.MOVIE,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.movieId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.MOVIE,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.error(
|
||||
`Unable to get queue from Radarr server: ${server.name}`,
|
||||
{
|
||||
label: 'Download Tracker',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,42 +143,51 @@ class DownloadTracker {
|
||||
);
|
||||
});
|
||||
|
||||
// Load downloads from Radarr servers
|
||||
// Load downloads from Sonarr servers
|
||||
Promise.all(
|
||||
filteredServers.map(async (server) => {
|
||||
if (server.syncEnabled) {
|
||||
const radarr = new SonarrAPI({
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const queueItems = await radarr.getQueue();
|
||||
try {
|
||||
const queueItems = await sonarr.getQueue();
|
||||
|
||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.seriesId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.TV,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||
externalId: item.seriesId,
|
||||
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
|
||||
mediaType: MediaType.TV,
|
||||
size: item.size,
|
||||
sizeLeft: item.sizeleft,
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
if (queueItems.length > 0) {
|
||||
logger.debug(
|
||||
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
|
||||
{ label: 'Download Tracker' }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
logger.error(
|
||||
`Unable to get queue from Sonarr server: ${server.name}`,
|
||||
{
|
||||
label: 'Download Tracker',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate this data to matching servers
|
||||
const matchingServers = settings.sonarr.filter(
|
||||
(rs) =>
|
||||
rs.hostname === server.hostname &&
|
||||
rs.port === server.port &&
|
||||
rs.baseUrl === server.baseUrl &&
|
||||
rs.id !== server.id
|
||||
(ss) =>
|
||||
ss.hostname === server.hostname &&
|
||||
ss.port === server.port &&
|
||||
ss.baseUrl === server.baseUrl &&
|
||||
ss.id !== server.id
|
||||
);
|
||||
|
||||
if (matchingServers.length > 0) {
|
||||
|
||||
@@ -51,11 +51,12 @@ class PGPEncryptor extends Transform {
|
||||
|
||||
// Only sign the message if private key and password exist
|
||||
if (this._signingKey && this._password) {
|
||||
privateKey = await openpgp.readPrivateKey({
|
||||
armoredKey: this._signingKey,
|
||||
privateKey = await openpgp.decryptKey({
|
||||
privateKey: await openpgp.readPrivateKey({
|
||||
armoredKey: this._signingKey,
|
||||
}),
|
||||
passphrase: this._password,
|
||||
});
|
||||
|
||||
await openpgp.decryptKey({ privateKey, passphrase: this._password });
|
||||
}
|
||||
|
||||
const emailPartDelimiter = '\r\n\r\n';
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { Notification } from '..';
|
||||
import type Issue from '../../../entity/Issue';
|
||||
import IssueComment from '../../../entity/IssueComment';
|
||||
import Media from '../../../entity/Media';
|
||||
import { MediaRequest } from '../../../entity/MediaRequest';
|
||||
import { User } from '../../../entity/User';
|
||||
import { NotificationAgentConfig } from '../../settings';
|
||||
|
||||
export interface NotificationPayload {
|
||||
event?: string;
|
||||
subject: string;
|
||||
notifyAdmin: boolean;
|
||||
notifyUser?: User;
|
||||
media?: Media;
|
||||
image?: string;
|
||||
message?: string;
|
||||
extra?: { name: string; value: string }[];
|
||||
request?: MediaRequest;
|
||||
issue?: Issue;
|
||||
comment?: IssueComment;
|
||||
}
|
||||
|
||||
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentDiscord,
|
||||
@@ -107,9 +111,9 @@ class DiscordAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): DiscordRichEmbed {
|
||||
const settings = getSettings();
|
||||
let color = EmbedColors.DARK_PURPLE;
|
||||
const { applicationUrl } = getSettings().main;
|
||||
|
||||
let color = EmbedColors.DARK_PURPLE;
|
||||
const fields: Field[] = [];
|
||||
|
||||
if (payload.request) {
|
||||
@@ -118,56 +122,94 @@ class DiscordAgent
|
||||
value: payload.request.requestedBy.displayName,
|
||||
inline: true,
|
||||
});
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
color = EmbedColors.ORANGE;
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
color = EmbedColors.PURPLE;
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
color = EmbedColors.GREEN;
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
color = EmbedColors.RED;
|
||||
status = 'Declined';
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
color = EmbedColors.RED;
|
||||
status = 'Failed';
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
fields.push({
|
||||
name: 'Request Status',
|
||||
value: status,
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
fields.push({
|
||||
name: `Comment from ${payload.comment.user.displayName}`,
|
||||
value: payload.comment.message,
|
||||
inline: false,
|
||||
});
|
||||
} else if (payload.issue) {
|
||||
fields.push(
|
||||
{
|
||||
name: 'Reported By',
|
||||
value: payload.issue.createdBy.displayName,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Issue Type',
|
||||
value: IssueTypeName[payload.issue.issueType],
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: 'Issue Status',
|
||||
value:
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved',
|
||||
inline: true,
|
||||
}
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case Notification.ISSUE_CREATED:
|
||||
case Notification.ISSUE_REOPENED:
|
||||
color = EmbedColors.RED;
|
||||
break;
|
||||
case Notification.ISSUE_COMMENT:
|
||||
color = EmbedColors.ORANGE;
|
||||
break;
|
||||
case Notification.ISSUE_RESOLVED:
|
||||
color = EmbedColors.GREEN;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
color = EmbedColors.ORANGE;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Pending Approval',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
color = EmbedColors.PURPLE;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Processing',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
color = EmbedColors.GREEN;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Available',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
color = EmbedColors.RED;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Declined',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
color = EmbedColors.RED;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
value: 'Failed',
|
||||
inline: true,
|
||||
});
|
||||
break;
|
||||
for (const extra of payload.extra ?? []) {
|
||||
fields.push({
|
||||
name: extra.name,
|
||||
value: extra.value,
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
const url =
|
||||
settings.main.applicationUrl && payload.media
|
||||
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
const url = applicationUrl
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title: payload.subject,
|
||||
@@ -175,18 +217,12 @@ class DiscordAgent
|
||||
description: payload.message,
|
||||
color,
|
||||
timestamp: new Date().toISOString(),
|
||||
author: {
|
||||
name: settings.main.applicationTitle,
|
||||
url: settings.main.applicationUrl,
|
||||
},
|
||||
fields: [
|
||||
...fields,
|
||||
// If we have extra data, map it to fields for discord notifications
|
||||
...(payload.extra ?? []).map((extra) => ({
|
||||
name: extra.name,
|
||||
value: extra.value,
|
||||
})),
|
||||
],
|
||||
author: payload.event
|
||||
? {
|
||||
name: payload.event,
|
||||
}
|
||||
: undefined,
|
||||
fields,
|
||||
thumbnail: {
|
||||
url: payload.image,
|
||||
},
|
||||
@@ -219,54 +255,53 @@ class DiscordAgent
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
let content = undefined;
|
||||
const userMentions: string[] = [];
|
||||
|
||||
try {
|
||||
if (payload.notifyUser) {
|
||||
// Mention user who submitted the request
|
||||
if (
|
||||
payload.notifyUser.settings?.hasNotificationType(
|
||||
NotificationAgentKey.DISCORD,
|
||||
type
|
||||
) &&
|
||||
payload.notifyUser.settings?.discordId
|
||||
payload.notifyUser.settings.discordId
|
||||
) {
|
||||
content = `<@${payload.notifyUser.settings.discordId}>`;
|
||||
userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
|
||||
}
|
||||
} else {
|
||||
// Mention all users with the Manage Requests permission
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
content = users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.DISCORD,
|
||||
type
|
||||
) &&
|
||||
user.settings?.discordId &&
|
||||
// Check if it's the user's own auto-approved request
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !== payload.request?.requestedBy.id)
|
||||
)
|
||||
.map((user) => `<@${user.settings?.discordId}>`)
|
||||
.join(' ');
|
||||
userMentions.push(
|
||||
...users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.DISCORD,
|
||||
type
|
||||
) &&
|
||||
user.settings.discordId &&
|
||||
shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map((user) => `<@${user.settings?.discordId}>`)
|
||||
);
|
||||
}
|
||||
|
||||
await axios.post(settings.options.webhookUrl, {
|
||||
username: settings.options.botUsername,
|
||||
username: settings.options.botUsername
|
||||
? settings.options.botUsername
|
||||
: getSettings().main.applicationTitle,
|
||||
avatar_url: settings.options.botAvatarUrl,
|
||||
embeds: [this.buildEmbed(type, payload)],
|
||||
content,
|
||||
content: userMentions.join(' '),
|
||||
} as DiscordWebhookPayload);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Discord notification', {
|
||||
label: 'Notifications',
|
||||
mentions: content,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { EmailOptions } from 'email-templates';
|
||||
import path from 'path';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { Notification } from '..';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import PreparedEmail from '../../email';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentEmail,
|
||||
@@ -67,59 +67,47 @@ class EmailAgent
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.media) {
|
||||
let requestType = '';
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
: 'series'
|
||||
: undefined;
|
||||
const is4k = payload.request?.is4k;
|
||||
|
||||
if (payload.request) {
|
||||
let body = '';
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
requestType = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
body = `A user has requested a new ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
}!`;
|
||||
body = `A new request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}is pending approval:`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
requestType = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
body = `Your request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} has been approved:`;
|
||||
body = `Your request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}has been approved:`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
requestType = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
body = `A new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} has been automatically approved:`;
|
||||
body = `A new request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}has been automatically approved:`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
requestType = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
body = `The following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} you requested is now available!`;
|
||||
body = `Your request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}is now available:`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
requestType = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
body = `Your request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} was declined:`;
|
||||
body = `Your request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}was declined:`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
requestType = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
body = `A new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} could not be added to ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
|
||||
body = `A request for the following ${mediaType} ${
|
||||
is4k ? 'in 4K ' : ''
|
||||
}failed to be added to ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr'
|
||||
}:`;
|
||||
break;
|
||||
}
|
||||
@@ -133,14 +121,13 @@ class EmailAgent
|
||||
to: recipientEmail,
|
||||
},
|
||||
locals: {
|
||||
requestType,
|
||||
event: payload.event,
|
||||
body,
|
||||
mediaName: payload.subject,
|
||||
mediaPlot: payload.message,
|
||||
mediaExtra: payload.extra ?? [],
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.request?.requestedBy.displayName,
|
||||
requestedBy: payload.request.requestedBy.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
@@ -150,6 +137,52 @@ class EmailAgent
|
||||
recipientEmail,
|
||||
},
|
||||
};
|
||||
} else if (payload.issue) {
|
||||
const issueType =
|
||||
payload.issue && payload.issue.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
|
||||
: 'issue';
|
||||
|
||||
let body = '';
|
||||
|
||||
switch (type) {
|
||||
case Notification.ISSUE_CREATED:
|
||||
body = `A new ${issueType} has been reported by ${payload.issue.createdBy.displayName} for the ${mediaType} ${payload.subject}:`;
|
||||
break;
|
||||
case Notification.ISSUE_COMMENT:
|
||||
body = `${payload.comment?.user.displayName} commented on the ${issueType} for the ${mediaType} ${payload.subject}:`;
|
||||
break;
|
||||
case Notification.ISSUE_RESOLVED:
|
||||
body = `The ${issueType} for the ${mediaType} ${payload.subject} was marked as resolved by ${payload.issue.modifiedBy?.displayName}!`;
|
||||
break;
|
||||
case Notification.ISSUE_REOPENED:
|
||||
body = `The ${issueType} for the ${mediaType} ${payload.subject} was reopened by ${payload.issue.modifiedBy?.displayName}.`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
template: path.join(__dirname, '../../../templates/email/media-issue'),
|
||||
message: {
|
||||
to: recipientEmail,
|
||||
},
|
||||
locals: {
|
||||
event: payload.event,
|
||||
body,
|
||||
issueDescription: payload.message,
|
||||
issueComment: payload.comment?.message,
|
||||
mediaName: payload.subject,
|
||||
extra: payload.extra ?? [],
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -160,7 +193,6 @@ class EmailAgent
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
if (payload.notifyUser) {
|
||||
// Send notification to the user who submitted the request
|
||||
if (
|
||||
!payload.notifyUser.settings ||
|
||||
// Check if user has email notifications enabled and fallback to true if undefined
|
||||
@@ -203,8 +235,9 @@ class EmailAgent
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Send notifications to all users with the Manage Requests permission
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
@@ -212,7 +245,6 @@ class EmailAgent
|
||||
users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
(!user.settings ||
|
||||
// Check if user has email notifications enabled and fallback to true if undefined
|
||||
// since email should default to true
|
||||
@@ -221,9 +253,7 @@ class EmailAgent
|
||||
type
|
||||
) ??
|
||||
true)) &&
|
||||
// Check if it's the user's own auto-approved request
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !== payload.request?.requestedBy.id)
|
||||
shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map(async (user) => {
|
||||
logger.debug('Sending email notification', {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentLunaSea } from '../../settings';
|
||||
@@ -22,17 +23,17 @@ class LunaSeaAgent
|
||||
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||
return {
|
||||
notification_type: Notification[type],
|
||||
event: payload.event,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
image: payload.image ?? null,
|
||||
email: payload.notifyUser?.email,
|
||||
username: payload.notifyUser?.username,
|
||||
username: payload.notifyUser?.displayName,
|
||||
avatar: payload.notifyUser?.avatar,
|
||||
media: payload.media
|
||||
? {
|
||||
media_type: payload.media.mediaType,
|
||||
tmdbId: payload.media.tmdbId,
|
||||
imdbId: payload.media.imdbId,
|
||||
tvdbId: payload.media.tvdbId,
|
||||
status: MediaStatus[payload.media.status],
|
||||
status4k: MediaStatus[payload.media.status4k],
|
||||
@@ -47,6 +48,24 @@ class LunaSeaAgent
|
||||
requestedBy_avatar: payload.request.requestedBy.avatar,
|
||||
}
|
||||
: null,
|
||||
issue: payload.issue
|
||||
? {
|
||||
issue_id: payload.issue.id,
|
||||
issue_type: IssueType[payload.issue.issueType],
|
||||
issue_status: IssueStatus[payload.issue.status],
|
||||
createdBy_email: payload.issue.createdBy.email,
|
||||
createdBy_username: payload.issue.createdBy.displayName,
|
||||
createdBy_avatar: payload.issue.createdBy.avatar,
|
||||
}
|
||||
: null,
|
||||
comment: payload.comment
|
||||
? {
|
||||
comment_message: payload.comment.message,
|
||||
commentedBy_email: payload.comment.user.email,
|
||||
commentedBy_username: payload.comment.user.displayName,
|
||||
commentedBy_avatar: payload.comment.user.avatar,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { getRepository } from 'typeorm';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushbullet } from '../../settings';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
NotificationAgentPushbullet,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface PushbulletPayload {
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
@@ -25,109 +36,62 @@ class PushbulletAgent
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (settings.enabled && settings.options.accessToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private constructMessageDetails(
|
||||
private getNotificationPayload(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): {
|
||||
title: string;
|
||||
body: string;
|
||||
} {
|
||||
let messageTitle = '';
|
||||
let message = '';
|
||||
): PushbulletPayload {
|
||||
const title = payload.event
|
||||
? `${payload.event} - ${payload.subject}`
|
||||
: payload.subject;
|
||||
let body = payload.message ?? '';
|
||||
|
||||
const title = payload.subject;
|
||||
const plot = payload.message;
|
||||
const username = payload.request?.requestedBy.displayName;
|
||||
if (payload.request) {
|
||||
body += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Pending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Processing`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Processing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Available`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Declined`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
messageTitle = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Failed`;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
messageTitle = 'Test Notification';
|
||||
message += `${plot}`;
|
||||
break;
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
status = 'Declined';
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
status = 'Failed';
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
body += `\nRequest Status: ${status}`;
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
body += `\n\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
|
||||
} else if (payload.issue) {
|
||||
body += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
|
||||
body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
|
||||
body += `\nIssue Status: ${
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||
}`;
|
||||
}
|
||||
|
||||
for (const extra of payload.extra ?? []) {
|
||||
message += `\n${extra.name}: ${extra.value}`;
|
||||
body += `\n${extra.name}: ${extra.value}`;
|
||||
}
|
||||
|
||||
return {
|
||||
title: messageTitle,
|
||||
body: message,
|
||||
type: 'note',
|
||||
title,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,46 +100,128 @@ class PushbulletAgent
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
const endpoint = 'https://api.pushbullet.com/v2/pushes';
|
||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug('Sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
const { title, body } = this.constructMessageDetails(type, payload);
|
||||
|
||||
await axios.post(
|
||||
'https://api.pushbullet.com/v2/pushes',
|
||||
{
|
||||
type: 'note',
|
||||
title: title,
|
||||
body: body,
|
||||
} as PushbulletPayload,
|
||||
{
|
||||
headers: {
|
||||
'Access-Token': settings.options.accessToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
// Send system notification
|
||||
if (
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.enabled &&
|
||||
settings.options.accessToken
|
||||
) {
|
||||
logger.debug('Sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
try {
|
||||
await axios.post(endpoint, notificationPayload, {
|
||||
headers: {
|
||||
'Access-Token': settings.options.accessToken,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.notifyUser) {
|
||||
if (
|
||||
payload.notifyUser.settings?.hasNotificationType(
|
||||
NotificationAgentKey.PUSHBULLET,
|
||||
type
|
||||
) &&
|
||||
payload.notifyUser.settings?.pushbulletAccessToken &&
|
||||
payload.notifyUser.settings.pushbulletAccessToken !==
|
||||
settings.options.accessToken
|
||||
) {
|
||||
logger.debug('Sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, notificationPayload, {
|
||||
headers: {
|
||||
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
await Promise.all(
|
||||
users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.PUSHBULLET,
|
||||
type
|
||||
) && shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map(async (user) => {
|
||||
if (
|
||||
user.settings?.pushbulletAccessToken &&
|
||||
user.settings.pushbulletAccessToken !==
|
||||
settings.options.accessToken
|
||||
) {
|
||||
logger.debug('Sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, notificationPayload, {
|
||||
headers: {
|
||||
'Access-Token': user.settings.pushbulletAccessToken,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { getRepository } from 'typeorm';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushover } from '../../settings';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
NotificationAgentPushover,
|
||||
} from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface PushoverPayload {
|
||||
@@ -31,130 +41,89 @@ class PushoverAgent
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
settings.options.userToken
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private constructMessageDetails(
|
||||
private getNotificationPayload(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): {
|
||||
title: string;
|
||||
message: string;
|
||||
url: string | undefined;
|
||||
url_title: string | undefined;
|
||||
priority: number;
|
||||
} {
|
||||
const settings = getSettings();
|
||||
let messageTitle = '';
|
||||
let message = '';
|
||||
let url: string | undefined;
|
||||
let url_title: string | undefined;
|
||||
): Partial<PushoverPayload> {
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
const title = payload.event ?? payload.subject;
|
||||
let message = payload.event ? `<b>${payload.subject}</b>` : '';
|
||||
let priority = 0;
|
||||
|
||||
const title = payload.subject;
|
||||
const plot = payload.message;
|
||||
const username = payload.request?.requestedBy.displayName;
|
||||
if (payload.message) {
|
||||
message += `<small>${message ? '\n' : ''}${payload.message}</small>`;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nPending Approval</small>`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nAvailable</small>`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nDeclined</small>`;
|
||||
if (payload.request) {
|
||||
message += `<small>\n\n<b>Requested By:</b> ${payload.request.requestedBy.displayName}</small>`;
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
status = 'Declined';
|
||||
priority = 1;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
status = 'Failed';
|
||||
priority = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
message += `<small>\n<b>Request Status:</b> ${status}</small>`;
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
message += `<small>\n\n<b>Comment from ${payload.comment.user.displayName}:</b> ${payload.comment.message}</small>`;
|
||||
} else if (payload.issue) {
|
||||
message += `<small>\n\n<b>Reported By:</b> ${payload.issue.createdBy.displayName}</small>`;
|
||||
message += `<small>\n<b>Issue Type:</b> ${
|
||||
IssueTypeName[payload.issue.issueType]
|
||||
}</small>`;
|
||||
message += `<small>\n<b>Issue Status:</b> ${
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||
}</small>`;
|
||||
|
||||
if (type === Notification.ISSUE_CREATED) {
|
||||
priority = 1;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
messageTitle = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `<small>\n${plot}</small>`;
|
||||
}
|
||||
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
|
||||
message += `<small>\n\n<b>Status</b>\nFailed</small>`;
|
||||
priority = 1;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
messageTitle = 'Test Notification';
|
||||
message += `<small>${plot}</small>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const extra of payload.extra ?? []) {
|
||||
message += `<small>\n\n<b>${extra.name}</b>\n${extra.value}</small>`;
|
||||
message += `<small>\n<b>${extra.name}:</b> ${extra.value}</small>`;
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
url_title = `Open in ${settings.main.applicationTitle}`;
|
||||
}
|
||||
const url = applicationUrl
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
const url_title = url
|
||||
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title: messageTitle,
|
||||
title,
|
||||
message,
|
||||
url,
|
||||
url_title,
|
||||
priority,
|
||||
html: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,45 +132,134 @@ class PushoverAgent
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
const endpoint = 'https://api.pushover.net/1/messages.json';
|
||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||
|
||||
if (!hasNotificationType(type, settings.types ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug('Sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
try {
|
||||
const endpoint = 'https://api.pushover.net/1/messages.json';
|
||||
|
||||
const { title, message, url, url_title, priority } =
|
||||
this.constructMessageDetails(type, payload);
|
||||
|
||||
await axios.post(endpoint, {
|
||||
token: settings.options.accessToken,
|
||||
user: settings.options.userToken,
|
||||
title: title,
|
||||
message: message,
|
||||
url: url,
|
||||
url_title: url_title,
|
||||
priority: priority,
|
||||
html: 1,
|
||||
} as PushoverPayload);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
// Send system notification
|
||||
if (
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
settings.options.userToken
|
||||
) {
|
||||
logger.debug('Sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
token: settings.options.accessToken,
|
||||
user: settings.options.userToken,
|
||||
} as PushoverPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.notifyUser) {
|
||||
if (
|
||||
payload.notifyUser.settings?.hasNotificationType(
|
||||
NotificationAgentKey.PUSHOVER,
|
||||
type
|
||||
) &&
|
||||
payload.notifyUser.settings?.pushoverApplicationToken &&
|
||||
payload.notifyUser.settings?.pushoverUserKey &&
|
||||
payload.notifyUser.settings.pushoverApplicationToken !==
|
||||
settings.options.accessToken &&
|
||||
payload.notifyUser.settings?.pushoverUserKey !==
|
||||
settings.options.userToken
|
||||
) {
|
||||
logger.debug('Sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
token: payload.notifyUser.settings.pushoverApplicationToken,
|
||||
user: payload.notifyUser.settings.pushoverUserKey,
|
||||
} as PushoverPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
await Promise.all(
|
||||
users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.PUSHOVER,
|
||||
type
|
||||
) && shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map(async (user) => {
|
||||
if (
|
||||
user.settings?.pushoverApplicationToken &&
|
||||
user.settings?.pushoverUserKey &&
|
||||
user.settings.pushoverApplicationToken !==
|
||||
settings.options.accessToken &&
|
||||
user.settings.pushoverUserKey !== settings.options.userToken
|
||||
) {
|
||||
logger.debug('Sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
token: user.settings.pushoverApplicationToken,
|
||||
user: user.settings.pushoverUserKey,
|
||||
} as PushoverPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -19,9 +19,10 @@ interface TextItem {
|
||||
interface Element {
|
||||
type: 'button';
|
||||
text?: TextItem;
|
||||
value: string;
|
||||
url: string;
|
||||
action_id: 'button-action';
|
||||
action_id: string;
|
||||
url?: string;
|
||||
value?: string;
|
||||
style?: 'primary' | 'danger';
|
||||
}
|
||||
|
||||
interface EmbedBlock {
|
||||
@@ -34,7 +35,7 @@ interface EmbedBlock {
|
||||
image_url: string;
|
||||
alt_text: string;
|
||||
};
|
||||
elements?: Element[];
|
||||
elements?: (Element | TextItem)[];
|
||||
}
|
||||
|
||||
interface SlackBlockEmbed {
|
||||
@@ -59,9 +60,7 @@ class SlackAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): SlackBlockEmbed {
|
||||
const settings = getSettings();
|
||||
let header = '';
|
||||
let actionUrl: string | undefined;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
const fields: EmbedField[] = [];
|
||||
|
||||
@@ -70,66 +69,55 @@ class SlackAgent
|
||||
type: 'mrkdwn',
|
||||
text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
|
||||
});
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
header = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
status = 'Declined';
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
status = 'Failed';
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nPending Approval',
|
||||
text: `*Request Status*\n${status}`,
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
fields.push({
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: `*Comment from ${payload.comment.user.displayName}*\n${payload.comment.message}`,
|
||||
});
|
||||
} else if (payload.issue) {
|
||||
fields.push(
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
fields.push({
|
||||
text: `*Reported By*\n${payload.issue.createdBy.displayName}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
fields.push({
|
||||
text: `*Issue Type*\n${IssueTypeName[payload.issue.issueType]}`,
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nAvailable',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nDeclined',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
header = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nFailed',
|
||||
});
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
header = 'Test Notification';
|
||||
break;
|
||||
text: `*Issue Status*\n${
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||
}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
for (const extra of payload.extra ?? []) {
|
||||
@@ -139,30 +127,28 @@ class SlackAgent
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
|
||||
}
|
||||
const blocks: EmbedBlock[] = [];
|
||||
|
||||
const blocks: EmbedBlock[] = [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: header,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (type !== Notification.TEST_NOTIFICATION) {
|
||||
if (payload.event) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*${payload.subject}*`,
|
||||
},
|
||||
type: 'context',
|
||||
elements: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: `*${payload.event}*`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: payload.subject,
|
||||
},
|
||||
});
|
||||
|
||||
if (payload.message) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
@@ -183,30 +169,31 @@ class SlackAgent
|
||||
if (fields.length > 0) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
fields: [
|
||||
...fields,
|
||||
...(payload.extra ?? []).map(
|
||||
(extra): EmbedField => ({
|
||||
type: 'mrkdwn',
|
||||
text: `*${extra.name}*\n${extra.value}`,
|
||||
})
|
||||
),
|
||||
],
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
if (actionUrl) {
|
||||
const url = applicationUrl
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (url) {
|
||||
blocks.push({
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
action_id: 'button-action',
|
||||
action_id: 'open-in-overseerr',
|
||||
type: 'button',
|
||||
url: actionUrl,
|
||||
value: 'open_overseerr',
|
||||
url,
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: `Open in ${settings.main.applicationTitle}`,
|
||||
text: `View ${
|
||||
payload.issue ? 'Issue' : 'Media'
|
||||
} in ${applicationTitle}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
shouldSendAdminNotification,
|
||||
} from '..';
|
||||
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
|
||||
import { User } from '../../../entity/User';
|
||||
import logger from '../../../logger';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentKey,
|
||||
@@ -46,11 +49,7 @@ class TelegramAgent
|
||||
public shouldSend(): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.options.botAPI &&
|
||||
settings.options.chatId
|
||||
) {
|
||||
if (settings.enabled && settings.options.botAPI) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,118 +60,91 @@ class TelegramAgent
|
||||
return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : '';
|
||||
}
|
||||
|
||||
private buildMessage(
|
||||
private getNotificationPayload(
|
||||
type: Notification,
|
||||
payload: NotificationPayload,
|
||||
chatId: string,
|
||||
sendSilently: boolean
|
||||
): TelegramMessagePayload | TelegramPhotoPayload {
|
||||
const settings = getSettings();
|
||||
let message = '';
|
||||
|
||||
const title = this.escapeText(payload.subject);
|
||||
const plot = this.escapeText(payload.message);
|
||||
const user = this.escapeText(payload.request?.requestedBy.displayName);
|
||||
const applicationTitle = this.escapeText(settings.main.applicationTitle);
|
||||
payload: NotificationPayload
|
||||
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
/* eslint-disable no-useless-escape */
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
message += `\*New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nPending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nAvailable`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nDeclined`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
message += `\*Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nFailed`;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
message += `\*Test Notification\*`;
|
||||
message += `\n\n${plot}`;
|
||||
break;
|
||||
let message = `\*${this.escapeText(
|
||||
payload.event ? `${payload.event} - ${payload.subject}` : payload.subject
|
||||
)}\*`;
|
||||
if (payload.message) {
|
||||
message += `\n${this.escapeText(payload.message)}`;
|
||||
}
|
||||
|
||||
if (payload.request) {
|
||||
message += `\n\n\*Requested By:\* ${this.escapeText(
|
||||
payload.request?.requestedBy.displayName
|
||||
)}`;
|
||||
|
||||
let status = '';
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
status = 'Pending Approval';
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
status = 'Processing';
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
status = 'Available';
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
status = 'Declined';
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
status = 'Failed';
|
||||
break;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
message += `\n\*Request Status:\* ${status}`;
|
||||
}
|
||||
} else if (payload.comment) {
|
||||
message += `\n\n\*Comment from ${this.escapeText(
|
||||
payload.comment.user.displayName
|
||||
)}:\* ${this.escapeText(payload.comment.message)}`;
|
||||
} else if (payload.issue) {
|
||||
message += `\n\n\*Reported By:\* ${this.escapeText(
|
||||
payload.issue.createdBy.displayName
|
||||
)}`;
|
||||
message += `\n\*Issue Type:\* ${IssueTypeName[payload.issue.issueType]}`;
|
||||
message += `\n\*Issue Status:\* ${
|
||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||
}`;
|
||||
}
|
||||
|
||||
for (const extra of payload.extra ?? []) {
|
||||
message += `\n\n\*${extra.name}\*\n${extra.value}`;
|
||||
message += `\n\*${extra.name}:\* ${extra.value}`;
|
||||
}
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
|
||||
const url = applicationUrl
|
||||
? payload.issue
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
if (url) {
|
||||
message += `\n\n\[View ${
|
||||
payload.issue ? 'Issue' : 'Media'
|
||||
} in ${this.escapeText(applicationTitle)}\]\(${url}\)`;
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
return payload.image
|
||||
? ({
|
||||
? {
|
||||
photo: payload.image,
|
||||
caption: message,
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: chatId,
|
||||
disable_notification: !!sendSilently,
|
||||
} as TelegramPhotoPayload)
|
||||
: ({
|
||||
}
|
||||
: {
|
||||
text: message,
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: chatId,
|
||||
disable_notification: !!sendSilently,
|
||||
} as TelegramMessagePayload);
|
||||
};
|
||||
}
|
||||
|
||||
public async send(
|
||||
@@ -180,13 +152,16 @@ class TelegramAgent
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
|
||||
payload.image ? 'sendPhoto' : 'sendMessage'
|
||||
}`;
|
||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||
|
||||
// Send system notification
|
||||
if (hasNotificationType(type, settings.types ?? 0)) {
|
||||
if (
|
||||
hasNotificationType(type, settings.types ?? 0) &&
|
||||
settings.options.chatId
|
||||
) {
|
||||
logger.debug('Sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
@@ -194,15 +169,11 @@ class TelegramAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
endpoint,
|
||||
this.buildMessage(
|
||||
type,
|
||||
payload,
|
||||
settings.options.chatId,
|
||||
settings.options.sendSilently
|
||||
)
|
||||
);
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
chat_id: settings.options.chatId,
|
||||
disable_notification: !!settings.options.sendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
@@ -217,14 +188,13 @@ class TelegramAgent
|
||||
}
|
||||
|
||||
if (payload.notifyUser) {
|
||||
// Send notification to the user who submitted the request
|
||||
if (
|
||||
payload.notifyUser.settings?.hasNotificationType(
|
||||
NotificationAgentKey.TELEGRAM,
|
||||
type
|
||||
) &&
|
||||
payload.notifyUser.settings?.telegramChatId &&
|
||||
payload.notifyUser.settings?.telegramChatId !== settings.options.chatId
|
||||
payload.notifyUser.settings.telegramChatId !== settings.options.chatId
|
||||
) {
|
||||
logger.debug('Sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
@@ -234,15 +204,12 @@ class TelegramAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
endpoint,
|
||||
this.buildMessage(
|
||||
type,
|
||||
payload,
|
||||
payload.notifyUser.settings.telegramChatId,
|
||||
!!payload.notifyUser.settings.telegramSendSilently
|
||||
)
|
||||
);
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
chat_id: payload.notifyUser.settings.telegramChatId,
|
||||
disable_notification:
|
||||
!!payload.notifyUser.settings.telegramSendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
@@ -256,8 +223,9 @@ class TelegramAgent
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Send notifications to all users with the Manage Requests permission
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
@@ -265,14 +233,10 @@ class TelegramAgent
|
||||
users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
user.settings?.hasNotificationType(
|
||||
NotificationAgentKey.TELEGRAM,
|
||||
type
|
||||
) &&
|
||||
// Check if it's the user's own auto-approved request
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !== payload.request?.requestedBy.id)
|
||||
) && shouldSendAdminNotification(type, user, payload)
|
||||
)
|
||||
.map(async (user) => {
|
||||
if (
|
||||
@@ -287,15 +251,11 @@ class TelegramAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
endpoint,
|
||||
this.buildMessage(
|
||||
type,
|
||||
payload,
|
||||
user.settings.telegramChatId,
|
||||
!!user.settings?.telegramSendSilently
|
||||
)
|
||||
);
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
chat_id: user.settings.telegramChatId,
|
||||
disable_notification: !!user.settings?.telegramSendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { get } from 'lodash';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { IssueStatus, IssueType } from '../../../constants/issue';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentWebhook } from '../../settings';
|
||||
@@ -13,6 +14,7 @@ type KeyMapFunction = (
|
||||
|
||||
const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
notification_type: (_payload, type) => Notification[type],
|
||||
event: 'event',
|
||||
subject: 'subject',
|
||||
message: 'message',
|
||||
image: 'image',
|
||||
@@ -22,13 +24,12 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
|
||||
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
|
||||
media_tmdbid: 'media.tmdbId',
|
||||
media_imdbid: 'media.imdbId',
|
||||
media_tvdbid: 'media.tvdbId',
|
||||
media_type: 'media.mediaType',
|
||||
media_status: (payload) =>
|
||||
payload.media?.status ? MediaStatus[payload.media?.status] : '',
|
||||
payload.media ? MediaStatus[payload.media.status] : '',
|
||||
media_status4k: (payload) =>
|
||||
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
|
||||
payload.media ? MediaStatus[payload.media.status4k] : '',
|
||||
request_id: 'request.id',
|
||||
requestedBy_username: 'request.requestedBy.displayName',
|
||||
requestedBy_email: 'request.requestedBy.email',
|
||||
@@ -36,6 +37,22 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
|
||||
requestedBy_settings_telegramChatId:
|
||||
'request.requestedBy.settings.telegramChatId',
|
||||
issue_id: 'issue.id',
|
||||
issue_type: (payload) =>
|
||||
payload.issue ? IssueType[payload.issue.issueType] : '',
|
||||
issue_status: (payload) =>
|
||||
payload.issue ? IssueStatus[payload.issue.status] : '',
|
||||
reportedBy_username: 'issue.createdBy.displayName',
|
||||
reportedBy_email: 'issue.createdBy.email',
|
||||
reportedBy_avatar: 'issue.createdBy.avatar',
|
||||
reportedBy_settings_discordId: 'issue.createdBy.settings.discordId',
|
||||
reportedBy_settings_telegramChatId: 'issue.createdBy.settings.telegramChatId',
|
||||
comment_message: 'comment.message',
|
||||
commentedBy_username: 'comment.user.displayName',
|
||||
commentedBy_email: 'comment.user.email',
|
||||
commentedBy_avatar: 'comment.user.avatar',
|
||||
commentedBy_settings_discordId: 'comment.user.settings.discordId',
|
||||
commentedBy_settings_telegramChatId: 'comment.user.settings.telegramChatId',
|
||||
};
|
||||
|
||||
class WebhookAgent
|
||||
@@ -78,6 +95,22 @@ class WebhookAgent
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'request';
|
||||
} else if (key === '{{issue}}') {
|
||||
if (payload.issue) {
|
||||
finalPayload.issue = finalPayload[key];
|
||||
} else {
|
||||
finalPayload.issue = null;
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'issue';
|
||||
} else if (key === '{{comment}}') {
|
||||
if (payload.comment) {
|
||||
finalPayload.comment = finalPayload[key];
|
||||
} else {
|
||||
finalPayload.comment = null;
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'comment';
|
||||
}
|
||||
|
||||
if (typeof finalPayload[key] === 'string') {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import webpush from 'web-push';
|
||||
import { Notification } from '..';
|
||||
import { Notification, shouldSendAdminNotification } from '..';
|
||||
import { IssueType, IssueTypeName } from '../../../constants/issue';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { User } from '../../../entity/User';
|
||||
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
|
||||
import logger from '../../../logger';
|
||||
import { Permission } from '../../permissions';
|
||||
import {
|
||||
getSettings,
|
||||
NotificationAgentConfig,
|
||||
@@ -15,12 +15,11 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface PushNotificationPayload {
|
||||
notificationType: string;
|
||||
mediaType?: 'movie' | 'tv';
|
||||
tmdbId?: number;
|
||||
subject: string;
|
||||
message?: string;
|
||||
image?: string;
|
||||
actionUrl?: string;
|
||||
actionUrlTitle?: string;
|
||||
requestId?: number;
|
||||
}
|
||||
|
||||
@@ -42,97 +41,92 @@ class WebPushAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): PushNotificationPayload {
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
: 'series'
|
||||
: undefined;
|
||||
const is4k = payload.request?.is4k;
|
||||
|
||||
const issueType = payload.issue
|
||||
? payload.issue.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
|
||||
: 'issue'
|
||||
: undefined;
|
||||
|
||||
let message: string | undefined;
|
||||
switch (type) {
|
||||
case Notification.NONE:
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
message = payload.message;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
message = `Your ${
|
||||
is4k ? '4K ' : ''
|
||||
}${mediaType} request has been approved.`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
message = `Automatically approved a new ${
|
||||
is4k ? '4K ' : ''
|
||||
}${mediaType} request from ${
|
||||
payload.request?.requestedBy.displayName
|
||||
}.`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
message = `Your ${
|
||||
is4k ? '4K ' : ''
|
||||
}${mediaType} request is now available!`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
message = `Your ${is4k ? '4K ' : ''}${mediaType} request was declined.`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
message = `Failed to process ${is4k ? '4K ' : ''}${mediaType} request.`;
|
||||
break;
|
||||
case Notification.MEDIA_PENDING:
|
||||
message = `Approval required for a new ${
|
||||
is4k ? '4K ' : ''
|
||||
}${mediaType} request from ${
|
||||
payload.request?.requestedBy.displayName
|
||||
}.`;
|
||||
break;
|
||||
case Notification.ISSUE_CREATED:
|
||||
message = `A new ${issueType} was reported by ${payload.issue?.createdBy.displayName}.`;
|
||||
break;
|
||||
case Notification.ISSUE_COMMENT:
|
||||
message = `${payload.comment?.user.displayName} commented on the ${issueType}.`;
|
||||
break;
|
||||
case Notification.ISSUE_RESOLVED:
|
||||
message = `The ${issueType} was marked as resolved by ${payload.issue?.modifiedBy?.displayName}!`;
|
||||
break;
|
||||
case Notification.ISSUE_REOPENED:
|
||||
message = `The ${issueType} was reopened by ${payload.issue?.modifiedBy?.displayName}.`;
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: 'Unknown',
|
||||
};
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
};
|
||||
case Notification.MEDIA_APPROVED:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Your ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request has been approved.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Automatically approved a new ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request from ${payload.request?.requestedBy.displayName}.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Your ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request is now available!`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_DECLINED:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Your ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request was declined.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_FAILED:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Failed to process ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
case Notification.MEDIA_PENDING:
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message: `Approval required for new ${
|
||||
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||
} request from ${payload.request?.requestedBy.displayName}.`,
|
||||
image: payload.image,
|
||||
mediaType: payload.media?.mediaType,
|
||||
tmdbId: payload.media?.tmdbId,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const actionUrl = payload.issue
|
||||
? `/issues/${payload.issue.id}`
|
||||
: payload.media
|
||||
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
|
||||
: undefined;
|
||||
|
||||
const actionUrlTitle = actionUrl
|
||||
? `View ${payload.issue ? 'Issue' : 'Media'}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message,
|
||||
image: payload.image,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl,
|
||||
actionUrlTitle,
|
||||
};
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
@@ -151,7 +145,7 @@ class WebPushAgent
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
const settings = getSettings();
|
||||
|
||||
let pushSubs: UserPushSubscription[] = [];
|
||||
const pushSubs: UserPushSubscription[] = [];
|
||||
|
||||
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
||||
|
||||
@@ -169,13 +163,14 @@ class WebPushAgent
|
||||
where: { user: payload.notifyUser.id },
|
||||
});
|
||||
|
||||
pushSubs = notifySubs;
|
||||
} else if (!payload.notifyUser) {
|
||||
pushSubs.push(...notifySubs);
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
const users = await userRepository.find();
|
||||
|
||||
const manageUsers = users.filter(
|
||||
(user) =>
|
||||
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
// Check if user has webpush notifications enabled and fallback to true if undefined
|
||||
// since web push should default to true
|
||||
(user.settings?.hasNotificationType(
|
||||
@@ -183,9 +178,7 @@ class WebPushAgent
|
||||
type
|
||||
) ??
|
||||
true) &&
|
||||
// Check if it's the user's own auto-approved request
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !== payload.request?.requestedBy.id)
|
||||
shouldSendAdminNotification(type, user, payload)
|
||||
);
|
||||
|
||||
const allSubs = await userPushSubRepository
|
||||
@@ -196,7 +189,7 @@ class WebPushAgent
|
||||
})
|
||||
.getMany();
|
||||
|
||||
pushSubs = allSubs;
|
||||
pushSubs.push(...allSubs);
|
||||
}
|
||||
|
||||
if (mainUser && pushSubs.length > 0) {
|
||||
@@ -206,6 +199,11 @@ class WebPushAgent
|
||||
settings.vapidPrivate
|
||||
);
|
||||
|
||||
const notificationPayload = Buffer.from(
|
||||
JSON.stringify(this.getNotificationPayload(type, payload)),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
pushSubs.map(async (sub) => {
|
||||
logger.debug('Sending web push notification', {
|
||||
@@ -224,10 +222,7 @@ class WebPushAgent
|
||||
p256dh: sub.p256dh,
|
||||
},
|
||||
},
|
||||
Buffer.from(
|
||||
JSON.stringify(this.getNotificationPayload(type, payload)),
|
||||
'utf-8'
|
||||
)
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { User } from '../../entity/User';
|
||||
import logger from '../../logger';
|
||||
import { Permission } from '../permissions';
|
||||
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
||||
|
||||
export enum Notification {
|
||||
@@ -10,6 +12,10 @@ export enum Notification {
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
MEDIA_AUTO_APPROVED = 128,
|
||||
ISSUE_CREATED = 256,
|
||||
ISSUE_COMMENT = 512,
|
||||
ISSUE_RESOLVED = 1024,
|
||||
ISSUE_REOPENED = 2048,
|
||||
}
|
||||
|
||||
export const hasNotificationType = (
|
||||
@@ -38,6 +44,50 @@ export const hasNotificationType = (
|
||||
return !!(value & total);
|
||||
};
|
||||
|
||||
export const getAdminPermission = (type: Notification): Permission => {
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
case Notification.MEDIA_FAILED:
|
||||
case Notification.MEDIA_DECLINED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
return Permission.MANAGE_REQUESTS;
|
||||
case Notification.ISSUE_CREATED:
|
||||
case Notification.ISSUE_COMMENT:
|
||||
case Notification.ISSUE_RESOLVED:
|
||||
case Notification.ISSUE_REOPENED:
|
||||
return Permission.MANAGE_ISSUES;
|
||||
default:
|
||||
return Permission.ADMIN;
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldSendAdminNotification = (
|
||||
type: Notification,
|
||||
user: User,
|
||||
payload: NotificationPayload
|
||||
): boolean => {
|
||||
return (
|
||||
user.id !== payload.notifyUser?.id &&
|
||||
user.hasPermission(getAdminPermission(type)) &&
|
||||
// Check if the user submitted this request (on behalf of themself OR another user)
|
||||
(type !== Notification.MEDIA_AUTO_APPROVED ||
|
||||
user.id !==
|
||||
(payload.request?.modifiedBy ?? payload.request?.requestedBy)?.id) &&
|
||||
// Check if the user created this issue
|
||||
(type !== Notification.ISSUE_CREATED ||
|
||||
user.id !== payload.issue?.createdBy.id) &&
|
||||
// Check if the user submitted this issue comment
|
||||
(type !== Notification.ISSUE_COMMENT ||
|
||||
user.id !== payload.comment?.user.id) &&
|
||||
// Check if the user resolved/reopened this issue
|
||||
((type !== Notification.ISSUE_RESOLVED &&
|
||||
type !== Notification.ISSUE_REOPENED) ||
|
||||
user.id !== payload.issue?.modifiedBy?.id)
|
||||
);
|
||||
};
|
||||
|
||||
class NotificationManager {
|
||||
private activeAgents: NotificationAgent[] = [];
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ export enum Permission {
|
||||
AUTO_APPROVE_4K_TV = 131072,
|
||||
REQUEST_MOVIE = 262144,
|
||||
REQUEST_TV = 524288,
|
||||
MANAGE_ISSUES = 1048576,
|
||||
VIEW_ISSUES = 2097152,
|
||||
CREATE_ISSUES = 4194304,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
|
||||
@@ -350,7 +350,7 @@ class Settings {
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
jsonPayload:
|
||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
|
||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||
},
|
||||
},
|
||||
webpush: {
|
||||
|
||||
55
server/migration/1634904083966-AddIssues.ts
Normal file
55
server/migration/1634904083966-AddIssues.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddIssues1634904083966 implements MigrationInterface {
|
||||
name = 'AddIssues1634904083966';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT 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" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT 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" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "issue"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "issue_comment"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "issue_comment" RENAME TO "temporary_issue_comment"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "temporary_issue_comment"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_issue_comment"`);
|
||||
await queryRunner.query(`ALTER TABLE "issue" RENAME TO "temporary_issue"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT 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" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "temporary_issue"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_issue"`);
|
||||
await queryRunner.query(`DROP TABLE "issue_comment"`);
|
||||
await queryRunner.query(`DROP TABLE "issue"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPushbulletPushoverUserSettings1635079863457
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddPushbulletPushoverUserSettings1635079863457';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" 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", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" 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<void> {
|
||||
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, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), 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", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sortBy } from 'lodash';
|
||||
import type { TmdbCollection } from '../api/themoviedb/interfaces';
|
||||
import { MediaType } from '../constants/media';
|
||||
import Media from '../entity/Media';
|
||||
@@ -21,7 +22,7 @@ export const mapCollection = (
|
||||
overview: collection.overview,
|
||||
posterPath: collection.poster_path,
|
||||
backdropPath: collection.backdrop_path,
|
||||
parts: collection.parts.map((part) =>
|
||||
parts: sortBy(collection.parts, 'release_date').map((part) =>
|
||||
mapMovieResult(
|
||||
part,
|
||||
media?.find(
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface TvDetails {
|
||||
overview: string;
|
||||
popularity: number;
|
||||
productionCompanies: ProductionCompany[];
|
||||
productionCountries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
}[];
|
||||
spokenLanguages: SpokenLanguage[];
|
||||
seasons: Season[];
|
||||
status: string;
|
||||
@@ -187,6 +191,7 @@ export const mapTvDetails = (
|
||||
originCountry: company.origin_country,
|
||||
logoPath: company.logo_path,
|
||||
})),
|
||||
productionCountries: show.production_countries,
|
||||
contentRatings: show.content_ratings,
|
||||
spokenLanguages: show.spoken_languages.map((language) => ({
|
||||
englishName: language.english_name,
|
||||
|
||||
@@ -14,6 +14,8 @@ import { isPerson } from '../utils/typeHelpers';
|
||||
import authRoutes from './auth';
|
||||
import collectionRoutes from './collection';
|
||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||
import issueRoutes from './issue';
|
||||
import issueCommentRoutes from './issueComment';
|
||||
import mediaRoutes from './media';
|
||||
import movieRoutes from './movie';
|
||||
import personRoutes from './person';
|
||||
@@ -108,6 +110,8 @@ router.use('/media', isAuthenticated(), mediaRoutes);
|
||||
router.use('/person', isAuthenticated(), personRoutes);
|
||||
router.use('/collection', isAuthenticated(), collectionRoutes);
|
||||
router.use('/service', isAuthenticated(), serviceRoutes);
|
||||
router.use('/issue', isAuthenticated(), issueRoutes);
|
||||
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
|
||||
router.get('/regions', isAuthenticated(), async (req, res) => {
|
||||
|
||||
332
server/routes/issue.ts
Normal file
332
server/routes/issue.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { Router } from 'express';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { IssueStatus } from '../constants/issue';
|
||||
import Issue from '../entity/Issue';
|
||||
import IssueComment from '../entity/IssueComment';
|
||||
import Media from '../entity/Media';
|
||||
import { IssueResultsResponse } from '../interfaces/api/issueInterfaces';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const issueRoutes = Router();
|
||||
|
||||
issueRoutes.get<Record<string, string>, IssueResultsResponse>(
|
||||
'/',
|
||||
isAuthenticated(
|
||||
[
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
],
|
||||
{ type: 'or' }
|
||||
),
|
||||
async (req, res, next) => {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const createdBy = req.query.createdBy ? Number(req.query.createdBy) : null;
|
||||
|
||||
let sortFilter: string;
|
||||
|
||||
switch (req.query.sort) {
|
||||
case 'modified':
|
||||
sortFilter = 'issue.updatedAt';
|
||||
break;
|
||||
default:
|
||||
sortFilter = 'issue.createdAt';
|
||||
}
|
||||
|
||||
let statusFilter: IssueStatus[];
|
||||
|
||||
switch (req.query.filter) {
|
||||
case 'open':
|
||||
statusFilter = [IssueStatus.OPEN];
|
||||
break;
|
||||
case 'resolved':
|
||||
statusFilter = [IssueStatus.RESOLVED];
|
||||
break;
|
||||
default:
|
||||
statusFilter = [IssueStatus.OPEN, IssueStatus.RESOLVED];
|
||||
}
|
||||
|
||||
let query = getRepository(Issue)
|
||||
.createQueryBuilder('issue')
|
||||
.leftJoinAndSelect('issue.createdBy', 'createdBy')
|
||||
.leftJoinAndSelect('issue.media', 'media')
|
||||
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
|
||||
.where('issue.status IN (:...issueStatus)', {
|
||||
issueStatus: statusFilter,
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
if (createdBy && createdBy !== req.user?.id) {
|
||||
return next({
|
||||
status: 403,
|
||||
message:
|
||||
'You do not have permission to view issues reported by other users',
|
||||
});
|
||||
}
|
||||
query = query.andWhere('createdBy.id = :id', { id: req.user?.id });
|
||||
} else if (createdBy) {
|
||||
query = query.andWhere('createdBy.id = :id', { id: createdBy });
|
||||
}
|
||||
|
||||
const [issues, issueCount] = await query
|
||||
.orderBy(sortFilter, 'DESC')
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(issueCount / pageSize),
|
||||
pageSize,
|
||||
results: issueCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: issues,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.post<
|
||||
Record<string, string>,
|
||||
Issue,
|
||||
{
|
||||
message: string;
|
||||
mediaId: number;
|
||||
issueType: number;
|
||||
problemSeason: number;
|
||||
problemEpisode: number;
|
||||
}
|
||||
>(
|
||||
'/',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request.' });
|
||||
}
|
||||
|
||||
const issueRepository = getRepository(Issue);
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { id: req.body.mediaId },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
return next({ status: 404, message: 'Media does not exist.' });
|
||||
}
|
||||
|
||||
const issue = new Issue({
|
||||
createdBy: req.user,
|
||||
issueType: req.body.issueType,
|
||||
problemSeason: req.body.problemSeason,
|
||||
problemEpisode: req.body.problemEpisode,
|
||||
media,
|
||||
comments: [
|
||||
new IssueComment({
|
||||
user: req.user,
|
||||
message: req.body.message,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const newIssue = await issueRepository.save(issue);
|
||||
|
||||
return res.status(200).json(newIssue);
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.get<{ issueId: string }>(
|
||||
'/:issueId',
|
||||
isAuthenticated(
|
||||
[
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
],
|
||||
{ type: 'or' }
|
||||
),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await issueRepository
|
||||
.createQueryBuilder('issue')
|
||||
.leftJoinAndSelect('issue.comments', 'comments')
|
||||
.leftJoinAndSelect('issue.createdBy', 'createdBy')
|
||||
.leftJoinAndSelect('comments.user', 'user')
|
||||
.leftJoinAndSelect('issue.media', 'media')
|
||||
.where('issue.id = :issueId', { issueId: Number(req.params.issueId) })
|
||||
.getOneOrFail();
|
||||
|
||||
if (
|
||||
issue.createdBy.id !== req.user.id &&
|
||||
!req.user.hasPermission(
|
||||
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to view this issue.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(issue);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to retrieve issue.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 500, message: 'Issue not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
|
||||
'/:issueId/comment',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await issueRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.issueId) },
|
||||
});
|
||||
|
||||
if (
|
||||
issue.createdBy.id !== req.user.id &&
|
||||
!req.user.hasPermission(Permission.MANAGE_ISSUES)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to comment on this issue.',
|
||||
});
|
||||
}
|
||||
|
||||
const comment = new IssueComment({
|
||||
message: req.body.message,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
issue.comments = [...issue.comments, comment];
|
||||
|
||||
await issueRepository.save(issue);
|
||||
|
||||
return res.status(200).json(issue);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong creating an issue comment.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 500, message: 'Issue not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.post<{ issueId: string; status: string }, Issue>(
|
||||
'/:issueId/:status',
|
||||
isAuthenticated(Permission.MANAGE_ISSUES),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
if (!req.user) {
|
||||
return next({ status: 500, message: 'User missing from request.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await issueRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.issueId) },
|
||||
});
|
||||
|
||||
let newStatus: IssueStatus | undefined;
|
||||
|
||||
switch (req.params.status) {
|
||||
case 'resolved':
|
||||
newStatus = IssueStatus.RESOLVED;
|
||||
break;
|
||||
case 'open':
|
||||
newStatus = IssueStatus.OPEN;
|
||||
}
|
||||
|
||||
if (!newStatus) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'You must provide a valid status',
|
||||
});
|
||||
}
|
||||
|
||||
issue.status = newStatus;
|
||||
issue.modifiedBy = req.user;
|
||||
|
||||
await issueRepository.save(issue);
|
||||
|
||||
return res.status(200).json(issue);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong creating an issue comment.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 500, message: 'Issue not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueRoutes.delete(
|
||||
'/:issueId',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
|
||||
try {
|
||||
const issue = await issueRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.issueId) },
|
||||
relations: ['createdBy'],
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
(issue.createdBy.id !== req.user?.id || issue.comments.length > 1)
|
||||
) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You do not have permission to delete this issue.',
|
||||
});
|
||||
}
|
||||
|
||||
await issueRepository.remove(issue);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong deleting an issue.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Issue not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default issueRoutes;
|
||||
129
server/routes/issueComment.ts
Normal file
129
server/routes/issueComment.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Router } from 'express';
|
||||
import { getRepository } from 'typeorm';
|
||||
import IssueComment from '../entity/IssueComment';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const issueCommentRoutes = Router();
|
||||
|
||||
issueCommentRoutes.get<{ commentId: string }, IssueComment>(
|
||||
'/:commentId',
|
||||
isAuthenticated(
|
||||
[
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
),
|
||||
async (req, res, next) => {
|
||||
const issueCommentRepository = getRepository(IssueComment);
|
||||
|
||||
try {
|
||||
const comment = await issueCommentRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.commentId) },
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
||||
{ type: 'or' }
|
||||
) &&
|
||||
comment.user.id !== req.user?.id
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to view this comment.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(comment);
|
||||
} catch (e) {
|
||||
logger.debug('Request for unknown issue comment failed', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Issue comment not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueCommentRoutes.put<
|
||||
{ commentId: string },
|
||||
IssueComment,
|
||||
{ message: string }
|
||||
>(
|
||||
'/:commentId',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueCommentRepository = getRepository(IssueComment);
|
||||
|
||||
try {
|
||||
const comment = await issueCommentRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.commentId) },
|
||||
});
|
||||
|
||||
if (comment.user.id !== req.user?.id) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You can only edit your own comments.',
|
||||
});
|
||||
}
|
||||
|
||||
comment.message = req.body.message;
|
||||
|
||||
await issueCommentRepository.save(comment);
|
||||
|
||||
return res.status(200).json(comment);
|
||||
} catch (e) {
|
||||
logger.debug('Put request for issue comment failed', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Issue comment not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
issueCommentRoutes.delete<{ commentId: string }, IssueComment>(
|
||||
'/:commentId',
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueCommentRepository = getRepository(IssueComment);
|
||||
|
||||
try {
|
||||
const comment = await issueCommentRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.commentId) },
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission([Permission.MANAGE_ISSUES], { type: 'or' }) &&
|
||||
comment.user.id !== req.user?.id
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to delete this comment.',
|
||||
});
|
||||
}
|
||||
|
||||
await issueCommentRepository.remove(comment);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.debug('Delete request for issue comment failed', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Issue comment not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default issueCommentRoutes;
|
||||
@@ -13,131 +13,134 @@ import { isAuthenticated } from '../middleware/auth';
|
||||
|
||||
const requestRoutes = Router();
|
||||
|
||||
requestRoutes.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 requestedBy = req.query.requestedBy
|
||||
? Number(req.query.requestedBy)
|
||||
: null;
|
||||
requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
'/',
|
||||
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 requestedBy = req.query.requestedBy
|
||||
? Number(req.query.requestedBy)
|
||||
: null;
|
||||
|
||||
let statusFilter: MediaRequestStatus[];
|
||||
let statusFilter: MediaRequestStatus[];
|
||||
|
||||
switch (req.query.filter) {
|
||||
case 'approved':
|
||||
case 'processing':
|
||||
case 'available':
|
||||
statusFilter = [MediaRequestStatus.APPROVED];
|
||||
break;
|
||||
case 'pending':
|
||||
statusFilter = [MediaRequestStatus.PENDING];
|
||||
break;
|
||||
case 'unavailable':
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
];
|
||||
break;
|
||||
default:
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
MediaRequestStatus.DECLINED,
|
||||
];
|
||||
}
|
||||
switch (req.query.filter) {
|
||||
case 'approved':
|
||||
case 'processing':
|
||||
case 'available':
|
||||
statusFilter = [MediaRequestStatus.APPROVED];
|
||||
break;
|
||||
case 'pending':
|
||||
statusFilter = [MediaRequestStatus.PENDING];
|
||||
break;
|
||||
case 'unavailable':
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
];
|
||||
break;
|
||||
default:
|
||||
statusFilter = [
|
||||
MediaRequestStatus.PENDING,
|
||||
MediaRequestStatus.APPROVED,
|
||||
MediaRequestStatus.DECLINED,
|
||||
];
|
||||
}
|
||||
|
||||
let mediaStatusFilter: MediaStatus[];
|
||||
let mediaStatusFilter: MediaStatus[];
|
||||
|
||||
switch (req.query.filter) {
|
||||
case 'available':
|
||||
mediaStatusFilter = [MediaStatus.AVAILABLE];
|
||||
break;
|
||||
case 'processing':
|
||||
case 'unavailable':
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
MediaStatus.PENDING,
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
];
|
||||
break;
|
||||
default:
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
MediaStatus.PENDING,
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
MediaStatus.AVAILABLE,
|
||||
];
|
||||
}
|
||||
switch (req.query.filter) {
|
||||
case 'available':
|
||||
mediaStatusFilter = [MediaStatus.AVAILABLE];
|
||||
break;
|
||||
case 'processing':
|
||||
case 'unavailable':
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
MediaStatus.PENDING,
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
];
|
||||
break;
|
||||
default:
|
||||
mediaStatusFilter = [
|
||||
MediaStatus.UNKNOWN,
|
||||
MediaStatus.PENDING,
|
||||
MediaStatus.PROCESSING,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
MediaStatus.AVAILABLE,
|
||||
];
|
||||
}
|
||||
|
||||
let sortFilter: string;
|
||||
let sortFilter: string;
|
||||
|
||||
switch (req.query.sort) {
|
||||
case 'modified':
|
||||
sortFilter = 'request.updatedAt';
|
||||
break;
|
||||
default:
|
||||
sortFilter = 'request.id';
|
||||
}
|
||||
switch (req.query.sort) {
|
||||
case 'modified':
|
||||
sortFilter = 'request.updatedAt';
|
||||
break;
|
||||
default:
|
||||
sortFilter = 'request.id';
|
||||
}
|
||||
|
||||
let query = getRepository(MediaRequest)
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.leftJoinAndSelect('request.seasons', 'seasons')
|
||||
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
|
||||
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
|
||||
.where('request.status IN (:...requestStatus)', {
|
||||
requestStatus: statusFilter,
|
||||
})
|
||||
.andWhere(
|
||||
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))',
|
||||
{
|
||||
mediaStatus: mediaStatusFilter,
|
||||
let query = getRepository(MediaRequest)
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.leftJoinAndSelect('request.seasons', 'seasons')
|
||||
.leftJoinAndSelect('request.modifiedBy', 'modifiedBy')
|
||||
.leftJoinAndSelect('request.requestedBy', 'requestedBy')
|
||||
.where('request.status IN (:...requestStatus)', {
|
||||
requestStatus: statusFilter,
|
||||
})
|
||||
.andWhere(
|
||||
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))',
|
||||
{
|
||||
mediaStatus: mediaStatusFilter,
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
if (requestedBy && requestedBy !== req.user?.id) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to view this user's requests.",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
if (requestedBy && requestedBy !== req.user?.id) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to view this user's requests.",
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: req.user?.id,
|
||||
});
|
||||
} else if (requestedBy) {
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: requestedBy,
|
||||
});
|
||||
}
|
||||
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: req.user?.id,
|
||||
});
|
||||
} else if (requestedBy) {
|
||||
query = query.andWhere('requestedBy.id = :id', {
|
||||
id: requestedBy,
|
||||
const [requests, requestCount] = await query
|
||||
.orderBy(sortFilter, 'DESC')
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(requestCount / pageSize),
|
||||
pageSize,
|
||||
results: requestCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: requests,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
|
||||
const [requests, requestCount] = await query
|
||||
.orderBy(sortFilter, 'DESC')
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(requestCount / pageSize),
|
||||
pageSize,
|
||||
results: requestCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: requests,
|
||||
} as RequestResultsResponse);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
requestRoutes.post('/', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
@@ -497,9 +500,26 @@ requestRoutes.get('/:requestId', async (req, res, next) => {
|
||||
relations: ['requestedBy', 'modifiedBy'],
|
||||
});
|
||||
|
||||
if (
|
||||
request.requestedBy.id !== req.user?.id &&
|
||||
!req.user?.hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to view this request.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(request);
|
||||
} catch (e) {
|
||||
next({ status: 404, message: 'Request not found' });
|
||||
logger.debug('Failed to retrieve request.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Request not found.' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -529,11 +549,11 @@ requestRoutes.put<{ requestId: string }>(
|
||||
});
|
||||
}
|
||||
|
||||
let requestUser = req.user;
|
||||
let requestUser = request.requestedBy;
|
||||
|
||||
if (
|
||||
req.body.userId &&
|
||||
req.body.userId !== req.user?.id &&
|
||||
req.body.userId !== request.requestedBy.id &&
|
||||
!req.user?.hasPermission([
|
||||
Permission.MANAGE_USERS,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
@@ -665,7 +685,10 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error(e.message);
|
||||
logger.error('Something went wrong deleting a request.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Request not found.' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -258,25 +258,29 @@ settingsRoutes.get(
|
||||
try {
|
||||
fs.readFileSync(logFile)
|
||||
.toString()
|
||||
.split('\n')
|
||||
.split(/(?=\n\d{4}-\d{2})/g)
|
||||
.forEach((line) => {
|
||||
if (!line.length) return;
|
||||
|
||||
const timestamp = line.match(new RegExp(/^.{24}/)) || [];
|
||||
const level = line.match(new RegExp(/\s\[\w+\]/)) || [];
|
||||
const label = line.match(new RegExp(/\]\[.+?\]/)) || [];
|
||||
const message = line.match(new RegExp(/:\s([^{}]+)({.*})?/)) || [];
|
||||
const jsonRegexp = new RegExp(
|
||||
/[{[]{1}([,:{}[\]0-9.\-+Eaeflnr-u \n\r\t]|"[^"\n]*?")+[}\]]{1}/
|
||||
);
|
||||
|
||||
if (level.length && filter.includes(level[0].slice(2, -1))) {
|
||||
const timestamp = line.match(new RegExp(/.{24}/)) || [];
|
||||
const level = line.match(new RegExp(/(?<=.{24}\s\[).+?(?=\])/)) || [];
|
||||
const label =
|
||||
line.match(new RegExp(/(?<=.{24}\s\[.+\]\[).+?(?=\])/)) || [];
|
||||
const message =
|
||||
line.match(new RegExp(/(?<=\[.+\]:\s)[\s\S][^\r]+/)) || [];
|
||||
const data = message[0].match(jsonRegexp) || [];
|
||||
|
||||
if (level.length && filter.includes(level[0])) {
|
||||
logs.push({
|
||||
timestamp: timestamp[0],
|
||||
level: level.length ? level[0].slice(2, -1) : '',
|
||||
label: label.length ? label[0].slice(2, -1) : '',
|
||||
message: message.length && message[1] ? message[1] : '',
|
||||
data:
|
||||
message.length && message[2]
|
||||
? JSON.parse(message[2])
|
||||
: undefined,
|
||||
level: level[0],
|
||||
label: label[0],
|
||||
message: message[0].replace(jsonRegexp, ''),
|
||||
data: data.length ? JSON.parse(data[0]) : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { User } from '../../entity/User';
|
||||
import { Notification } from '../../lib/notifications';
|
||||
import { NotificationAgent } from '../../lib/notifications/agents/agent';
|
||||
import DiscordAgent from '../../lib/notifications/agents/discord';
|
||||
import EmailAgent from '../../lib/notifications/agents/email';
|
||||
import LunaSeaAgent from '../../lib/notifications/agents/lunasea';
|
||||
@@ -13,6 +15,14 @@ import { getSettings } from '../../lib/settings';
|
||||
|
||||
const notificationRoutes = Router();
|
||||
|
||||
const sendTestNotification = async (agent: NotificationAgent, user: User) =>
|
||||
await agent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyAdmin: false,
|
||||
notifyUser: user,
|
||||
subject: 'Test Notification',
|
||||
message: 'Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
notificationRoutes.get('/discord', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -37,14 +47,7 @@ notificationRoutes.post('/discord/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const discordAgent = new DiscordAgent(req.body);
|
||||
if (
|
||||
await discordAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(discordAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -78,14 +81,7 @@ notificationRoutes.post('/slack/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const slackAgent = new SlackAgent(req.body);
|
||||
if (
|
||||
await slackAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(slackAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -119,14 +115,7 @@ notificationRoutes.post('/telegram/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const telegramAgent = new TelegramAgent(req.body);
|
||||
if (
|
||||
await telegramAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(telegramAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -160,14 +149,7 @@ notificationRoutes.post('/pushbullet/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const pushbulletAgent = new PushbulletAgent(req.body);
|
||||
if (
|
||||
await pushbulletAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(pushbulletAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -201,14 +183,7 @@ notificationRoutes.post('/pushover/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const pushoverAgent = new PushoverAgent(req.body);
|
||||
if (
|
||||
await pushoverAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(pushoverAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -242,14 +217,7 @@ notificationRoutes.post('/email/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const emailAgent = new EmailAgent(req.body);
|
||||
if (
|
||||
await emailAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(emailAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -283,14 +251,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const webpushAgent = new WebPushAgent(req.body);
|
||||
if (
|
||||
await webpushAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(webpushAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -369,14 +330,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
|
||||
};
|
||||
|
||||
const webhookAgent = new WebhookAgent(testBody);
|
||||
if (
|
||||
await webhookAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(webhookAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
@@ -413,14 +367,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const lunaseaAgent = new LunaSeaAgent(req.body);
|
||||
if (
|
||||
await lunaseaAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
})
|
||||
) {
|
||||
if (await sendTestNotification(lunaseaAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
|
||||
@@ -46,7 +46,10 @@ radarrRoutes.post<
|
||||
url: RadarrAPI.buildUrl(req.body, '/api/v3'),
|
||||
});
|
||||
|
||||
const { urlBase } = await radarr.getSystemStatus();
|
||||
const urlBase = await radarr
|
||||
.getSystemStatus()
|
||||
.then((value) => value.urlBase)
|
||||
.catch(() => req.body.baseUrl);
|
||||
const profiles = await radarr.getProfiles();
|
||||
const folders = await radarr.getRootFolders();
|
||||
const tags = await radarr.getTags();
|
||||
@@ -58,10 +61,7 @@ radarrRoutes.post<
|
||||
path: folder.path,
|
||||
})),
|
||||
tags,
|
||||
urlBase:
|
||||
req.body.baseUrl && req.body.baseUrl !== '/'
|
||||
? req.body.baseUrl
|
||||
: urlBase,
|
||||
urlBase,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to test Radarr', {
|
||||
|
||||
@@ -42,7 +42,10 @@ sonarrRoutes.post('/test', async (req, res, next) => {
|
||||
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
|
||||
});
|
||||
|
||||
const { urlBase } = await sonarr.getSystemStatus();
|
||||
const urlBase = await sonarr
|
||||
.getSystemStatus()
|
||||
.then((value) => value.urlBase)
|
||||
.catch(() => req.body.baseUrl);
|
||||
const profiles = await sonarr.getProfiles();
|
||||
const folders = await sonarr.getRootFolders();
|
||||
const languageProfiles = await sonarr.getLanguageProfiles();
|
||||
@@ -56,10 +59,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
|
||||
})),
|
||||
languageProfiles,
|
||||
tags,
|
||||
urlBase:
|
||||
req.body.baseUrl && req.body.baseUrl !== '/'
|
||||
? req.body.baseUrl
|
||||
: urlBase,
|
||||
urlBase,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to test Sonarr', {
|
||||
|
||||
@@ -257,6 +257,9 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
? settings?.discord.types
|
||||
: 0,
|
||||
discordId: user.settings?.discordId,
|
||||
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
|
||||
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
|
||||
pushoverUserKey: user.settings?.pushoverUserKey,
|
||||
telegramEnabled: settings?.telegram.enabled,
|
||||
telegramBotUsername: settings?.telegram.options.botUsername,
|
||||
telegramChatId: user.settings?.telegramChatId,
|
||||
@@ -298,6 +301,9 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
user: req.user,
|
||||
pgpKey: req.body.pgpKey,
|
||||
discordId: req.body.discordId,
|
||||
pushbulletAccessToken: req.body.pushbulletAccessToken,
|
||||
pushoverApplicationToken: req.body.pushoverApplicationToken,
|
||||
pushoverUserKey: req.body.pushoverUserKey,
|
||||
telegramChatId: req.body.telegramChatId,
|
||||
telegramSendSilently: req.body.telegramSendSilently,
|
||||
notificationTypes: req.body.notificationTypes,
|
||||
@@ -305,6 +311,10 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
} else {
|
||||
user.settings.pgpKey = req.body.pgpKey;
|
||||
user.settings.discordId = req.body.discordId;
|
||||
user.settings.pushbulletAccessToken = req.body.pushbulletAccessToken;
|
||||
user.settings.pushoverApplicationToken =
|
||||
req.body.pushoverApplicationToken;
|
||||
user.settings.pushoverUserKey = req.body.pushoverUserKey;
|
||||
user.settings.telegramChatId = req.body.telegramChatId;
|
||||
user.settings.telegramSendSilently = req.body.telegramSendSilently;
|
||||
user.settings.notificationTypes = Object.assign(
|
||||
@@ -319,6 +329,9 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
return res.status(200).json({
|
||||
pgpKey: user.settings?.pgpKey,
|
||||
discordId: user.settings?.discordId,
|
||||
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
|
||||
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
|
||||
pushoverUserKey: user.settings?.pushoverUserKey,
|
||||
telegramChatId: user.settings?.telegramChatId,
|
||||
telegramSendSilently: user?.settings?.telegramSendSilently,
|
||||
notificationTypes: user.settings.notificationTypes,
|
||||
|
||||
95
server/subscriber/IssueCommentSubscriber.ts
Normal file
95
server/subscriber/IssueCommentSubscriber.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { sortBy } from 'lodash';
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
getRepository,
|
||||
InsertEvent,
|
||||
} from 'typeorm';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { IssueType, IssueTypeName } from '../constants/issue';
|
||||
import { MediaType } from '../constants/media';
|
||||
import IssueComment from '../entity/IssueComment';
|
||||
import Media from '../entity/Media';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import { Permission } from '../lib/permissions';
|
||||
|
||||
@EventSubscriber()
|
||||
export class IssueCommentSubscriber
|
||||
implements EntitySubscriberInterface<IssueComment>
|
||||
{
|
||||
public listenTo(): typeof IssueComment {
|
||||
return IssueComment;
|
||||
}
|
||||
|
||||
private async sendIssueCommentNotification(entity: IssueComment) {
|
||||
let title: string;
|
||||
let image: string;
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const issue = (
|
||||
await getRepository(IssueComment).findOne({
|
||||
where: { id: entity.id },
|
||||
relations: ['issue'],
|
||||
})
|
||||
)?.issue;
|
||||
if (!issue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = await getRepository(Media).findOne({
|
||||
where: { id: issue.media.id },
|
||||
});
|
||||
if (!media) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (media.mediaType === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
||||
|
||||
title = `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
||||
} else {
|
||||
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
|
||||
title = `${tvshow.name}${
|
||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||
}
|
||||
|
||||
const [firstComment] = sortBy(issue.comments, 'id');
|
||||
|
||||
if (entity.id !== firstComment.id) {
|
||||
// Send notifications to all issue managers
|
||||
notificationManager.sendNotification(Notification.ISSUE_COMMENT, {
|
||||
event: `New Comment on ${
|
||||
issue.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[issue.issueType]} `
|
||||
: ''
|
||||
}Issue`,
|
||||
subject: title,
|
||||
message: firstComment.message,
|
||||
comment: entity,
|
||||
issue,
|
||||
media,
|
||||
image,
|
||||
notifyAdmin: true,
|
||||
notifyUser:
|
||||
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
issue.createdBy.id !== entity.user.id
|
||||
? issue.createdBy
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public afterInsert(event: InsertEvent<IssueComment>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendIssueCommentNotification(event.entity);
|
||||
}
|
||||
}
|
||||
124
server/subscriber/IssueSubscriber.ts
Normal file
124
server/subscriber/IssueSubscriber.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { sortBy } from 'lodash';
|
||||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
InsertEvent,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { IssueStatus, IssueType, IssueTypeName } from '../constants/issue';
|
||||
import { MediaType } from '../constants/media';
|
||||
import Issue from '../entity/Issue';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import { Permission } from '../lib/permissions';
|
||||
|
||||
@EventSubscriber()
|
||||
export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
||||
public listenTo(): typeof Issue {
|
||||
return Issue;
|
||||
}
|
||||
|
||||
private async sendIssueNotification(entity: Issue, type: Notification) {
|
||||
let title: string;
|
||||
let image: string;
|
||||
const tmdb = new TheMovieDb();
|
||||
if (entity.media.mediaType === MediaType.MOVIE) {
|
||||
const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
|
||||
|
||||
title = `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
|
||||
} else {
|
||||
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||
|
||||
title = `${tvshow.name}${
|
||||
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
|
||||
}`;
|
||||
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
|
||||
}
|
||||
|
||||
const [firstComment] = sortBy(entity.comments, 'id');
|
||||
const extra: { name: string; value: string }[] = [];
|
||||
|
||||
if (entity.media.mediaType === MediaType.TV && entity.problemSeason > 0) {
|
||||
extra.push({
|
||||
name: 'Affected Season',
|
||||
value: entity.problemSeason.toString(),
|
||||
});
|
||||
|
||||
if (entity.problemEpisode > 0) {
|
||||
extra.push({
|
||||
name: 'Affected Episode',
|
||||
value: entity.problemEpisode.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
notificationManager.sendNotification(type, {
|
||||
event:
|
||||
type === Notification.ISSUE_CREATED
|
||||
? `New ${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Reported`
|
||||
: type === Notification.ISSUE_RESOLVED
|
||||
? `${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Resolved`
|
||||
: `${
|
||||
entity.issueType !== IssueType.OTHER
|
||||
? `${IssueTypeName[entity.issueType]} `
|
||||
: ''
|
||||
}Issue Reopened`,
|
||||
subject: title,
|
||||
message: firstComment.message,
|
||||
issue: entity,
|
||||
media: entity.media,
|
||||
image,
|
||||
extra,
|
||||
notifyAdmin: true,
|
||||
notifyUser:
|
||||
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
(type === Notification.ISSUE_RESOLVED ||
|
||||
type === Notification.ISSUE_REOPENED)
|
||||
? entity.createdBy
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public afterInsert(event: InsertEvent<Issue>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendIssueNotification(event.entity, Notification.ISSUE_CREATED);
|
||||
}
|
||||
|
||||
public beforeUpdate(event: UpdateEvent<Issue>): void {
|
||||
if (!event.entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.status === IssueStatus.RESOLVED &&
|
||||
event.databaseEntity.status !== IssueStatus.RESOLVED
|
||||
) {
|
||||
this.sendIssueNotification(
|
||||
event.entity as Issue,
|
||||
Notification.ISSUE_RESOLVED
|
||||
);
|
||||
} else if (
|
||||
event.entity.status === IssueStatus.OPEN &&
|
||||
event.databaseEntity.status !== IssueStatus.OPEN
|
||||
) {
|
||||
this.sendIssueNotification(
|
||||
event.entity as Issue,
|
||||
Notification.ISSUE_REOPENED
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
getRepository,
|
||||
Not,
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
@@ -13,16 +14,24 @@ import Season from '../entity/Season';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
private async notifyAvailableMovie(entity: Media, dbEntity?: Media) {
|
||||
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||
private async notifyAvailableMovie(
|
||||
entity: Media,
|
||||
dbEntity: Media,
|
||||
is4k: boolean
|
||||
) {
|
||||
if (
|
||||
entity.status === MediaStatus.AVAILABLE &&
|
||||
dbEntity?.status !== MediaStatus.AVAILABLE
|
||||
entity[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||
dbEntity[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
if (entity.mediaType === MediaType.MOVIE) {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const relatedRequests = await requestRepository.find({
|
||||
where: { media: entity, is4k: false },
|
||||
where: {
|
||||
media: entity,
|
||||
is4k,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
});
|
||||
|
||||
if (relatedRequests.length > 0) {
|
||||
@@ -31,6 +40,8 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
|
||||
relatedRequests.forEach((request) => {
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||
notifyAdmin: false,
|
||||
notifyUser: request.requestedBy,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
@@ -42,7 +53,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
}),
|
||||
media: entity,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: request,
|
||||
request,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -50,15 +61,25 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(entity: Media, dbEntity: Media) {
|
||||
private async notifyAvailableSeries(
|
||||
entity: Media,
|
||||
dbEntity: Media,
|
||||
is4k: boolean
|
||||
) {
|
||||
const seasonRepository = getRepository(Season);
|
||||
const newAvailableSeasons = entity.seasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
const oldSeasonIds = dbEntity.seasons.map((season) => season.id);
|
||||
const oldSeasons = await seasonRepository.findByIds(oldSeasonIds);
|
||||
const oldAvailableSeasons = oldSeasons
|
||||
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||
.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
const changedSeasons = newAvailableSeasons.filter(
|
||||
@@ -72,7 +93,11 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
|
||||
for (const changedSeasonNumber of changedSeasons) {
|
||||
const requests = await requestRepository.find({
|
||||
where: { media: entity, is4k: false },
|
||||
where: {
|
||||
media: entity,
|
||||
is4k,
|
||||
status: Not(MediaRequestStatus.DECLINED),
|
||||
},
|
||||
});
|
||||
const request = requests.find(
|
||||
(request) =>
|
||||
@@ -91,6 +116,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
);
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${is4k ? '4K ' : ''}Series Request Now Available`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
@@ -99,18 +125,19 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
notifyAdmin: false,
|
||||
notifyUser: request.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
media: entity,
|
||||
extra: [
|
||||
{
|
||||
name: 'Seasons',
|
||||
name: 'Requested Seasons',
|
||||
value: request.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: request,
|
||||
request,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -144,7 +171,22 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(event.entity as Media, event.databaseEntity);
|
||||
this.notifyAvailableMovie(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.MOVIE &&
|
||||
event.entity.status4k === MediaStatus.AVAILABLE
|
||||
) {
|
||||
this.notifyAvailableMovie(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -152,7 +194,23 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
(event.entity.status === MediaStatus.AVAILABLE ||
|
||||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(event.entity as Media, event.databaseEntity);
|
||||
this.notifyAvailableSeries(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
event.entity.mediaType === MediaType.TV &&
|
||||
(event.entity.status4k === MediaStatus.AVAILABLE ||
|
||||
event.entity.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
this.notifyAvailableSeries(
|
||||
event.entity as Media,
|
||||
event.databaseEntity,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -169,4 +227,8 @@ export class MediaSubscriber implements EntitySubscriberInterface {
|
||||
this.updateChildRequestStatus(event.entity as Media, true);
|
||||
}
|
||||
}
|
||||
|
||||
public listenTo(): typeof Media {
|
||||
return Media;
|
||||
}
|
||||
}
|
||||
|
||||
53
server/templates/email/media-issue/html.pug
Normal file
53
server/templates/email/media-issue/html.pug
Normal file
@@ -0,0 +1,53 @@
|
||||
doctype html
|
||||
head
|
||||
meta(charset='utf-8')
|
||||
meta(name='x-apple-disable-message-reformatting')
|
||||
meta(http-equiv='x-ua-compatible' content='ie=edge')
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
|
||||
style.
|
||||
.title:hover * {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@media only screen and (max-width:600px) {
|
||||
table {
|
||||
font-size: 20px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
||||
table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;')
|
||||
tr
|
||||
td(style="text-align: center;")
|
||||
if applicationUrl
|
||||
a(href=applicationUrl style='margin: 0 1rem;')
|
||||
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
|
||||
else
|
||||
div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;')
|
||||
| #{applicationTitle}
|
||||
if recipientName !== recipientEmail
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}!
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| #{body}
|
||||
if issueComment
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| #{issueComment}
|
||||
else if issueDescription
|
||||
tr
|
||||
td(style='text-align: center;')
|
||||
div(style='margin: 1rem 0 0; font-size: 1.25em;')
|
||||
| #{issueDescription}
|
||||
if actionUrl
|
||||
tr
|
||||
td
|
||||
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
|
||||
| View Issue in #{applicationTitle}
|
||||
1
server/templates/email/media-issue/subject.pug
Normal file
1
server/templates/email/media-issue/subject.pug
Normal file
@@ -0,0 +1 @@
|
||||
!= `${event} - ${mediaName} [${applicationTitle}]`
|
||||
@@ -66,4 +66,4 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
||||
td
|
||||
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
|
||||
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
|
||||
| Open in #{applicationTitle}
|
||||
| View Media in #{applicationTitle}
|
||||
|
||||
@@ -1 +1 @@
|
||||
!= `${requestType} - ${mediaName} [${applicationTitle}]`
|
||||
!= `${event} - ${mediaName} [${applicationTitle}]`
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { DownloadIcon, DuplicateIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import { DownloadIcon } from '@heroicons/react/outline';
|
||||
import { uniq } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import type { Collection } from '../../../server/models/Collection';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
@@ -17,23 +14,17 @@ import Error from '../../pages/_error';
|
||||
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import Modal from '../Common/Modal';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import RequestModal from '../RequestModal';
|
||||
import Slider from '../Slider';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
import TitleCard from '../TitleCard';
|
||||
import Transition from '../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
overview: 'Overview',
|
||||
numberofmovies: '{count} Movies',
|
||||
requestcollection: 'Request Collection',
|
||||
requestswillbecreated:
|
||||
'The following titles will have requests created for them:',
|
||||
requestcollection4k: 'Request Collection in 4K',
|
||||
requestswillbecreated4k:
|
||||
'The following titles will have 4K requests created for them:',
|
||||
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
||||
});
|
||||
|
||||
interface CollectionDetailsProps {
|
||||
@@ -46,10 +37,8 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const { addToast } = useToasts();
|
||||
const { hasPermission } = useUser();
|
||||
const [requestModal, setRequestModal] = useState(false);
|
||||
const [isRequesting, setRequesting] = useState(false);
|
||||
const [is4k, setIs4k] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<Collection>(
|
||||
@@ -124,48 +113,6 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
|
||||
).length > 0;
|
||||
|
||||
const requestableParts = data.parts.filter(
|
||||
(part) =>
|
||||
!part.mediaInfo ||
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] === MediaStatus.UNKNOWN
|
||||
);
|
||||
|
||||
const requestBundle = async () => {
|
||||
try {
|
||||
setRequesting(true);
|
||||
await Promise.all(
|
||||
requestableParts.map(async (part) => {
|
||||
await axios.post<MediaRequest>('/api/v1/request', {
|
||||
mediaId: part.id,
|
||||
mediaType: 'movie',
|
||||
is4k,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestSuccess, {
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'success', autoDismiss: true }
|
||||
);
|
||||
} catch (e) {
|
||||
addToast('Something went wrong requesting the collection.', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
setRequestModal(false);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const collectionAttributes: React.ReactNode[] = [];
|
||||
|
||||
collectionAttributes.push(
|
||||
@@ -229,53 +176,17 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<PageTitle title={data.name} />
|
||||
<Transition
|
||||
enter="opacity-0 transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
<RequestModal
|
||||
tmdbId={data.id}
|
||||
show={requestModal}
|
||||
>
|
||||
<Modal
|
||||
onOk={() => requestBundle()}
|
||||
okText={
|
||||
isRequesting
|
||||
? intl.formatMessage(globalMessages.requesting)
|
||||
: intl.formatMessage(
|
||||
is4k ? globalMessages.request4k : globalMessages.request
|
||||
)
|
||||
}
|
||||
okDisabled={isRequesting}
|
||||
okButtonType="primary"
|
||||
onCancel={() => setRequestModal(false)}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.requestcollection4k : messages.requestcollection
|
||||
)}
|
||||
iconSvg={<DuplicateIcon />}
|
||||
>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
is4k
|
||||
? messages.requestswillbecreated4k
|
||||
: messages.requestswillbecreated
|
||||
)}
|
||||
</p>
|
||||
<ul className="py-4 pl-8 list-disc">
|
||||
{data.parts
|
||||
.filter(
|
||||
(part) =>
|
||||
!part.mediaInfo ||
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN
|
||||
)
|
||||
.map((part) => (
|
||||
<li key={`request-part-${part.id}`}>{part.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
</Transition>
|
||||
type="collection"
|
||||
is4k={is4k}
|
||||
onComplete={() => {
|
||||
revalidate();
|
||||
setRequestModal(false);
|
||||
}}
|
||||
onCancel={() => setRequestModal(false)}
|
||||
/>
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
@@ -323,7 +234,9 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
.map((t, k) => <span key={k}>{t}</span>)
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev} | {curr}
|
||||
{prev}
|
||||
<span>|</span>
|
||||
{curr}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -13,36 +13,36 @@ const Badge: React.FC<BadgeProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const badgeStyle = [
|
||||
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
|
||||
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap',
|
||||
];
|
||||
|
||||
if (url) {
|
||||
badgeStyle.push('transition cursor-pointer');
|
||||
badgeStyle.push('transition cursor-pointer !no-underline');
|
||||
} else {
|
||||
badgeStyle.push('cursor-default');
|
||||
}
|
||||
|
||||
switch (badgeType) {
|
||||
case 'danger':
|
||||
badgeStyle.push('bg-red-600 text-red-100');
|
||||
badgeStyle.push('bg-red-600 !text-red-100');
|
||||
if (url) {
|
||||
badgeStyle.push('hover:bg-red-500');
|
||||
}
|
||||
break;
|
||||
case 'warning':
|
||||
badgeStyle.push('bg-yellow-500 text-yellow-100');
|
||||
badgeStyle.push('bg-yellow-500 !text-yellow-100');
|
||||
if (url) {
|
||||
badgeStyle.push('hover:bg-yellow-400');
|
||||
}
|
||||
break;
|
||||
case 'success':
|
||||
badgeStyle.push('bg-green-500 text-green-100');
|
||||
badgeStyle.push('bg-green-500 !text-green-100');
|
||||
if (url) {
|
||||
badgeStyle.push('hover:bg-green-400');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
badgeStyle.push('bg-indigo-500 text-indigo-100');
|
||||
badgeStyle.push('bg-indigo-500 !text-indigo-100');
|
||||
if (url) {
|
||||
badgeStyle.push('hover:bg-indigo-400');
|
||||
}
|
||||
@@ -54,8 +54,13 @@ const Badge: React.FC<BadgeProps> = ({
|
||||
|
||||
if (url) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<span className={badgeStyle.join(' ')}>{children}</span>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={badgeStyle.join(' ')}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -111,7 +111,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
}}
|
||||
>
|
||||
{backdrop && (
|
||||
<div className="absolute top-0 left-0 right-0 z-0 w-full h-64">
|
||||
<div className="absolute top-0 left-0 right-0 z-0 w-full h-64 max-h-full">
|
||||
<CachedImage
|
||||
alt=""
|
||||
src={backdrop}
|
||||
|
||||
@@ -7,7 +7,7 @@ import Transition from '../../Transition';
|
||||
|
||||
interface SlideOverProps {
|
||||
show?: boolean;
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
subText?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ const networks: Network[] = [
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
|
||||
url: '/discover/tv/network/49',
|
||||
},
|
||||
{
|
||||
name: 'Discovery+',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1D1bS3Dyw4ScYnFWTlBOvJXC3nb.png',
|
||||
url: '/discover/tv/network/4353',
|
||||
},
|
||||
{
|
||||
name: 'ABC',
|
||||
image:
|
||||
|
||||
69
src/components/IssueBlock/index.tsx
Normal file
69
src/components/IssueBlock/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
CalendarIcon,
|
||||
ExclamationIcon,
|
||||
EyeIcon,
|
||||
UserIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import type Issue from '../../../server/entity/Issue';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Button from '../Common/Button';
|
||||
import { issueOptions } from '../IssueModal/constants';
|
||||
|
||||
interface IssueBlockProps {
|
||||
issue: Issue;
|
||||
}
|
||||
|
||||
const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
|
||||
const intl = useIntl();
|
||||
const issueOption = issueOptions.find(
|
||||
(opt) => opt.issueType === issue.issueType
|
||||
);
|
||||
|
||||
if (!issueOption) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 text-gray-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5">
|
||||
<div className="flex flex-nowrap">
|
||||
<ExclamationIcon className="flex-shrink-0 mr-1.5 h-5 w-5" />
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
{intl.formatMessage(issueOption.name)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex mb-1 flex-nowrap white">
|
||||
<UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
{issue.createdBy.displayName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex mb-1 flex-nowrap white">
|
||||
<CalendarIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
{intl.formatDate(issue.createdAt, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap flex-shrink-0 ml-2">
|
||||
<Link href={`/issues/${issue.id}`} passHref>
|
||||
<Button buttonType="primary" buttonSize="sm" as="a">
|
||||
<EyeIcon />
|
||||
<span>{intl.formatMessage(globalMessages.view)}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueBlock;
|
||||
269
src/components/IssueDetails/IssueComment/index.tsx
Normal file
269
src/components/IssueDetails/IssueComment/index.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Menu } from '@headlessui/react';
|
||||
import { ExclamationIcon } from '@heroicons/react/outline';
|
||||
import { DotsVerticalIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import * as Yup from 'yup';
|
||||
import type { default as IssueCommentType } from '../../../../server/entity/IssueComment';
|
||||
import { Permission, useUser } from '../../../hooks/useUser';
|
||||
import Button from '../../Common/Button';
|
||||
import Modal from '../../Common/Modal';
|
||||
import Transition from '../../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
postedby: 'Posted {relativeTime} by {username}',
|
||||
postedbyedited: 'Posted {relativeTime} by {username} (Edited)',
|
||||
delete: 'Delete Comment',
|
||||
areyousuredelete: 'Are you sure you want to delete this comment?',
|
||||
validationComment: 'You must enter a message',
|
||||
edit: 'Edit Comment',
|
||||
});
|
||||
|
||||
interface IssueCommentProps {
|
||||
comment: IssueCommentType;
|
||||
isReversed?: boolean;
|
||||
isActiveUser?: boolean;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
const IssueComment: React.FC<IssueCommentProps> = ({
|
||||
comment,
|
||||
isReversed = false,
|
||||
isActiveUser = false,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { hasPermission } = useUser();
|
||||
|
||||
const EditCommentSchema = Yup.object().shape({
|
||||
newMessage: Yup.string().required(
|
||||
intl.formatMessage(messages.validationComment)
|
||||
),
|
||||
});
|
||||
|
||||
const deleteComment = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/v1/issueComment/${comment.id}`);
|
||||
} catch (e) {
|
||||
// something went wrong deleting the comment
|
||||
} finally {
|
||||
if (onUpdate) {
|
||||
onUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${
|
||||
isReversed ? 'flex-row' : 'flex-row-reverse space-x-reverse'
|
||||
} mt-4 space-x-4`}
|
||||
>
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={showDeleteModal}
|
||||
>
|
||||
<Modal
|
||||
title={intl.formatMessage(messages.delete)}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onOk={() => deleteComment()}
|
||||
okText={intl.formatMessage(messages.delete)}
|
||||
okButtonType="danger"
|
||||
iconSvg={<ExclamationIcon />}
|
||||
>
|
||||
{intl.formatMessage(messages.areyousuredelete)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
|
||||
<a>
|
||||
<img
|
||||
src={comment.user.avatar}
|
||||
alt=""
|
||||
className="w-10 h-10 transition duration-300 scale-100 rounded-full ring-1 ring-gray-500 transform-gpu hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="relative flex-1">
|
||||
<div className="w-full rounded-md shadow ring-1 ring-gray-500">
|
||||
{(isActiveUser || hasPermission(Permission.MANAGE_ISSUES)) && (
|
||||
<Menu
|
||||
as="div"
|
||||
className="absolute z-40 inline-block text-left top-2 right-1"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="flex items-center text-gray-400 rounded-full hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500">
|
||||
<span className="sr-only">Open options</span>
|
||||
<DotsVerticalIcon
|
||||
className="w-5 h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static
|
||||
className="absolute right-0 w-56 mt-2 origin-top-right bg-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div className="py-1">
|
||||
{isActiveUser && (
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={`block w-full text-left px-4 py-2 text-sm ${
|
||||
active
|
||||
? 'bg-gray-600 text-white'
|
||||
: 'text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{intl.formatMessage(messages.edit)}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className={`block w-full text-left px-4 py-2 text-sm ${
|
||||
active
|
||||
? 'bg-gray-600 text-white'
|
||||
: 'text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{intl.formatMessage(messages.delete)}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
<div
|
||||
className={`absolute w-3 h-3 transform rotate-45 bg-gray-800 shadow top-3 z-10 ring-1 ring-gray-500 ${
|
||||
isReversed ? '-left-1' : '-right-1'
|
||||
}`}
|
||||
/>
|
||||
<div className="relative z-20 w-full py-4 pl-4 pr-8 bg-gray-800 rounded-md">
|
||||
{isEditing ? (
|
||||
<Formik
|
||||
initialValues={{ newMessage: comment.message }}
|
||||
onSubmit={async (values) => {
|
||||
await axios.put(`/api/v1/issueComment/${comment.id}`, {
|
||||
message: values.newMessage,
|
||||
});
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
}}
|
||||
validationSchema={EditCommentSchema}
|
||||
>
|
||||
{({ isValid, isSubmitting, errors, touched }) => {
|
||||
return (
|
||||
<Form>
|
||||
<Field
|
||||
as="textarea"
|
||||
id="newMessage"
|
||||
name="newMessage"
|
||||
className="h-24"
|
||||
/>
|
||||
{errors.newMessage && touched.newMessage && (
|
||||
<div className="error">{errors.newMessage}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end mt-4 space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
) : (
|
||||
<div className="w-full max-w-full prose">
|
||||
<ReactMarkdown skipHtml allowedElements={['p', 'em', 'strong']}>
|
||||
{comment.message}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex justify-between items-center text-xs pt-2 ${
|
||||
isReversed ? 'flex-row-reverse' : 'flex-row'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
comment.createdAt !== comment.updatedAt
|
||||
? messages.postedbyedited
|
||||
: messages.postedby,
|
||||
{
|
||||
username: (
|
||||
<Link
|
||||
href={
|
||||
isActiveUser ? '/profile' : `/users/${comment.user.id}`
|
||||
}
|
||||
>
|
||||
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
{comment.user.displayName}
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
relativeTime: (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(comment.createdAt).getTime() - Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueComment;
|
||||
157
src/components/IssueDetails/IssueDescription/index.tsx
Normal file
157
src/components/IssueDetails/IssueDescription/index.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { DotsVerticalIcon } from '@heroicons/react/solid';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Permission, useUser } from '../../../hooks/useUser';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Button from '../../Common/Button';
|
||||
|
||||
const messages = defineMessages({
|
||||
description: 'Description',
|
||||
edit: 'Edit Description',
|
||||
deleteissue: 'Delete Issue',
|
||||
});
|
||||
|
||||
interface IssueDescriptionProps {
|
||||
description: string;
|
||||
belongsToUser: boolean;
|
||||
commentCount: number;
|
||||
onEdit: (newDescription: string) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const IssueDescription: React.FC<IssueDescriptionProps> = ({
|
||||
description,
|
||||
belongsToUser,
|
||||
commentCount,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-gray-100 lg:text-xl">
|
||||
{intl.formatMessage(messages.description)}
|
||||
</div>
|
||||
{(hasPermission(Permission.MANAGE_ISSUES) || belongsToUser) && (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="flex items-center text-gray-400 rounded-full hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500">
|
||||
<span className="sr-only">Open options</span>
|
||||
<DotsVerticalIcon className="w-5 h-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static
|
||||
className="absolute right-0 w-56 mt-2 origin-top-right bg-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div className="py-1">
|
||||
{belongsToUser && (
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={`block w-full text-left px-4 py-2 text-sm ${
|
||||
active
|
||||
? 'bg-gray-600 text-white'
|
||||
: 'text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{intl.formatMessage(messages.edit)}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{(hasPermission(Permission.MANAGE_ISSUES) ||
|
||||
!commentCount) && (
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => onDelete()}
|
||||
className={`block w-full text-left px-4 py-2 text-sm ${
|
||||
active
|
||||
? 'bg-gray-600 text-white'
|
||||
: 'text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{intl.formatMessage(messages.deleteissue)}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<Formik
|
||||
initialValues={{ newMessage: description }}
|
||||
onSubmit={(values) => {
|
||||
onEdit(values.newMessage);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
{() => {
|
||||
return (
|
||||
<Form className="mt-4">
|
||||
<Field
|
||||
id="newMessage"
|
||||
name="newMessage"
|
||||
as="textarea"
|
||||
className="h-40"
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="mr-2"
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<span>{intl.formatMessage(globalMessages.cancel)}</span>
|
||||
</Button>
|
||||
<Button buttonType="primary">
|
||||
<span>{intl.formatMessage(globalMessages.save)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
) : (
|
||||
<div className="mt-4 prose">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'img', 'strong', 'em']}
|
||||
skipHtml
|
||||
>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueDescription;
|
||||
664
src/components/IssueDetails/index.tsx
Normal file
664
src/components/IssueDetails/index.tsx
Normal file
@@ -0,0 +1,664 @@
|
||||
import {
|
||||
ChatIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationIcon,
|
||||
PlayIcon,
|
||||
ServerIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import { RefreshIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import { IssueStatus } from '../../../server/constants/issue';
|
||||
import { MediaType } from '../../../server/constants/media';
|
||||
import type Issue from '../../../server/entity/Issue';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import type { TvDetails } from '../../../server/models/Tv';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Error from '../../pages/_error';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import Modal from '../Common/Modal';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import { issueOptions } from '../IssueModal/constants';
|
||||
import Transition from '../Transition';
|
||||
import IssueComment from './IssueComment';
|
||||
import IssueDescription from './IssueDescription';
|
||||
|
||||
const messages = defineMessages({
|
||||
openedby: '#{issueId} opened {relativeTime} by {username}',
|
||||
closeissue: 'Close Issue',
|
||||
closeissueandcomment: 'Close with Comment',
|
||||
leavecomment: 'Comment',
|
||||
comments: 'Comments',
|
||||
reopenissue: 'Reopen Issue',
|
||||
reopenissueandcomment: 'Reopen with Comment',
|
||||
issuepagetitle: 'Issue',
|
||||
playonplex: 'Play on Plex',
|
||||
play4konplex: 'Play in 4K on Plex',
|
||||
openinarr: 'Open in {arr}',
|
||||
openin4karr: 'Open in 4K {arr}',
|
||||
toasteditdescriptionsuccess: 'Issue description edited successfully!',
|
||||
toasteditdescriptionfailed:
|
||||
'Something went wrong while editing the issue description.',
|
||||
toaststatusupdated: 'Issue status updated successfully!',
|
||||
toaststatusupdatefailed:
|
||||
'Something went wrong while updating the issue status.',
|
||||
issuetype: 'Type',
|
||||
lastupdated: 'Last Updated',
|
||||
problemseason: 'Affected Season',
|
||||
allseasons: 'All Seasons',
|
||||
season: 'Season {seasonNumber}',
|
||||
problemepisode: 'Affected Episode',
|
||||
allepisodes: 'All Episodes',
|
||||
episode: 'Episode {episodeNumber}',
|
||||
deleteissue: 'Delete Issue',
|
||||
deleteissueconfirm: 'Are you sure you want to delete this issue?',
|
||||
toastissuedeleted: 'Issue deleted successfully!',
|
||||
toastissuedeletefailed: 'Something went wrong while deleting the issue.',
|
||||
nocomments: 'No comments.',
|
||||
unknownissuetype: 'Unknown',
|
||||
commentplaceholder: 'Add a comment…',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
};
|
||||
|
||||
const IssueDetails: React.FC = () => {
|
||||
const { addToast } = useToasts();
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const { user: currentUser, hasPermission } = useUser();
|
||||
const { data: issueData, revalidate: revalidateIssue } = useSWR<Issue>(
|
||||
`/api/v1/issue/${router.query.issueId}`
|
||||
);
|
||||
const { data, error } = useSWR<MovieDetails | TvDetails>(
|
||||
issueData?.media.tmdbId
|
||||
? `/api/v1/${issueData.media.mediaType}/${issueData.media.tmdbId}`
|
||||
: null
|
||||
);
|
||||
|
||||
const CommentSchema = Yup.object().shape({
|
||||
message: Yup.string().required(),
|
||||
});
|
||||
|
||||
const issueOption = issueOptions.find(
|
||||
(opt) => opt.issueType === issueData?.issueType
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data || !issueData) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const belongsToUser = issueData.createdBy.id === currentUser?.id;
|
||||
|
||||
const [firstComment, ...otherComments] = issueData.comments;
|
||||
|
||||
const editFirstComment = async (newMessage: string) => {
|
||||
try {
|
||||
await axios.put(`/api/v1/issueComment/${firstComment.id}`, {
|
||||
message: newMessage,
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
revalidateIssue();
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.toasteditdescriptionfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
|
||||
try {
|
||||
await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`);
|
||||
|
||||
addToast(intl.formatMessage(messages.toaststatusupdated), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
revalidateIssue();
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.toaststatusupdatefailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteIssue = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/v1/issue/${issueData.id}`);
|
||||
|
||||
addToast(intl.formatMessage(messages.toastissuedeleted), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
router.push('/issues');
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.toastissuedeletefailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const title = isMovie(data) ? data.title : data.name;
|
||||
const releaseYear = isMovie(data) ? data.releaseDate : data.firstAirDate;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="media-page"
|
||||
style={{
|
||||
height: 493,
|
||||
}}
|
||||
>
|
||||
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={showDeleteModal}
|
||||
>
|
||||
<Modal
|
||||
title={intl.formatMessage(messages.deleteissue)}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onOk={() => deleteIssue()}
|
||||
okText={intl.formatMessage(messages.deleteissue)}
|
||||
okButtonType="danger"
|
||||
iconSvg={<ExclamationIcon />}
|
||||
>
|
||||
{intl.formatMessage(messages.deleteissueconfirm)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
{data.backdropPath && (
|
||||
<div className="media-page-bg-image">
|
||||
<CachedImage
|
||||
alt=""
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
priority
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
layout="responsive"
|
||||
width={600}
|
||||
height={900}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="media-title">
|
||||
<div className="media-status">
|
||||
{issueData.status === IssueStatus.OPEN && (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(globalMessages.open)}
|
||||
</Badge>
|
||||
)}
|
||||
{issueData.status === IssueStatus.RESOLVED && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.resolved)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1>
|
||||
<Link
|
||||
href={`/${
|
||||
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
|
||||
}/${data.id}`}
|
||||
>
|
||||
<a className="hover:underline">{title}</a>
|
||||
</Link>{' '}
|
||||
{releaseYear && (
|
||||
<span className="media-year">({releaseYear.slice(0, 4)})</span>
|
||||
)}
|
||||
</h1>
|
||||
<span className="media-attributes">
|
||||
{intl.formatMessage(messages.openedby, {
|
||||
issueId: issueData.id,
|
||||
username: (
|
||||
<Link
|
||||
href={
|
||||
belongsToUser
|
||||
? '/profile'
|
||||
: `/users/${issueData.createdBy.id}`
|
||||
}
|
||||
>
|
||||
<a className="inline-flex items-center h-full ml-1 xl:ml-1.5 group">
|
||||
<img
|
||||
className="w-5 h-5 mr-0.5 transition duration-300 scale-100 rounded-full xl:w-6 xl:h-6 xl:mr-1 transform-gpu group-hover:scale-105"
|
||||
src={issueData.createdBy.avatar}
|
||||
alt=""
|
||||
/>
|
||||
<span className="font-semibold text-gray-100 transition duration-300 group-hover:text-white group-hover:underline">
|
||||
{issueData.createdBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
relativeTime: (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issueData.createdAt).getTime() - Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 flex mt-6 text-gray-300">
|
||||
<div className="flex-1 lg:pr-4">
|
||||
<IssueDescription
|
||||
description={firstComment.message}
|
||||
belongsToUser={belongsToUser}
|
||||
commentCount={otherComments.length}
|
||||
onEdit={(newMessage) => {
|
||||
editFirstComment(newMessage);
|
||||
}}
|
||||
onDelete={() => setShowDeleteModal(true)}
|
||||
/>
|
||||
<div className="mt-8 lg:hidden">
|
||||
<div className="media-facts">
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.issuetype)}</span>
|
||||
<span className="media-fact-value">
|
||||
{intl.formatMessage(
|
||||
issueOption?.name ?? messages.unknownissuetype
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{issueData.media.mediaType === MediaType.TV && (
|
||||
<>
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.problemseason)}</span>
|
||||
<span className="media-fact-value">
|
||||
{intl.formatMessage(
|
||||
issueData.problemSeason > 0
|
||||
? messages.season
|
||||
: messages.allseasons,
|
||||
{ seasonNumber: issueData.problemSeason }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{issueData.problemSeason > 0 && (
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.problemepisode)}</span>
|
||||
<span className="media-fact-value">
|
||||
{intl.formatMessage(
|
||||
issueData.problemEpisode > 0
|
||||
? messages.episode
|
||||
: messages.allepisodes,
|
||||
{ episodeNumber: issueData.problemEpisode }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.lastupdated)}</span>
|
||||
<span className="media-fact-value">
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issueData.updatedAt).getTime() - Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col mt-4 mb-6 space-y-2">
|
||||
{issueData?.media.plexUrl && (
|
||||
<Button
|
||||
as="a"
|
||||
href={issueData?.media.plexUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
buttonType="ghost"
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>{intl.formatMessage(messages.playonplex)}</span>
|
||||
</Button>
|
||||
)}
|
||||
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
|
||||
<Button
|
||||
as="a"
|
||||
href={issueData?.media.serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
buttonType="ghost"
|
||||
>
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openinarr, {
|
||||
arr:
|
||||
issueData.media.mediaType === MediaType.MOVIE
|
||||
? 'Radarr'
|
||||
: 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{issueData?.media.plexUrl4k && (
|
||||
<Button
|
||||
as="a"
|
||||
href={issueData?.media.plexUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
buttonType="ghost"
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>{intl.formatMessage(messages.play4konplex)}</span>
|
||||
</Button>
|
||||
)}
|
||||
{issueData?.media.serviceUrl4k &&
|
||||
hasPermission(Permission.ADMIN) && (
|
||||
<Button
|
||||
as="a"
|
||||
href={issueData?.media.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
buttonType="ghost"
|
||||
>
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openin4karr, {
|
||||
arr:
|
||||
issueData.media.mediaType === MediaType.MOVIE
|
||||
? 'Radarr'
|
||||
: 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="font-semibold text-gray-100 lg:text-xl">
|
||||
{intl.formatMessage(messages.comments)}
|
||||
</div>
|
||||
{otherComments.map((comment) => (
|
||||
<IssueComment
|
||||
comment={comment}
|
||||
key={`issue-comment-${comment.id}`}
|
||||
isReversed={issueData.createdBy.id === comment.user.id}
|
||||
isActiveUser={comment.user.id === currentUser?.id}
|
||||
onUpdate={() => revalidateIssue()}
|
||||
/>
|
||||
))}
|
||||
{otherComments.length === 0 && (
|
||||
<div className="mt-4 mb-10 text-gray-400">
|
||||
<span>{intl.formatMessage(messages.nocomments)}</span>
|
||||
</div>
|
||||
)}
|
||||
{(hasPermission(Permission.MANAGE_ISSUES) || belongsToUser) && (
|
||||
<Formik
|
||||
initialValues={{
|
||||
message: '',
|
||||
}}
|
||||
validationSchema={CommentSchema}
|
||||
onSubmit={async (values, { resetForm }) => {
|
||||
await axios.post(`/api/v1/issue/${issueData?.id}/comment`, {
|
||||
message: values.message,
|
||||
});
|
||||
revalidateIssue();
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
{({ isValid, isSubmitting, values, handleSubmit }) => {
|
||||
return (
|
||||
<Form>
|
||||
<div className="my-6">
|
||||
<Field
|
||||
id="message"
|
||||
name="message"
|
||||
as="textarea"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.commentplaceholder
|
||||
)}
|
||||
className="h-20"
|
||||
/>
|
||||
<div className="flex items-center justify-end mt-4 space-x-2">
|
||||
{hasPermission(Permission.MANAGE_ISSUES) && (
|
||||
<>
|
||||
{issueData.status === IssueStatus.OPEN ? (
|
||||
<Button
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
onClick={async () => {
|
||||
await updateIssueStatus('resolved');
|
||||
|
||||
if (values.message) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
values.message
|
||||
? messages.closeissueandcomment
|
||||
: messages.closeissue
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
buttonType="default"
|
||||
onClick={async () => {
|
||||
await updateIssueStatus('open');
|
||||
|
||||
if (values.message) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
values.message
|
||||
? messages.reopenissueandcomment
|
||||
: messages.reopenissue
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
buttonType="primary"
|
||||
disabled={
|
||||
!isValid || isSubmitting || !values.message
|
||||
}
|
||||
>
|
||||
<ChatIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.leavecomment)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:block lg:pl-4 lg:w-80">
|
||||
<div className="media-facts">
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.issuetype)}</span>
|
||||
<span className="media-fact-value">
|
||||
{intl.formatMessage(
|
||||
issueOption?.name ?? messages.unknownissuetype
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{issueData.media.mediaType === MediaType.TV && (
|
||||
<>
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.problemseason)}</span>
|
||||
<span className="media-fact-value">
|
||||
{intl.formatMessage(
|
||||
issueData.problemSeason > 0
|
||||
? messages.season
|
||||
: messages.allseasons,
|
||||
{ seasonNumber: issueData.problemSeason }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{issueData.problemSeason > 0 && (
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.problemepisode)}</span>
|
||||
<span className="media-fact-value">
|
||||
{intl.formatMessage(
|
||||
issueData.problemEpisode > 0
|
||||
? messages.episode
|
||||
: messages.allepisodes,
|
||||
{ episodeNumber: issueData.problemEpisode }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.lastupdated)}</span>
|
||||
<span className="media-fact-value">
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issueData.updatedAt).getTime() - Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col mt-4 mb-6 space-y-2">
|
||||
{issueData?.media.plexUrl && (
|
||||
<Button
|
||||
as="a"
|
||||
href={issueData?.media.plexUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
buttonType="ghost"
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>{intl.formatMessage(messages.playonplex)}</span>
|
||||
</Button>
|
||||
)}
|
||||
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
|
||||
<Button
|
||||
as="a"
|
||||
href={issueData?.media.serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
buttonType="ghost"
|
||||
>
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openinarr, {
|
||||
arr:
|
||||
issueData.media.mediaType === MediaType.MOVIE
|
||||
? 'Radarr'
|
||||
: 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{issueData?.media.plexUrl4k && (
|
||||
<Button
|
||||
as="a"
|
||||
href={issueData?.media.plexUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
buttonType="ghost"
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>{intl.formatMessage(messages.play4konplex)}</span>
|
||||
</Button>
|
||||
)}
|
||||
{issueData?.media.serviceUrl4k && hasPermission(Permission.ADMIN) && (
|
||||
<Button
|
||||
as="a"
|
||||
href={issueData?.media.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
buttonType="ghost"
|
||||
>
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openin4karr, {
|
||||
arr:
|
||||
issueData.media.mediaType === MediaType.MOVIE
|
||||
? 'Radarr'
|
||||
: 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueDetails;
|
||||
275
src/components/IssueList/IssueItem/index.tsx
Normal file
275
src/components/IssueList/IssueItem/index.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { EyeIcon } from '@heroicons/react/solid';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { IssueStatus } from '../../../../server/constants/issue';
|
||||
import { MediaType } from '../../../../server/constants/media';
|
||||
import Issue from '../../../../server/entity/Issue';
|
||||
import { MovieDetails } from '../../../../server/models/Movie';
|
||||
import { TvDetails } from '../../../../server/models/Tv';
|
||||
import { Permission, useUser } from '../../../hooks/useUser';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Badge from '../../Common/Badge';
|
||||
import Button from '../../Common/Button';
|
||||
import CachedImage from '../../Common/CachedImage';
|
||||
import { issueOptions } from '../../IssueModal/constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
openeduserdate: '{date} by {user}',
|
||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||
episodes: '{episodeCount, plural, one {Episode} other {Episodes}}',
|
||||
problemepisode: 'Affected Episode',
|
||||
issuetype: 'Type',
|
||||
issuestatus: 'Status',
|
||||
opened: 'Opened',
|
||||
viewissue: 'View Issue',
|
||||
unknownissuetype: 'Unknown',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
};
|
||||
|
||||
interface IssueItemProps {
|
||||
issue: Issue;
|
||||
}
|
||||
|
||||
const IssueItem: React.FC<IssueItemProps> = ({ issue }) => {
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
});
|
||||
const url =
|
||||
issue.media.mediaType === 'movie'
|
||||
? `/api/v1/movie/${issue.media.tmdbId}`
|
||||
: `/api/v1/tv/${issue.media.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? url : null
|
||||
);
|
||||
|
||||
if (!title && !error) {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-64 bg-gray-800 rounded-xl xl:h-28 animate-pulse"
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return <div>uh oh</div>;
|
||||
}
|
||||
|
||||
const issueOption = issueOptions.find(
|
||||
(opt) => opt.issueType === issue?.issueType
|
||||
);
|
||||
|
||||
const problemSeasonEpisodeLine: React.ReactNode[] = [];
|
||||
|
||||
if (!isMovie(title) && issue) {
|
||||
problemSeasonEpisodeLine.push(
|
||||
<>
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.seasons, {
|
||||
seasonCount: issue.problemSeason ? 1 : 0,
|
||||
})}
|
||||
</span>
|
||||
<span className="mr-4 uppercase">
|
||||
<Badge>
|
||||
{issue.problemSeason > 0
|
||||
? issue.problemSeason
|
||||
: intl.formatMessage(globalMessages.all)}
|
||||
</Badge>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (issue.problemSeason > 0) {
|
||||
problemSeasonEpisodeLine.push(
|
||||
<>
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.episodes, {
|
||||
episodeCount: issue.problemEpisode ? 1 : 0,
|
||||
})}
|
||||
</span>
|
||||
<span className="uppercase">
|
||||
<Badge>
|
||||
{issue.problemEpisode > 0
|
||||
? issue.problemEpisode
|
||||
: intl.formatMessage(globalMessages.all)}
|
||||
</Badge>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl xl:h-28 xl:flex-row">
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-center bg-cover xl:w-2/3">
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
alt=""
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex flex-col justify-between w-full overflow-hidden sm:flex-row">
|
||||
<div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden xl:w-7/12 2xl:w-2/3 sm:pr-0">
|
||||
<Link
|
||||
href={
|
||||
issue.media.mediaType === MediaType.MOVIE
|
||||
? `/movie/${issue.media.tmdbId}`
|
||||
: `/tv/${issue.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="relative flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md transform-gpu hover:scale-105">
|
||||
<CachedImage
|
||||
src={
|
||||
title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
layout="responsive"
|
||||
width={600}
|
||||
height={900}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex flex-col justify-center pl-2 overflow-hidden xl:pl-4">
|
||||
<div className="pt-0.5 sm:pt-1 text-xs text-white">
|
||||
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
|
||||
0,
|
||||
4
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
issue.media.mediaType === MediaType.MOVIE
|
||||
? `/movie/${issue.media.tmdbId}`
|
||||
: `/tv/${issue.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="min-w-0 mr-2 text-lg font-bold text-white truncate xl:text-xl hover:underline">
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</a>
|
||||
</Link>
|
||||
{problemSeasonEpisodeLine.length > 0 && (
|
||||
<div className="card-field">
|
||||
{problemSeasonEpisodeLine.map((t, k) => (
|
||||
<span key={k}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 flex flex-col justify-center w-full pr-4 mt-4 ml-4 overflow-hidden text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.issuestatus)}
|
||||
</span>
|
||||
{issue.status === IssueStatus.OPEN ? (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(globalMessages.open)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.resolved)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.issuetype)}
|
||||
</span>
|
||||
<span className="flex text-sm text-gray-300 truncate">
|
||||
{intl.formatMessage(
|
||||
issueOption?.name ?? messages.unknownissuetype
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-field">
|
||||
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
|
||||
type: 'or',
|
||||
}) ? (
|
||||
<>
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.opened)}
|
||||
</span>
|
||||
<span className="flex text-sm text-gray-300 truncate">
|
||||
{intl.formatMessage(messages.openeduserdate, {
|
||||
date: (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issue.createdAt).getTime() - Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
),
|
||||
user: (
|
||||
<Link href={`/users/${issue.createdBy.id}`}>
|
||||
<a className="flex items-center truncate group">
|
||||
<img
|
||||
src={issue.createdBy.avatar}
|
||||
alt=""
|
||||
className="ml-1.5 avatar-sm"
|
||||
/>
|
||||
<span className="text-sm font-semibold truncate group-hover:underline group-hover:text-white">
|
||||
{issue.createdBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.opened)}
|
||||
</span>
|
||||
<span className="flex text-sm text-gray-300 truncate">
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(issue.createdAt).getTime() - Date.now()) / 1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 flex flex-col justify-center w-full pl-4 pr-4 mt-4 xl:mt-0 xl:items-end xl:w-96 xl:pl-0">
|
||||
<span className="w-full">
|
||||
<Link href={`/issues/${issue.id}`} passHref>
|
||||
<Button as="a" className="w-full" buttonType="primary">
|
||||
<EyeIcon />
|
||||
<span>{intl.formatMessage(messages.viewissue)}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueItem;
|
||||
256
src/components/IssueList/index.tsx
Normal file
256
src/components/IssueList/index.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FilterIcon,
|
||||
SortDescendingIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { IssueResultsResponse } from '../../../server/interfaces/api/issueInterfaces';
|
||||
import Button from '../../components/Common/Button';
|
||||
import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Header from '../Common/Header';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import IssueItem from './IssueItem';
|
||||
|
||||
const messages = defineMessages({
|
||||
issues: 'Issues',
|
||||
sortAdded: 'Most Recent',
|
||||
sortModified: 'Last Modified',
|
||||
showallissues: 'Show All Issues',
|
||||
});
|
||||
|
||||
enum Filter {
|
||||
ALL = 'all',
|
||||
OPEN = 'open',
|
||||
RESOLVED = 'resolved',
|
||||
}
|
||||
|
||||
type Sort = 'added' | 'modified';
|
||||
|
||||
const IssueList: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.OPEN);
|
||||
const [currentSort, setCurrentSort] = useState<Sort>('added');
|
||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||
|
||||
const page = router.query.page ? Number(router.query.page) : 1;
|
||||
const pageIndex = page - 1;
|
||||
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
|
||||
|
||||
const { data, error } = useSWR<IssueResultsResponse>(
|
||||
`/api/v1/issue?take=${currentPageSize}&skip=${
|
||||
pageIndex * currentPageSize
|
||||
}&filter=${currentFilter}&sort=${currentSort}`
|
||||
);
|
||||
|
||||
// Restore last set filter values on component mount
|
||||
useEffect(() => {
|
||||
const filterString = window.localStorage.getItem('il-filter-settings');
|
||||
|
||||
if (filterString) {
|
||||
const filterSettings = JSON.parse(filterString);
|
||||
|
||||
setCurrentFilter(filterSettings.currentFilter);
|
||||
setCurrentSort(filterSettings.currentSort);
|
||||
setCurrentPageSize(filterSettings.currentPageSize);
|
||||
}
|
||||
|
||||
// If filter value is provided in query, use that instead
|
||||
if (Object.values(Filter).includes(router.query.filter as Filter)) {
|
||||
setCurrentFilter(router.query.filter as Filter);
|
||||
}
|
||||
}, [router.query.filter]);
|
||||
|
||||
// Set filter values to local storage any time they are changed
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
'il-filter-settings',
|
||||
JSON.stringify({
|
||||
currentFilter,
|
||||
currentSort,
|
||||
currentPageSize,
|
||||
})
|
||||
);
|
||||
}, [currentFilter, currentSort, currentPageSize]);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const hasNextPage = data.pageInfo.pages > pageIndex + 1;
|
||||
const hasPrevPage = pageIndex > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.issues)} />
|
||||
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
|
||||
<Header>{intl.formatMessage(messages.issues)}</Header>
|
||||
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
|
||||
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex items-center px-3 text-sm text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md">
|
||||
<FilterIcon className="w-6 h-6" />
|
||||
</span>
|
||||
<select
|
||||
id="filter"
|
||||
name="filter"
|
||||
onChange={(e) => {
|
||||
setCurrentFilter(e.target.value as Filter);
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
});
|
||||
}}
|
||||
value={currentFilter}
|
||||
className="rounded-r-only"
|
||||
>
|
||||
<option value="all">
|
||||
{intl.formatMessage(globalMessages.all)}
|
||||
</option>
|
||||
<option value="open">
|
||||
{intl.formatMessage(globalMessages.open)}
|
||||
</option>
|
||||
<option value="resolved">
|
||||
{intl.formatMessage(globalMessages.resolved)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-grow mb-2 sm:mb-0 lg:flex-grow-0">
|
||||
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default sm:text-sm rounded-l-md">
|
||||
<SortDescendingIcon className="w-6 h-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sort"
|
||||
name="sort"
|
||||
onChange={(e) => {
|
||||
setCurrentSort(e.target.value as Sort);
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
});
|
||||
}}
|
||||
value={currentSort}
|
||||
className="rounded-r-only"
|
||||
>
|
||||
<option value="added">
|
||||
{intl.formatMessage(messages.sortAdded)}
|
||||
</option>
|
||||
<option value="modified">
|
||||
{intl.formatMessage(messages.sortModified)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.results.map((issue) => {
|
||||
return (
|
||||
<div className="py-2" key={`issue-item-${issue.id}`}>
|
||||
<IssueItem issue={issue} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center w-full py-24 text-white">
|
||||
<span className="text-2xl text-gray-400">
|
||||
{intl.formatMessage(globalMessages.noresults)}
|
||||
</span>
|
||||
{currentFilter !== Filter.ALL && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => setCurrentFilter(Filter.ALL)}
|
||||
>
|
||||
{intl.formatMessage(messages.showallissues)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<nav
|
||||
className="flex flex-col items-center mb-3 space-y-3 sm:space-y-0 sm:flex-row"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<div className="hidden lg:flex lg:flex-1">
|
||||
<p className="text-sm">
|
||||
{data.results.length > 0 &&
|
||||
intl.formatMessage(globalMessages.showingresults, {
|
||||
from: pageIndex * currentPageSize + 1,
|
||||
to:
|
||||
data.results.length < currentPageSize
|
||||
? pageIndex * currentPageSize + data.results.length
|
||||
: (pageIndex + 1) * currentPageSize,
|
||||
total: data.pageInfo.results,
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-medium">{msg}</span>;
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
|
||||
<span className="items-center -mt-3 text-sm truncate sm:mt-0">
|
||||
{intl.formatMessage(globalMessages.resultsperpage, {
|
||||
pageSize: (
|
||||
<select
|
||||
id="pageSize"
|
||||
name="pageSize"
|
||||
onChange={(e) => {
|
||||
setCurrentPageSize(Number(e.target.value));
|
||||
router
|
||||
.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
})
|
||||
.then(() => window.scrollTo(0, 0));
|
||||
}}
|
||||
value={currentPageSize}
|
||||
className="inline short"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
|
||||
<Button
|
||||
disabled={!hasPrevPage}
|
||||
onClick={() => updateQueryParams('page', (page - 1).toString())}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span>{intl.formatMessage(globalMessages.previous)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => updateQueryParams('page', (page + 1).toString())}
|
||||
>
|
||||
<span>{intl.formatMessage(globalMessages.next)}</span>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueList;
|
||||
329
src/components/IssueModal/CreateIssueModal/index.tsx
Normal file
329
src/components/IssueModal/CreateIssueModal/index.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { RadioGroup } from '@headlessui/react';
|
||||
import { ExclamationIcon } from '@heroicons/react/outline';
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import { Field, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import { MediaStatus } from '../../../../server/constants/media';
|
||||
import type Issue from '../../../../server/entity/Issue';
|
||||
import { MovieDetails } from '../../../../server/models/Movie';
|
||||
import { TvDetails } from '../../../../server/models/Tv';
|
||||
import useSettings from '../../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../../hooks/useUser';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Button from '../../Common/Button';
|
||||
import Modal from '../../Common/Modal';
|
||||
import { issueOptions } from '../constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
validationMessageRequired: 'You must provide a description',
|
||||
issomethingwrong: 'Is there a problem with {title}?',
|
||||
whatswrong: "What's wrong?",
|
||||
providedetail:
|
||||
'Please provide a detailed explanation of the issue you encountered.',
|
||||
extras: 'Extras',
|
||||
season: 'Season {seasonNumber}',
|
||||
episode: 'Episode {episodeNumber}',
|
||||
allseasons: 'All Seasons',
|
||||
allepisodes: 'All Episodes',
|
||||
problemseason: 'Affected Season',
|
||||
problemepisode: 'Affected Episode',
|
||||
toastSuccessCreate:
|
||||
'Issue report for <strong>{title}</strong> submitted successfully!',
|
||||
toastFailedCreate: 'Something went wrong while submitting the issue.',
|
||||
toastviewissue: 'View Issue',
|
||||
reportissue: 'Report an Issue',
|
||||
submitissue: 'Submit Issue',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
};
|
||||
|
||||
const classNames = (...classes: string[]) => {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
};
|
||||
|
||||
interface CreateIssueModalProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
tmdbId?: number;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const CreateIssueModal: React.FC<CreateIssueModalProps> = ({
|
||||
onCancel,
|
||||
mediaType,
|
||||
tmdbId,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { hasPermission } = useUser();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error } = useSWR<MovieDetails | TvDetails>(
|
||||
tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null
|
||||
);
|
||||
|
||||
if (!tmdbId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
|
||||
.filter(
|
||||
(season) =>
|
||||
season.status === MediaStatus.AVAILABLE ||
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
(settings.currentSettings.series4kEnabled &&
|
||||
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
(season.status4k === MediaStatus.AVAILABLE ||
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE))
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
const CreateIssueModalSchema = Yup.object().shape({
|
||||
message: Yup.string().required(
|
||||
intl.formatMessage(messages.validationMessageRequired)
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
selectedIssue: issueOptions[0],
|
||||
message: '',
|
||||
problemSeason: availableSeasons.length === 1 ? availableSeasons[0] : 0,
|
||||
problemEpisode: 0,
|
||||
}}
|
||||
validationSchema={CreateIssueModalSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const newIssue = await axios.post<Issue>('/api/v1/issue', {
|
||||
issueType: values.selectedIssue.issueType,
|
||||
message: values.message,
|
||||
mediaId: data?.mediaInfo?.id,
|
||||
problemSeason: values.problemSeason,
|
||||
problemEpisode:
|
||||
values.problemSeason > 0 ? values.problemEpisode : 0,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
addToast(
|
||||
<>
|
||||
<div>
|
||||
{intl.formatMessage(messages.toastSuccessCreate, {
|
||||
title: isMovie(data) ? data.title : data.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<Link href={`/issues/${newIssue.data.id}`}>
|
||||
<Button as="a" className="mt-4">
|
||||
<span>{intl.formatMessage(messages.toastviewissue)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
</Button>
|
||||
</Link>
|
||||
</>,
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.toastFailedCreate), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit, values, setFieldValue, errors, touched }) => {
|
||||
return (
|
||||
<Modal
|
||||
backgroundClickable
|
||||
onCancel={onCancel}
|
||||
iconSvg={<ExclamationIcon />}
|
||||
title={intl.formatMessage(messages.reportissue)}
|
||||
cancelText={intl.formatMessage(globalMessages.close)}
|
||||
onOk={() => handleSubmit()}
|
||||
okText={intl.formatMessage(messages.submitissue)}
|
||||
loading={!data && !error}
|
||||
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
||||
>
|
||||
{data && (
|
||||
<div className="flex items-center">
|
||||
<span className="mr-1 font-semibold">
|
||||
{intl.formatMessage(messages.issomethingwrong, {
|
||||
title: isMovie(data) ? data.title : data.name,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mediaType === 'tv' && data && !isMovie(data) && (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<label htmlFor="problemSeason" className="text-label">
|
||||
{intl.formatMessage(messages.problemseason)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="problemSeason"
|
||||
name="problemSeason"
|
||||
disabled={availableSeasons.length === 1}
|
||||
>
|
||||
{availableSeasons.length > 1 && (
|
||||
<option value={0}>
|
||||
{intl.formatMessage(messages.allseasons)}
|
||||
</option>
|
||||
)}
|
||||
{availableSeasons.map((season) => (
|
||||
<option
|
||||
value={season}
|
||||
key={`problem-season-${season}`}
|
||||
>
|
||||
{season === 0
|
||||
? intl.formatMessage(messages.extras)
|
||||
: intl.formatMessage(messages.season, {
|
||||
seasonNumber: season,
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{values.problemSeason > 0 && (
|
||||
<div className="mb-2 form-row">
|
||||
<label htmlFor="problemEpisode" className="text-label">
|
||||
{intl.formatMessage(messages.problemepisode)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="problemEpisode"
|
||||
name="problemEpisode"
|
||||
>
|
||||
<option value={0}>
|
||||
{intl.formatMessage(messages.allepisodes)}
|
||||
</option>
|
||||
{[
|
||||
...Array(
|
||||
data.seasons.find(
|
||||
(season) =>
|
||||
Number(values.problemSeason) ===
|
||||
season.seasonNumber
|
||||
)?.episodeCount ?? 0
|
||||
),
|
||||
].map((i, index) => (
|
||||
<option
|
||||
value={index + 1}
|
||||
key={`problem-episode-${index + 1}`}
|
||||
>
|
||||
{intl.formatMessage(messages.episode, {
|
||||
episodeNumber: index + 1,
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<RadioGroup
|
||||
value={values.selectedIssue}
|
||||
onChange={(issue) => setFieldValue('selectedIssue', issue)}
|
||||
className="mt-4"
|
||||
>
|
||||
<RadioGroup.Label className="sr-only">
|
||||
Select an Issue
|
||||
</RadioGroup.Label>
|
||||
<div className="-space-y-px overflow-hidden bg-gray-800 rounded-md bg-opacity-30">
|
||||
{issueOptions.map((setting, index) => (
|
||||
<RadioGroup.Option
|
||||
key={`issue-type-${setting.issueType}`}
|
||||
value={setting}
|
||||
className={({ checked }) =>
|
||||
classNames(
|
||||
index === 0 ? 'rounded-tl-md rounded-tr-md' : '',
|
||||
index === issueOptions.length - 1
|
||||
? 'rounded-bl-md rounded-br-md'
|
||||
: '',
|
||||
checked
|
||||
? 'bg-indigo-600 border-indigo-500 z-10'
|
||||
: 'border-gray-500',
|
||||
'relative border p-4 flex cursor-pointer focus:outline-none'
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ active, checked }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
checked
|
||||
? 'bg-indigo-800 border-transparent'
|
||||
: 'bg-white border-gray-300'
|
||||
} ${
|
||||
active ? 'ring-2 ring-offset-2 ring-indigo-300' : ''
|
||||
} h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="rounded-full bg-white w-1.5 h-1.5" />
|
||||
</span>
|
||||
<div className="flex flex-col ml-3">
|
||||
<RadioGroup.Label
|
||||
as="span"
|
||||
className={`block text-sm font-medium ${
|
||||
checked ? 'text-indigo-100' : 'text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{intl.formatMessage(setting.name)}
|
||||
</RadioGroup.Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<div className="flex-col mt-4 space-y-2">
|
||||
<label htmlFor="message">
|
||||
{intl.formatMessage(messages.whatswrong)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<Field
|
||||
as="textarea"
|
||||
name="message"
|
||||
id="message"
|
||||
className="h-28"
|
||||
placeholder={intl.formatMessage(messages.providedetail)}
|
||||
/>
|
||||
{errors.message && touched.message && (
|
||||
<div className="error">{errors.message}</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateIssueModal;
|
||||
34
src/components/IssueModal/constants.ts
Normal file
34
src/components/IssueModal/constants.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineMessages, MessageDescriptor } from 'react-intl';
|
||||
import { IssueType } from '../../../server/constants/issue';
|
||||
|
||||
const messages = defineMessages({
|
||||
issueAudio: 'Audio',
|
||||
issueVideo: 'Video',
|
||||
issueSubtitles: 'Subtitle',
|
||||
issueOther: 'Other',
|
||||
});
|
||||
|
||||
interface IssueOption {
|
||||
name: MessageDescriptor;
|
||||
issueType: IssueType;
|
||||
mediaType?: 'movie' | 'tv';
|
||||
}
|
||||
|
||||
export const issueOptions: IssueOption[] = [
|
||||
{
|
||||
name: messages.issueVideo,
|
||||
issueType: IssueType.VIDEO,
|
||||
},
|
||||
{
|
||||
name: messages.issueAudio,
|
||||
issueType: IssueType.AUDIO,
|
||||
},
|
||||
{
|
||||
name: messages.issueSubtitles,
|
||||
issueType: IssueType.SUBTITLES,
|
||||
},
|
||||
{
|
||||
name: messages.issueOther,
|
||||
issueType: IssueType.OTHER,
|
||||
},
|
||||
];
|
||||
36
src/components/IssueModal/index.tsx
Normal file
36
src/components/IssueModal/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import Transition from '../Transition';
|
||||
import CreateIssueModal from './CreateIssueModal';
|
||||
|
||||
interface IssueModalProps {
|
||||
show?: boolean;
|
||||
onCancel: () => void;
|
||||
mediaType: 'movie' | 'tv';
|
||||
tmdbId: number;
|
||||
issueId?: never;
|
||||
}
|
||||
|
||||
const IssueModal: React.FC<IssueModalProps> = ({
|
||||
show,
|
||||
mediaType,
|
||||
onCancel,
|
||||
tmdbId,
|
||||
}) => (
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
>
|
||||
<CreateIssueModal
|
||||
mediaType={mediaType}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
/>
|
||||
</Transition>
|
||||
);
|
||||
|
||||
export default IssueModal;
|
||||
@@ -27,7 +27,6 @@ const SearchInput: React.FC = () => {
|
||||
className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full bg-opacity-80 focus:bg-opacity-100 focus:border-gray-500 hover:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
type="search"
|
||||
inputMode="search"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
@@ -36,6 +35,12 @@ const SearchInput: React.FC = () => {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{searchValue.length > 0 && (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExclamationIcon,
|
||||
SparklesIcon,
|
||||
UsersIcon,
|
||||
XIcon,
|
||||
@@ -17,6 +18,7 @@ import VersionStatus from '../VersionStatus';
|
||||
const messages = defineMessages({
|
||||
dashboard: 'Discover',
|
||||
requests: 'Requests',
|
||||
issues: 'Issues',
|
||||
users: 'Users',
|
||||
settings: 'Settings',
|
||||
});
|
||||
@@ -33,6 +35,7 @@ interface SidebarLinkProps {
|
||||
activeRegExp: RegExp;
|
||||
as?: string;
|
||||
requiredPermission?: Permission | Permission[];
|
||||
permissionType?: 'and' | 'or';
|
||||
}
|
||||
|
||||
const SidebarLinks: SidebarLinkProps[] = [
|
||||
@@ -48,6 +51,20 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
svgIcon: <ClockIcon className="w-6 h-6 mr-3" />,
|
||||
activeRegExp: /^\/requests/,
|
||||
},
|
||||
{
|
||||
href: '/issues',
|
||||
messagesKey: 'issues',
|
||||
svgIcon: (
|
||||
<ExclamationIcon className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
|
||||
),
|
||||
activeRegExp: /^\/issues/,
|
||||
requiredPermission: [
|
||||
Permission.MANAGE_ISSUES,
|
||||
Permission.CREATE_ISSUES,
|
||||
Permission.VIEW_ISSUES,
|
||||
],
|
||||
permissionType: 'or',
|
||||
},
|
||||
{
|
||||
href: '/users',
|
||||
messagesKey: 'users',
|
||||
@@ -121,7 +138,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
<nav className="flex-1 px-4 mt-16 space-y-4">
|
||||
{SidebarLinks.filter((link) =>
|
||||
link.requiredPermission
|
||||
? hasPermission(link.requiredPermission)
|
||||
? hasPermission(link.requiredPermission, {
|
||||
type: link.permissionType ?? 'and',
|
||||
})
|
||||
: true
|
||||
).map((sidebarLink) => {
|
||||
return (
|
||||
@@ -188,7 +207,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
<nav className="flex-1 px-4 mt-16 space-y-4">
|
||||
{SidebarLinks.filter((link) =>
|
||||
link.requiredPermission
|
||||
? hasPermission(link.requiredPermission)
|
||||
? hasPermission(link.requiredPermission, {
|
||||
type: link.permissionType ?? 'and',
|
||||
})
|
||||
: true
|
||||
).map((sidebarLink) => {
|
||||
return (
|
||||
|
||||
277
src/components/ManageSlideOver/index.tsx
Normal file
277
src/components/ManageSlideOver/index.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { ServerIcon } from '@heroicons/react/outline';
|
||||
import { CheckCircleIcon, DocumentRemoveIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { IssueStatus } from '../../../server/constants/issue';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import { MovieDetails } from '../../../server/models/Movie';
|
||||
import { TvDetails } from '../../../server/models/Tv';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Button from '../Common/Button';
|
||||
import ConfirmButton from '../Common/ConfirmButton';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
import DownloadBlock from '../DownloadBlock';
|
||||
import IssueBlock from '../IssueBlock';
|
||||
import RequestBlock from '../RequestBlock';
|
||||
|
||||
const messages = defineMessages({
|
||||
manageModalTitle: 'Manage {mediaType}',
|
||||
manageModalIssues: 'Open Issues',
|
||||
manageModalRequests: 'Requests',
|
||||
manageModalNoRequests: 'No requests.',
|
||||
manageModalClearMedia: 'Clear Media Data',
|
||||
manageModalClearMediaWarning:
|
||||
'* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
|
||||
openarr: 'Open in {arr}',
|
||||
openarr4k: 'Open in 4K {arr}',
|
||||
downloadstatus: 'Download Status',
|
||||
markavailable: 'Mark as Available',
|
||||
mark4kavailable: 'Mark as Available in 4K',
|
||||
allseasonsmarkedavailable: '* All seasons will be marked as available.',
|
||||
// Recreated here for lowercase versions to go with the modal clear media warning
|
||||
movie: 'movie',
|
||||
tvshow: 'series',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
};
|
||||
|
||||
interface ManageSlideOverProps {
|
||||
// mediaType: 'movie' | 'tv';
|
||||
show?: boolean;
|
||||
onClose: () => void;
|
||||
revalidate: () => void;
|
||||
}
|
||||
|
||||
interface ManageSlideOverMovieProps extends ManageSlideOverProps {
|
||||
mediaType: 'movie';
|
||||
data: MovieDetails;
|
||||
}
|
||||
|
||||
interface ManageSlideOverTvProps extends ManageSlideOverProps {
|
||||
mediaType: 'tv';
|
||||
data: TvDetails;
|
||||
}
|
||||
|
||||
const ManageSlideOver: React.FC<
|
||||
ManageSlideOverMovieProps | ManageSlideOverTvProps
|
||||
> = ({ show, mediaType, onClose, data, revalidate }) => {
|
||||
const { hasPermission } = useUser();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data?.mediaInfo?.id) {
|
||||
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const markAvailable = async (is4k = false) => {
|
||||
await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, {
|
||||
is4k,
|
||||
});
|
||||
revalidate();
|
||||
};
|
||||
|
||||
const openIssues =
|
||||
data.mediaInfo?.issues?.filter(
|
||||
(issue) => issue.status === IssueStatus.OPEN
|
||||
) ?? [];
|
||||
|
||||
return (
|
||||
<SlideOver
|
||||
show={show}
|
||||
title={intl.formatMessage(messages.manageModalTitle, {
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow
|
||||
),
|
||||
})}
|
||||
onClose={() => onClose()}
|
||||
subText={isMovie(data) ? data.title : data.name}
|
||||
>
|
||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl">
|
||||
{intl.formatMessage(messages.downloadstatus)}
|
||||
</h3>
|
||||
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
|
||||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled)) && (
|
||||
<div className="mb-6">
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable()}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>{intl.formatMessage(messages.markavailable)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable(true)}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>{intl.formatMessage(messages.mark4kavailable)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{mediaType === 'tv' && (
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.allseasonsmarkedavailable)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
openIssues.length > 0 && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl">
|
||||
{intl.formatMessage(messages.manageModalIssues)}
|
||||
</h3>
|
||||
<div className="mb-4 overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{openIssues.map((issue) => (
|
||||
<li
|
||||
key={`manage-issue-${issue.id}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<IssueBlock issue={issue} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<h3 className="mb-2 text-xl">
|
||||
{intl.formatMessage(messages.manageModalRequests)}
|
||||
</h3>
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.requests?.map((request) => (
|
||||
<li
|
||||
key={`manage-request-${request.id}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<RequestBlock request={request} onUpdate={() => revalidate()} />
|
||||
</li>
|
||||
))}
|
||||
{(data.mediaInfo?.requests ?? []).length === 0 && (
|
||||
<li className="py-4 text-center text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalNoRequests)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{hasPermission(Permission.ADMIN) &&
|
||||
(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
|
||||
<div className="mt-8">
|
||||
{data?.mediaInfo?.serviceUrl && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block mb-2 last:mb-0"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openarr, {
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? globalMessages.movie
|
||||
: globalMessages.tvshow
|
||||
),
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{data?.mediaInfo?.serviceUrl4k && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openarr4k, {
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? globalMessages.movie
|
||||
: globalMessages.tvshow
|
||||
),
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo && (
|
||||
<div className="mt-8">
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<DocumentRemoveIcon />
|
||||
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalClearMediaWarning, {
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie' ? messages.movie : messages.tvshow
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SlideOver>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageSlideOver;
|
||||
@@ -2,18 +2,17 @@ import {
|
||||
ArrowCircleRightIcon,
|
||||
CloudIcon,
|
||||
CogIcon,
|
||||
ExclamationIcon,
|
||||
FilmIcon,
|
||||
PlayIcon,
|
||||
TicketIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDoubleDownIcon,
|
||||
ChevronDoubleUpIcon,
|
||||
DocumentRemoveIcon,
|
||||
ExternalLinkIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import { hasFlag } from 'country-flag-icons';
|
||||
import 'country-flag-icons/3x2/flags.css';
|
||||
import { uniqBy } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -21,6 +20,7 @@ import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
import { IssueStatus } from '../../../server/constants/issue';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
|
||||
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
|
||||
@@ -36,16 +36,14 @@ import Error from '../../pages/_error';
|
||||
import { sortCrewPriority } from '../../utils/creditHelpers';
|
||||
import Button from '../Common/Button';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import ConfirmButton from '../Common/ConfirmButton';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
import DownloadBlock from '../DownloadBlock';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
import IssueModal from '../IssueModal';
|
||||
import ManageSlideOver from '../ManageSlideOver';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
import PersonCard from '../PersonCard';
|
||||
import RequestBlock from '../RequestBlock';
|
||||
import RequestButton from '../RequestButton';
|
||||
import Slider from '../Slider';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
@@ -64,17 +62,8 @@ const messages = defineMessages({
|
||||
recommendations: 'Recommendations',
|
||||
similar: 'Similar Titles',
|
||||
overviewunavailable: 'Overview unavailable.',
|
||||
manageModalTitle: 'Manage Movie',
|
||||
manageModalRequests: 'Requests',
|
||||
manageModalNoRequests: 'No requests.',
|
||||
manageModalClearMedia: 'Clear Media Data',
|
||||
manageModalClearMediaWarning:
|
||||
'* This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
|
||||
studio: '{studioCount, plural, one {Studio} other {Studios}}',
|
||||
viewfullcrew: 'View Full Crew',
|
||||
openradarr: 'Open Movie in Radarr',
|
||||
openradarr4k: 'Open Movie in 4K Radarr',
|
||||
downloadstatus: 'Download Status',
|
||||
playonplex: 'Play on Plex',
|
||||
play4konplex: 'Play in 4K on Plex',
|
||||
markavailable: 'Mark as Available',
|
||||
@@ -82,6 +71,8 @@ const messages = defineMessages({
|
||||
showmore: 'Show More',
|
||||
showless: 'Show Less',
|
||||
streamingproviders: 'Currently Streaming On',
|
||||
productioncountries:
|
||||
'Production {countryCount, plural, one {Country} other {Countries}}',
|
||||
});
|
||||
|
||||
interface MovieDetailsProps {
|
||||
@@ -97,6 +88,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
const minStudios = 3;
|
||||
const [showMoreStudios, setShowMoreStudios] = useState(false);
|
||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<MovieDetailsType>(
|
||||
`/api/v1/movie/${router.query.movieId}`,
|
||||
@@ -164,20 +156,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data?.mediaInfo?.id) {
|
||||
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const markAvailable = async (is4k = false) => {
|
||||
await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, {
|
||||
is4k,
|
||||
});
|
||||
revalidate();
|
||||
};
|
||||
|
||||
const region = user?.settings?.region
|
||||
? user.settings.region
|
||||
: settings.currentSettings.region
|
||||
@@ -264,141 +242,19 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</div>
|
||||
)}
|
||||
<PageTitle title={data.title} />
|
||||
<SlideOver
|
||||
show={showManager}
|
||||
title={intl.formatMessage(messages.manageModalTitle)}
|
||||
<IssueModal
|
||||
onCancel={() => setShowIssueModal(false)}
|
||||
show={showIssueModal}
|
||||
mediaType="movie"
|
||||
tmdbId={data.id}
|
||||
/>
|
||||
<ManageSlideOver
|
||||
data={data}
|
||||
mediaType="movie"
|
||||
onClose={() => setShowManager(false)}
|
||||
subText={data.title}
|
||||
>
|
||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.downloadstatus)}
|
||||
</h3>
|
||||
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
|
||||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.movie4kEnabled)) && (
|
||||
<div className="mb-6">
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable()}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>{intl.formatMessage(messages.markavailable)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.movie4kEnabled && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable(true)}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.mark4kavailable)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalRequests)}
|
||||
</h3>
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.requests?.map((request) => (
|
||||
<li
|
||||
key={`manage-request-${request.id}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<RequestBlock request={request} onUpdate={() => revalidate()} />
|
||||
</li>
|
||||
))}
|
||||
{(data.mediaInfo?.requests ?? []).length === 0 && (
|
||||
<li className="py-4 text-center text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalNoRequests)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
|
||||
<div className="mt-8">
|
||||
{data?.mediaInfo?.serviceUrl && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block mb-2 last:mb-0"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.openradarr)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{data?.mediaInfo?.serviceUrl4k && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.openradarr4k)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo && (
|
||||
<div className="mt-8">
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<DocumentRemoveIcon />
|
||||
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalClearMediaWarning)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SlideOver>
|
||||
revalidate={() => revalidate()}
|
||||
show={showManager}
|
||||
/>
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
@@ -462,7 +318,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
.map((t, k) => <span key={k}>{t}</span>)
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev} | {curr}
|
||||
{prev}
|
||||
<span>|</span>
|
||||
{curr}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
@@ -475,13 +333,52 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
tmdbId={data.id}
|
||||
onUpdate={() => revalidate()}
|
||||
/>
|
||||
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
(settings.currentSettings.movie4kEnabled &&
|
||||
hasPermission(
|
||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
) &&
|
||||
data.mediaInfo?.status4k === MediaStatus.AVAILABLE)) &&
|
||||
hasPermission(
|
||||
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
) && (
|
||||
<Button
|
||||
buttonType="warning"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowIssueModal(true)}
|
||||
>
|
||||
<ExclamationIcon />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2 first:ml-0"
|
||||
className="relative ml-2 first:ml-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<CogIcon />
|
||||
<CogIcon className="!mr-0" />
|
||||
{hasPermission(
|
||||
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
) &&
|
||||
(
|
||||
data.mediaInfo?.issues.filter(
|
||||
(issue) => issue.status === IssueStatus.OPEN
|
||||
) ?? []
|
||||
).length > 0 && (
|
||||
<>
|
||||
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1" />
|
||||
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1 animate-ping" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -703,6 +600,37 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.productionCountries.length > 0 && (
|
||||
<div className="media-fact">
|
||||
<span>
|
||||
{intl.formatMessage(messages.productioncountries, {
|
||||
countryCount: data.productionCountries.length,
|
||||
})}
|
||||
</span>
|
||||
<span className="media-fact-value">
|
||||
{data.productionCountries.map((c) => {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center justify-end"
|
||||
key={`prodcountry-${c.iso_3166_1}`}
|
||||
>
|
||||
{hasFlag(c.iso_3166_1) && (
|
||||
<span
|
||||
className={`mr-1.5 text-xs leading-5 flag:${c.iso_3166_1}`}
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{intl.formatDisplayName(c.iso_3166_1, {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}) ?? c.name}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.productionCompanies.length > 0 && (
|
||||
<div className="media-fact">
|
||||
<span>
|
||||
|
||||
@@ -37,6 +37,28 @@ const messages = defineMessages({
|
||||
'Send notifications when media requests are declined.',
|
||||
usermediadeclinedDescription:
|
||||
'Get notified when your media requests are declined.',
|
||||
issuecreated: 'Issue Reported',
|
||||
issuecreatedDescription: 'Send notifications when issues are reported.',
|
||||
userissuecreatedDescription: 'Get notified when other users report issues.',
|
||||
issuecomment: 'Issue Comment',
|
||||
issuecommentDescription:
|
||||
'Send notifications when issues receive new comments.',
|
||||
userissuecommentDescription:
|
||||
'Get notified when issues you reported receive new comments.',
|
||||
adminissuecommentDescription:
|
||||
'Get notified when other users comment on issues.',
|
||||
issueresolved: 'Issue Resolved',
|
||||
issueresolvedDescription: 'Send notifications when issues are resolved.',
|
||||
userissueresolvedDescription:
|
||||
'Get notified when issues you reported are resolved.',
|
||||
adminissueresolvedDescription:
|
||||
'Get notified when issues are resolved by other users.',
|
||||
issuereopened: 'Issue Reopened',
|
||||
issuereopenedDescription: 'Send notifications when issues are reopened.',
|
||||
userissuereopenedDescription:
|
||||
'Get notified when issues you reported are reopened.',
|
||||
adminissuereopenedDescription:
|
||||
'Get notified when issues are reopened by other users.',
|
||||
});
|
||||
|
||||
export const hasNotificationType = (
|
||||
@@ -74,6 +96,10 @@ export enum Notification {
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
MEDIA_AUTO_APPROVED = 128,
|
||||
ISSUE_CREATED = 256,
|
||||
ISSUE_COMMENT = 512,
|
||||
ISSUE_RESOLVED = 1024,
|
||||
ISSUE_REOPENED = 2048,
|
||||
}
|
||||
|
||||
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
||||
@@ -85,7 +111,7 @@ export interface NotificationItem {
|
||||
name: string;
|
||||
description: string;
|
||||
value: Notification;
|
||||
hasNotifyUser?: boolean;
|
||||
hasNotifyUser: boolean;
|
||||
children?: NotificationItem[];
|
||||
hidden?: boolean;
|
||||
}
|
||||
@@ -173,6 +199,7 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
|
||||
: messages.mediarequestedDescription
|
||||
),
|
||||
value: Notification.MEDIA_PENDING,
|
||||
hasNotifyUser: false,
|
||||
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
|
||||
},
|
||||
{
|
||||
@@ -184,6 +211,7 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
|
||||
: messages.mediaAutoApprovedDescription
|
||||
),
|
||||
value: Notification.MEDIA_AUTO_APPROVED,
|
||||
hasNotifyUser: false,
|
||||
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
|
||||
},
|
||||
{
|
||||
@@ -231,6 +259,76 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
|
||||
),
|
||||
value: Notification.MEDIA_FAILED,
|
||||
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
|
||||
hasNotifyUser: false,
|
||||
},
|
||||
{
|
||||
id: 'issue-created',
|
||||
name: intl.formatMessage(messages.issuecreated),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? messages.userissuecreatedDescription
|
||||
: messages.issuecreatedDescription
|
||||
),
|
||||
value: Notification.ISSUE_CREATED,
|
||||
hidden: user && !hasPermission(Permission.MANAGE_ISSUES),
|
||||
hasNotifyUser: false,
|
||||
},
|
||||
{
|
||||
id: 'issue-comment',
|
||||
name: intl.formatMessage(messages.issuecomment),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? hasPermission(Permission.MANAGE_ISSUES)
|
||||
? messages.adminissuecommentDescription
|
||||
: messages.userissuecommentDescription
|
||||
: messages.issuecommentDescription
|
||||
),
|
||||
value: Notification.ISSUE_COMMENT,
|
||||
hidden:
|
||||
user &&
|
||||
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
hasNotifyUser:
|
||||
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
|
||||
},
|
||||
{
|
||||
id: 'issue-resolved',
|
||||
name: intl.formatMessage(messages.issueresolved),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? hasPermission(Permission.MANAGE_ISSUES)
|
||||
? messages.adminissueresolvedDescription
|
||||
: messages.userissueresolvedDescription
|
||||
: messages.issueresolvedDescription
|
||||
),
|
||||
value: Notification.ISSUE_RESOLVED,
|
||||
hidden:
|
||||
user &&
|
||||
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
hasNotifyUser:
|
||||
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
|
||||
},
|
||||
{
|
||||
id: 'issue-reopened',
|
||||
name: intl.formatMessage(messages.issuereopened),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? hasPermission(Permission.MANAGE_ISSUES)
|
||||
? messages.adminissuereopenedDescription
|
||||
: messages.userissuereopenedDescription
|
||||
: messages.issuereopenedDescription
|
||||
),
|
||||
value: Notification.ISSUE_REOPENED,
|
||||
hidden:
|
||||
user &&
|
||||
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
hasNotifyUser:
|
||||
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -9,21 +9,24 @@ export const messages = defineMessages({
|
||||
'Full administrator access. Bypasses all other permission checks.',
|
||||
users: 'Manage Users',
|
||||
usersDescription:
|
||||
'Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.',
|
||||
'Grant permission to manage users. Users with this permission cannot modify users with or grant the Admin privilege.',
|
||||
settings: 'Manage Settings',
|
||||
settingsDescription:
|
||||
'Grant permission to modify Overseerr settings. A user must have this permission to grant it to others.',
|
||||
'Grant permission to modify global settings. A user must have this permission to grant it to others.',
|
||||
managerequests: 'Manage Requests',
|
||||
managerequestsDescription:
|
||||
'Grant permission to manage Overseerr requests. All requests made by a user with this permission will be automatically approved.',
|
||||
'Grant permission to manage media requests. All requests made by a user with this permission will be automatically approved.',
|
||||
request: 'Request',
|
||||
requestDescription: 'Grant permission to request non-4K media.',
|
||||
requestDescription: 'Grant permission to submit requests for non-4K media.',
|
||||
requestMovies: 'Request Movies',
|
||||
requestMoviesDescription: 'Grant permission to request non-4K movies.',
|
||||
requestMoviesDescription:
|
||||
'Grant permission to submit requests for non-4K movies.',
|
||||
requestTv: 'Request Series',
|
||||
requestTvDescription: 'Grant permission to request non-4K series.',
|
||||
requestTvDescription:
|
||||
'Grant permission to submit requests for non-4K series.',
|
||||
autoapprove: 'Auto-Approve',
|
||||
autoapproveDescription: 'Grant automatic approval for all non-4K requests.',
|
||||
autoapproveDescription:
|
||||
'Grant automatic approval for all non-4K media requests.',
|
||||
autoapproveMovies: 'Auto-Approve Movies',
|
||||
autoapproveMoviesDescription:
|
||||
'Grant automatic approval for non-4K movie requests.',
|
||||
@@ -31,7 +34,8 @@ export const messages = defineMessages({
|
||||
autoapproveSeriesDescription:
|
||||
'Grant automatic approval for non-4K series requests.',
|
||||
autoapprove4k: 'Auto-Approve 4K',
|
||||
autoapprove4kDescription: 'Grant automatic approval for all 4K requests.',
|
||||
autoapprove4kDescription:
|
||||
'Grant automatic approval for all 4K media requests.',
|
||||
autoapprove4kMovies: 'Auto-Approve 4K Movies',
|
||||
autoapprove4kMoviesDescription:
|
||||
'Grant automatic approval for 4K movie requests.',
|
||||
@@ -39,16 +43,25 @@ export const messages = defineMessages({
|
||||
autoapprove4kSeriesDescription:
|
||||
'Grant automatic approval for 4K series requests.',
|
||||
request4k: 'Request 4K',
|
||||
request4kDescription: 'Grant permission to request 4K media.',
|
||||
request4kDescription: 'Grant permission to submit requests for 4K media.',
|
||||
request4kMovies: 'Request 4K Movies',
|
||||
request4kMoviesDescription: 'Grant permission to request 4K movies.',
|
||||
request4kMoviesDescription:
|
||||
'Grant permission to submit requests for 4K movies.',
|
||||
request4kTv: 'Request 4K Series',
|
||||
request4kTvDescription: 'Grant permission to request 4K series.',
|
||||
request4kTvDescription: 'Grant permission to submit requests for 4K series.',
|
||||
advancedrequest: 'Advanced Requests',
|
||||
advancedrequestDescription:
|
||||
'Grant permission to use advanced request options.',
|
||||
'Grant permission to modify advanced media request options.',
|
||||
viewrequests: 'View Requests',
|
||||
viewrequestsDescription: "Grant permission to view other users' requests.",
|
||||
viewrequestsDescription:
|
||||
'Grant permission to view media requests submitted by other users.',
|
||||
manageissues: 'Manage Issues',
|
||||
manageissuesDescription: 'Grant permission to manage media issues.',
|
||||
createissues: 'Report Issues',
|
||||
createissuesDescription: 'Grant permission to report media issues.',
|
||||
viewissues: 'View Issues',
|
||||
viewissuesDescription:
|
||||
'Grant permission to view media issues reported by other users.',
|
||||
});
|
||||
|
||||
interface PermissionEditProps {
|
||||
@@ -223,6 +236,26 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'manageissues',
|
||||
name: intl.formatMessage(messages.manageissues),
|
||||
description: intl.formatMessage(messages.manageissuesDescription),
|
||||
permission: Permission.MANAGE_ISSUES,
|
||||
children: [
|
||||
{
|
||||
id: 'createissues',
|
||||
name: intl.formatMessage(messages.createissues),
|
||||
description: intl.formatMessage(messages.createissuesDescription),
|
||||
permission: Permission.CREATE_ISSUES,
|
||||
},
|
||||
{
|
||||
id: 'viewissues',
|
||||
name: intl.formatMessage(messages.viewissues),
|
||||
description: intl.formatMessage(messages.viewissuesDescription),
|
||||
permission: Permission.VIEW_ISSUES,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -233,7 +233,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
||||
alt=""
|
||||
className="avatar-sm"
|
||||
/>
|
||||
<span className="truncate group-hover:underline">
|
||||
<span className="font-semibold truncate group-hover:underline group-hover:text-white">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -63,7 +63,7 @@ const RequestItemError: React.FC<RequestItemErroProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-64 px-10 bg-gray-800 lg:flex-row ring-1 ring-red-500 rounded-xl xl:h-32">
|
||||
<div className="flex flex-col items-center justify-center w-full h-64 px-10 bg-gray-800 lg:flex-row ring-1 ring-red-500 rounded-xl xl:h-28">
|
||||
<span className="text-sm text-center text-gray-300 lg:text-left">
|
||||
{intl.formatMessage(messages.mediaerror)}
|
||||
</span>
|
||||
@@ -104,7 +104,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
? `/api/v1/movie/${request.media.tmdbId}`
|
||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? `${url}` : null
|
||||
inView ? url : null
|
||||
);
|
||||
const {
|
||||
data: requestData,
|
||||
@@ -149,7 +149,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
if (!title && !error) {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-64 bg-gray-800 rounded-xl xl:h-32 animate-pulse"
|
||||
className="w-full h-64 bg-gray-800 rounded-xl xl:h-28 animate-pulse"
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
@@ -178,7 +178,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl xl:h-32 xl:flex-row">
|
||||
<div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl xl:h-28 xl:flex-row">
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-center bg-cover xl:w-2/3">
|
||||
<CachedImage
|
||||
@@ -205,7 +205,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
: `/tv/${requestData.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="relative flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md sm:w-14 transform-gpu hover:scale-105">
|
||||
<a className="relative flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md transform-gpu hover:scale-105">
|
||||
<CachedImage
|
||||
src={
|
||||
title.posterPath
|
||||
@@ -339,7 +339,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
alt=""
|
||||
className="ml-1.5 avatar-sm"
|
||||
/>
|
||||
<span className="text-sm truncate group-hover:underline">
|
||||
<span className="text-sm font-semibold truncate group-hover:underline group-hover:text-white">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
@@ -393,7 +393,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
alt=""
|
||||
className="ml-1.5 avatar-sm"
|
||||
/>
|
||||
<span className="text-sm truncate group-hover:underline">
|
||||
<span className="text-sm font-semibold truncate group-hover:underline group-hover:text-white">
|
||||
{requestData.modifiedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -22,7 +22,7 @@ import RequestItem from './RequestItem';
|
||||
const messages = defineMessages({
|
||||
requests: 'Requests',
|
||||
showallrequests: 'Show All Requests',
|
||||
sortAdded: 'Request Date',
|
||||
sortAdded: 'Most Recent',
|
||||
sortModified: 'Last Modified',
|
||||
});
|
||||
|
||||
|
||||
@@ -267,13 +267,23 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if ((!data || selectedServer === null) && !selectedUser) {
|
||||
if (
|
||||
(!data ||
|
||||
selectedServer === null ||
|
||||
(data.filter((server) => server.is4k === is4k).length < 2 &&
|
||||
(!serverData ||
|
||||
(serverData.profiles.length < 2 &&
|
||||
serverData.rootFolders.length < 2 &&
|
||||
(serverData.languageProfiles ?? []).length < 2 &&
|
||||
!serverData.tags?.length)))) &&
|
||||
(!selectedUser || (userData?.results ?? []).length < 2)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center mb-2 font-bold tracking-wider">
|
||||
<div className="flex items-center mt-4 mb-2 font-bold tracking-wider">
|
||||
<AdjustmentsIcon className="w-5 h-5 mr-1.5" />
|
||||
{intl.formatMessage(messages.advancedoptions)}
|
||||
</div>
|
||||
@@ -503,7 +513,8 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
|
||||
selectedUser && (
|
||||
selectedUser &&
|
||||
(userData?.results ?? []).length > 1 && (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={selectedUser}
|
||||
|
||||
476
src/components/RequestModal/CollectionRequestModal.tsx
Normal file
476
src/components/RequestModal/CollectionRequestModal.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import { DownloadIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
|
||||
import { Permission } from '../../../server/lib/permissions';
|
||||
import { Collection } from '../../../server/models/Collection';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Alert from '../Common/Alert';
|
||||
import Badge from '../Common/Badge';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import Modal from '../Common/Modal';
|
||||
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||
import QuotaDisplay from './QuotaDisplay';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestadmin: 'This request will be approved automatically.',
|
||||
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
||||
requesttitle: 'Request {title}',
|
||||
request4ktitle: 'Request {title} in 4K',
|
||||
requesterror: 'Something went wrong while submitting the request.',
|
||||
selectmovies: 'Select Movie(s)',
|
||||
requestmovies: 'Request {count} {count, plural, one {Movie} other {Movies}}',
|
||||
requestmovies4k:
|
||||
'Request {count} {count, plural, one {Movie} other {Movies}} in 4K',
|
||||
});
|
||||
|
||||
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
tmdbId: number;
|
||||
is4k?: boolean;
|
||||
onCancel?: () => void;
|
||||
onComplete?: (newStatus: MediaStatus) => void;
|
||||
onUpdating?: (isUpdating: boolean) => void;
|
||||
}
|
||||
|
||||
const CollectionRequestModal: React.FC<RequestModalProps> = ({
|
||||
onCancel,
|
||||
onComplete,
|
||||
tmdbId,
|
||||
onUpdating,
|
||||
is4k = false,
|
||||
}) => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [requestOverrides, setRequestOverrides] =
|
||||
useState<RequestOverrides | null>(null);
|
||||
const [selectedParts, setSelectedParts] = useState<number[]>([]);
|
||||
const { addToast } = useToasts();
|
||||
const { data, error } = useSWR<Collection>(`/api/v1/collection/${tmdbId}`, {
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
const intl = useIntl();
|
||||
const { user, hasPermission } = useUser();
|
||||
const { data: quota } = useSWR<QuotaResponse>(
|
||||
user &&
|
||||
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
|
||||
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
|
||||
: null
|
||||
);
|
||||
|
||||
const currentlyRemaining =
|
||||
(quota?.movie.remaining ?? 0) - selectedParts.length;
|
||||
|
||||
const getAllParts = (): number[] => {
|
||||
return (data?.parts ?? []).map((part) => part.id);
|
||||
};
|
||||
|
||||
const getAllRequestedParts = (): number[] => {
|
||||
const requestedParts = (data?.parts ?? []).reduce(
|
||||
(requestedParts, part) => {
|
||||
return [
|
||||
...requestedParts,
|
||||
...(part.mediaInfo?.requests ?? [])
|
||||
.filter(
|
||||
(request) =>
|
||||
request.is4k === is4k &&
|
||||
request.status !== MediaRequestStatus.DECLINED
|
||||
)
|
||||
.map((part) => part.id),
|
||||
];
|
||||
},
|
||||
[] as number[]
|
||||
);
|
||||
|
||||
const availableParts = (data?.parts ?? [])
|
||||
.filter(
|
||||
(part) =>
|
||||
part.mediaInfo &&
|
||||
(part.mediaInfo[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE ||
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PROCESSING) &&
|
||||
!requestedParts.includes(part.id)
|
||||
)
|
||||
.map((part) => part.id);
|
||||
|
||||
return [...requestedParts, ...availableParts];
|
||||
};
|
||||
|
||||
const isSelectedPart = (tmdbId: number): boolean =>
|
||||
selectedParts.includes(tmdbId);
|
||||
|
||||
const togglePart = (tmdbId: number): void => {
|
||||
// If this part already has a pending request, don't allow it to be toggled
|
||||
if (getAllRequestedParts().includes(tmdbId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no more remaining requests available, block toggle
|
||||
if (
|
||||
quota?.movie.limit &&
|
||||
currentlyRemaining <= 0 &&
|
||||
!isSelectedPart(tmdbId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedParts.includes(tmdbId)) {
|
||||
setSelectedParts((parts) => parts.filter((partId) => partId !== tmdbId));
|
||||
} else {
|
||||
setSelectedParts((parts) => [...parts, tmdbId]);
|
||||
}
|
||||
};
|
||||
|
||||
const unrequestedParts = getAllParts().filter(
|
||||
(tmdbId) => !getAllRequestedParts().includes(tmdbId)
|
||||
);
|
||||
|
||||
const toggleAllParts = (): void => {
|
||||
// If the user has a quota and not enough requests for all parts, block toggleAllParts
|
||||
if (
|
||||
quota?.movie.limit &&
|
||||
(quota?.movie.remaining ?? 0) < unrequestedParts.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
data &&
|
||||
selectedParts.length >= 0 &&
|
||||
selectedParts.length < unrequestedParts.length
|
||||
) {
|
||||
setSelectedParts(unrequestedParts);
|
||||
} else {
|
||||
setSelectedParts([]);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllParts = (): boolean => {
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
selectedParts.length ===
|
||||
getAllParts().filter((part) => !getAllRequestedParts().includes(part))
|
||||
.length
|
||||
);
|
||||
};
|
||||
|
||||
const getPartRequest = (tmdbId: number): MediaRequest | undefined => {
|
||||
const part = (data?.parts ?? []).find((part) => part.id === tmdbId);
|
||||
|
||||
return (part?.mediaInfo?.requests ?? []).find(
|
||||
(request) =>
|
||||
request.is4k === is4k && request.status !== MediaRequestStatus.DECLINED
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (onUpdating) {
|
||||
onUpdating(isUpdating);
|
||||
}
|
||||
}, [isUpdating, onUpdating]);
|
||||
|
||||
const sendRequest = useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
let overrideParams = {};
|
||||
if (requestOverrides) {
|
||||
overrideParams = {
|
||||
serverId: requestOverrides.server,
|
||||
profileId: requestOverrides.profile,
|
||||
rootFolder: requestOverrides.folder,
|
||||
userId: requestOverrides.user?.id,
|
||||
tags: requestOverrides.tags,
|
||||
};
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
(
|
||||
data?.parts.filter((part) => selectedParts.includes(part.id)) ?? []
|
||||
).map(async (part) => {
|
||||
await axios.post<MediaRequest>('/api/v1/request', {
|
||||
mediaId: part.id,
|
||||
mediaType: 'movie',
|
||||
is4k,
|
||||
...overrideParams,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(
|
||||
selectedParts.length === (data?.parts ?? []).length
|
||||
? MediaStatus.UNKNOWN
|
||||
: MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestSuccess, {
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'success', autoDismiss: true }
|
||||
);
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.requesterror), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [requestOverrides, data, onComplete, addToast, intl, selectedParts, is4k]);
|
||||
|
||||
const hasAutoApprove = hasPermission(
|
||||
[
|
||||
Permission.MANAGE_REQUESTS,
|
||||
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
|
||||
is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE,
|
||||
],
|
||||
{ type: 'or' }
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
loading={(!data && !error) || !quota}
|
||||
backgroundClickable
|
||||
onCancel={onCancel}
|
||||
onOk={sendRequest}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.request4ktitle : messages.requesttitle,
|
||||
{ title: data?.name }
|
||||
)}
|
||||
okText={
|
||||
isUpdating
|
||||
? intl.formatMessage(globalMessages.requesting)
|
||||
: selectedParts.length === 0
|
||||
? intl.formatMessage(messages.selectmovies)
|
||||
: intl.formatMessage(
|
||||
is4k ? messages.requestmovies4k : messages.requestmovies,
|
||||
{
|
||||
count: selectedParts.length,
|
||||
}
|
||||
)
|
||||
}
|
||||
okDisabled={selectedParts.length === 0}
|
||||
okButtonType={'primary'}
|
||||
iconSvg={<DownloadIcon />}
|
||||
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
||||
>
|
||||
{hasAutoApprove && !quota?.movie.restricted && (
|
||||
<div className="mt-6">
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.requestadmin)}
|
||||
type="info"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(quota?.movie.limit ?? 0) > 0 && (
|
||||
<QuotaDisplay
|
||||
mediaType="movie"
|
||||
quota={quota?.movie}
|
||||
remaining={currentlyRemaining}
|
||||
userOverride={
|
||||
requestOverrides?.user && requestOverrides.user.id !== user?.id
|
||||
? requestOverrides?.user?.id
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="-mx-4 sm:mx-0">
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<div className="overflow-hidden shadow sm:rounded-lg">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-16 px-4 py-3 bg-gray-500">
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={isAllParts()}
|
||||
onClick={() => toggleAllParts()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
toggleAllParts();
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none ${
|
||||
quota?.movie.limit &&
|
||||
(quota.movie.remaining ?? 0) < unrequestedParts.length
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isAllParts() ? 'bg-indigo-500' : 'bg-gray-800'
|
||||
} absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isAllParts() ? 'translate-x-5' : 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 border border-gray-200 rounded-full bg-white shadow transform group-focus:ring group-focus:border-blue-300 transition-transform ease-in-out duration-200`}
|
||||
></span>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-1 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
|
||||
{intl.formatMessage(globalMessages.movie)}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
|
||||
{intl.formatMessage(globalMessages.status)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-gray-600 divide-y divide-gray-700">
|
||||
{data?.parts.map((part) => {
|
||||
const partRequest = getPartRequest(part.id);
|
||||
const partMedia =
|
||||
part.mediaInfo &&
|
||||
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.UNKNOWN
|
||||
? part.mediaInfo
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<tr key={`part-${part.id}`}>
|
||||
<td className="px-4 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={
|
||||
!!partMedia || isSelectedPart(part.id)
|
||||
}
|
||||
onClick={() => togglePart(part.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
togglePart(part.id);
|
||||
}
|
||||
}}
|
||||
className={`pt-2 relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
|
||||
!!partMedia ||
|
||||
partRequest ||
|
||||
(quota?.movie.limit &&
|
||||
currentlyRemaining <= 0 &&
|
||||
!isSelectedPart(part.id))
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!partMedia ||
|
||||
partRequest ||
|
||||
isSelectedPart(part.id)
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-800'
|
||||
} absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!partMedia ||
|
||||
partRequest ||
|
||||
isSelectedPart(part.id)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 border border-gray-200 rounded-full bg-white shadow transform group-focus:ring group-focus:border-blue-300 transition-transform ease-in-out duration-200`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
||||
<div className="relative flex-shrink-0 w-10 h-auto overflow-hidden rounded-md">
|
||||
<CachedImage
|
||||
src={
|
||||
part.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
layout="responsive"
|
||||
width={600}
|
||||
height={900}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center pl-2">
|
||||
<div className="text-xs font-medium">
|
||||
{part.releaseDate?.slice(0, 4)}
|
||||
</div>
|
||||
<div className="text-base font-bold">
|
||||
{part.title}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap">
|
||||
{!partMedia && !partRequest && (
|
||||
<Badge>
|
||||
{intl.formatMessage(globalMessages.notrequested)}
|
||||
</Badge>
|
||||
)}
|
||||
{!partMedia &&
|
||||
partRequest?.status ===
|
||||
MediaRequestStatus.PENDING && (
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
)}
|
||||
{((!partMedia &&
|
||||
partRequest?.status ===
|
||||
MediaRequestStatus.APPROVED) ||
|
||||
partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PROCESSING) && (
|
||||
<Badge badgeType="primary">
|
||||
{intl.formatMessage(globalMessages.requested)}
|
||||
</Badge>
|
||||
)}
|
||||
{partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
||||
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
||||
<AdvancedRequester
|
||||
type="movie"
|
||||
is4k={is4k}
|
||||
onChange={(overrides) => {
|
||||
setRequestOverrides(overrides);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionRequestModal;
|
||||
@@ -23,12 +23,14 @@ const messages = defineMessages({
|
||||
requesttitle: 'Request {title}',
|
||||
request4ktitle: 'Request {title} in 4K',
|
||||
edit: 'Edit Request',
|
||||
approve: 'Approve Request',
|
||||
cancel: 'Cancel Request',
|
||||
pendingrequest: 'Pending Request for {title}',
|
||||
pending4krequest: 'Pending 4K Request for {title}',
|
||||
requestfrom: "{username}'s request is pending approval.",
|
||||
errorediting: 'Something went wrong while editing the request.',
|
||||
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
|
||||
requestApproved: 'Request for <strong>{title}</strong> approved!',
|
||||
requesterror: 'Something went wrong while submitting the request.',
|
||||
pendingapproval: 'Your request is pending approval.',
|
||||
});
|
||||
@@ -60,7 +62,10 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
const intl = useIntl();
|
||||
const { user, hasPermission } = useUser();
|
||||
const { data: quota } = useSWR<QuotaResponse>(
|
||||
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
|
||||
user &&
|
||||
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
|
||||
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,7 +161,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const updateRequest = async () => {
|
||||
const updateRequest = async (alsoApproveRequest = false) => {
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
@@ -169,14 +174,23 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
tags: requestOverrides?.tags,
|
||||
});
|
||||
|
||||
if (alsoApproveRequest) {
|
||||
await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
|
||||
}
|
||||
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestedited, {
|
||||
title: data?.title,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
{intl.formatMessage(
|
||||
alsoApproveRequest
|
||||
? messages.requestApproved
|
||||
: messages.requestedited,
|
||||
{
|
||||
title: data?.title,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
}
|
||||
)}
|
||||
</span>,
|
||||
{
|
||||
appearance: 'success',
|
||||
@@ -199,12 +213,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
|
||||
if (editRequest) {
|
||||
const isOwner = editRequest.requestedBy.id === user?.id;
|
||||
const showEditButton = hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_ADVANCED],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -215,20 +223,44 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
is4k ? messages.pending4krequest : messages.pendingrequest,
|
||||
{ title: data?.title }
|
||||
)}
|
||||
onOk={() => (showEditButton ? updateRequest() : cancelRequest())}
|
||||
onOk={() =>
|
||||
hasPermission(Permission.MANAGE_REQUESTS)
|
||||
? updateRequest(true)
|
||||
: hasPermission(Permission.REQUEST_ADVANCED)
|
||||
? updateRequest()
|
||||
: cancelRequest()
|
||||
}
|
||||
okDisabled={isUpdating}
|
||||
okText={
|
||||
showEditButton
|
||||
hasPermission(Permission.MANAGE_REQUESTS)
|
||||
? intl.formatMessage(messages.approve)
|
||||
: hasPermission(Permission.REQUEST_ADVANCED)
|
||||
? intl.formatMessage(messages.edit)
|
||||
: intl.formatMessage(messages.cancel)
|
||||
}
|
||||
okButtonType={showEditButton ? 'primary' : 'danger'}
|
||||
okButtonType={
|
||||
hasPermission(Permission.MANAGE_REQUESTS)
|
||||
? 'success'
|
||||
: hasPermission(Permission.REQUEST_ADVANCED)
|
||||
? 'primary'
|
||||
: 'danger'
|
||||
}
|
||||
onSecondary={
|
||||
isOwner && showEditButton ? () => cancelRequest() : undefined
|
||||
isOwner &&
|
||||
hasPermission(
|
||||
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? () => cancelRequest()
|
||||
: undefined
|
||||
}
|
||||
secondaryDisabled={isUpdating}
|
||||
secondaryText={
|
||||
isOwner && showEditButton
|
||||
isOwner &&
|
||||
hasPermission(
|
||||
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? intl.formatMessage(messages.cancel)
|
||||
: undefined
|
||||
}
|
||||
@@ -244,22 +276,20 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
})}
|
||||
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
||||
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
||||
<div className="mt-4">
|
||||
<AdvancedRequester
|
||||
type="movie"
|
||||
is4k={is4k}
|
||||
requestUser={editRequest.requestedBy}
|
||||
defaultOverrides={{
|
||||
folder: editRequest.rootFolder,
|
||||
profile: editRequest.profileId,
|
||||
server: editRequest.serverId,
|
||||
tags: editRequest.tags,
|
||||
}}
|
||||
onChange={(overrides) => {
|
||||
setRequestOverrides(overrides);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<AdvancedRequester
|
||||
type="movie"
|
||||
is4k={is4k}
|
||||
requestUser={editRequest.requestedBy}
|
||||
defaultOverrides={{
|
||||
folder: editRequest.rootFolder,
|
||||
profile: editRequest.profileId,
|
||||
server: editRequest.serverId,
|
||||
tags: editRequest.tags,
|
||||
}}
|
||||
onChange={(overrides) => {
|
||||
setRequestOverrides(overrides);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -99,8 +99,8 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
<div className="mb-2">
|
||||
{intl.formatMessage(
|
||||
userOverride
|
||||
? messages.requiredquota
|
||||
: messages.requiredquotaUser,
|
||||
? messages.requiredquotaUser
|
||||
: messages.requiredquota,
|
||||
{
|
||||
seasons: overLimit,
|
||||
strong: function strong(msg) {
|
||||
|
||||
@@ -30,13 +30,15 @@ const messages = defineMessages({
|
||||
requesttitle: 'Request {title}',
|
||||
request4ktitle: 'Request {title} in 4K',
|
||||
edit: 'Edit Request',
|
||||
approve: 'Approve Request',
|
||||
cancel: 'Cancel Request',
|
||||
pendingrequest: 'Pending Request for {title}',
|
||||
pending4krequest: 'Pending 4K Request for {title}',
|
||||
requestfrom: "{username}'s request is pending approval.",
|
||||
requestseasons:
|
||||
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}',
|
||||
requestall: 'Request All Seasons',
|
||||
requestseasons4k:
|
||||
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}} in 4K',
|
||||
alreadyrequested: 'Already Requested',
|
||||
selectseason: 'Select Season(s)',
|
||||
season: 'Season',
|
||||
@@ -45,6 +47,7 @@ const messages = defineMessages({
|
||||
extras: 'Extras',
|
||||
errorediting: 'Something went wrong while editing the request.',
|
||||
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
|
||||
requestApproved: 'Request for <strong>{title}</strong> approved!',
|
||||
requestcancelled: 'Request for <strong>{title}</strong> canceled.',
|
||||
autoapproval: 'Automatic Approval',
|
||||
requesterror: 'Something went wrong while submitting the request.',
|
||||
@@ -88,7 +91,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
});
|
||||
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined);
|
||||
const { data: quota } = useSWR<QuotaResponse>(
|
||||
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
|
||||
user &&
|
||||
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
|
||||
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
|
||||
: null
|
||||
);
|
||||
|
||||
const currentlyRemaining =
|
||||
@@ -96,7 +102,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
selectedSeasons.length +
|
||||
(editRequest?.seasons ?? []).length;
|
||||
|
||||
const updateRequest = async () => {
|
||||
const updateRequest = async (alsoApproveRequest = false) => {
|
||||
if (!editRequest) {
|
||||
return;
|
||||
}
|
||||
@@ -117,6 +123,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
tags: requestOverrides?.tags,
|
||||
seasons: selectedSeasons,
|
||||
});
|
||||
|
||||
if (alsoApproveRequest) {
|
||||
await axios.post(`/api/v1/request/${editRequest.id}/approve`);
|
||||
}
|
||||
} else {
|
||||
await axios.delete(`/api/v1/request/${editRequest.id}`);
|
||||
}
|
||||
@@ -124,12 +134,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
addToast(
|
||||
<span>
|
||||
{selectedSeasons.length > 0
|
||||
? intl.formatMessage(messages.requestedited, {
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})
|
||||
? intl.formatMessage(
|
||||
alsoApproveRequest
|
||||
? messages.requestApproved
|
||||
: messages.requestedited,
|
||||
{
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(messages.requestcancelled, {
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
@@ -368,7 +383,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
loading={!data && !error}
|
||||
backgroundClickable
|
||||
onCancel={tvdbId ? () => setSearchModal({ show: true }) : onCancel}
|
||||
onOk={() => (editRequest ? updateRequest() : sendRequest())}
|
||||
onOk={() =>
|
||||
editRequest
|
||||
? hasPermission(Permission.MANAGE_REQUESTS)
|
||||
? updateRequest(true)
|
||||
: updateRequest()
|
||||
: sendRequest()
|
||||
}
|
||||
title={intl.formatMessage(
|
||||
editRequest
|
||||
? is4k
|
||||
@@ -383,16 +404,23 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
editRequest
|
||||
? selectedSeasons.length === 0
|
||||
? intl.formatMessage(messages.cancel)
|
||||
: hasPermission(Permission.MANAGE_REQUESTS)
|
||||
? intl.formatMessage(messages.approve)
|
||||
: intl.formatMessage(messages.edit)
|
||||
: getAllRequestedSeasons().length >= getAllSeasons().length
|
||||
? intl.formatMessage(messages.alreadyrequested)
|
||||
: !settings.currentSettings.partialRequestsEnabled
|
||||
? intl.formatMessage(messages.requestall)
|
||||
? intl.formatMessage(
|
||||
is4k ? globalMessages.request4k : globalMessages.request
|
||||
)
|
||||
: selectedSeasons.length === 0
|
||||
? intl.formatMessage(messages.selectseason)
|
||||
: intl.formatMessage(messages.requestseasons, {
|
||||
seasonCount: selectedSeasons.length,
|
||||
})
|
||||
: intl.formatMessage(
|
||||
is4k ? messages.requestseasons4k : messages.requestseasons,
|
||||
{
|
||||
seasonCount: selectedSeasons.length,
|
||||
}
|
||||
)
|
||||
}
|
||||
okDisabled={
|
||||
editRequest
|
||||
@@ -406,11 +434,14 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
selectedSeasons.length === 0)
|
||||
}
|
||||
okButtonType={
|
||||
editRequest &&
|
||||
settings.currentSettings.partialRequestsEnabled &&
|
||||
selectedSeasons.length === 0
|
||||
? 'danger'
|
||||
: `primary`
|
||||
editRequest
|
||||
? settings.currentSettings.partialRequestsEnabled &&
|
||||
selectedSeasons.length === 0
|
||||
? 'danger'
|
||||
: hasPermission(Permission.MANAGE_REQUESTS)
|
||||
? 'success'
|
||||
: 'primary'
|
||||
: 'primary'
|
||||
}
|
||||
cancelText={
|
||||
editRequest
|
||||
@@ -440,7 +471,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
!(
|
||||
quota?.tv.limit &&
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
unrequestedSeasons.length > (quota?.tv.remaining ?? 0)
|
||||
) &&
|
||||
getAllRequestedSeasons().length < getAllSeasons().length &&
|
||||
!editRequest && (
|
||||
@@ -457,7 +488,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
quota={quota?.tv}
|
||||
remaining={
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
unrequestedSeasons.length > (quota?.tv.remaining ?? 0)
|
||||
? 0
|
||||
: currentlyRemaining
|
||||
}
|
||||
@@ -468,7 +499,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
overLimit={
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
unrequestedSeasons.length > (quota?.tv.remaining ?? 0)
|
||||
? unrequestedSeasons.length
|
||||
: undefined
|
||||
}
|
||||
@@ -667,28 +698,26 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
</div>
|
||||
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
||||
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
||||
<div className="mt-4">
|
||||
<AdvancedRequester
|
||||
type="tv"
|
||||
is4k={is4k}
|
||||
isAnime={data?.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)}
|
||||
onChange={(overrides) => setRequestOverrides(overrides)}
|
||||
requestUser={editRequest?.requestedBy}
|
||||
defaultOverrides={
|
||||
editRequest
|
||||
? {
|
||||
folder: editRequest.rootFolder,
|
||||
profile: editRequest.profileId,
|
||||
server: editRequest.serverId,
|
||||
language: editRequest.languageProfileId,
|
||||
tags: editRequest.tags,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AdvancedRequester
|
||||
type="tv"
|
||||
is4k={is4k}
|
||||
isAnime={data?.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)}
|
||||
onChange={(overrides) => setRequestOverrides(overrides)}
|
||||
requestUser={editRequest?.requestedBy}
|
||||
defaultOverrides={
|
||||
editRequest
|
||||
? {
|
||||
folder: editRequest.rootFolder,
|
||||
profile: editRequest.profileId,
|
||||
server: editRequest.serverId,
|
||||
language: editRequest.languageProfileId,
|
||||
tags: editRequest.tags,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import MovieRequestModal from './MovieRequestModal';
|
||||
import type { MediaStatus } from '../../../server/constants/media';
|
||||
import TvRequestModal from './TvRequestModal';
|
||||
import Transition from '../Transition';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import Transition from '../Transition';
|
||||
import CollectionRequestModal from './CollectionRequestModal';
|
||||
import MovieRequestModal from './MovieRequestModal';
|
||||
import TvRequestModal from './TvRequestModal';
|
||||
|
||||
interface RequestModalProps {
|
||||
show: boolean;
|
||||
type: 'movie' | 'tv';
|
||||
type: 'movie' | 'tv' | 'collection';
|
||||
tmdbId: number;
|
||||
is4k?: boolean;
|
||||
editRequest?: MediaRequest;
|
||||
@@ -26,29 +27,6 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
||||
onUpdating,
|
||||
onCancel,
|
||||
}) => {
|
||||
if (type === 'tv') {
|
||||
return (
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
>
|
||||
<TvRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
is4k={is4k}
|
||||
editRequest={editRequest}
|
||||
/>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300"
|
||||
@@ -59,14 +37,33 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
>
|
||||
<MovieRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
is4k={is4k}
|
||||
editRequest={editRequest}
|
||||
/>
|
||||
{type === 'movie' ? (
|
||||
<MovieRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
is4k={is4k}
|
||||
editRequest={editRequest}
|
||||
/>
|
||||
) : type === 'tv' ? (
|
||||
<TvRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
is4k={is4k}
|
||||
editRequest={editRequest}
|
||||
/>
|
||||
) : (
|
||||
<CollectionRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
is4k={is4k}
|
||||
/>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import useSettings from '../../../hooks/useSettings';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Button from '../../Common/Button';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
@@ -29,6 +30,7 @@ const messages = defineMessages({
|
||||
|
||||
const NotificationsDiscord: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR(
|
||||
@@ -195,7 +197,12 @@ const NotificationsDiscord: React.FC = () => {
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field id="botUsername" name="botUsername" type="text" />
|
||||
<Field
|
||||
id="botUsername"
|
||||
name="botUsername"
|
||||
type="text"
|
||||
placeholder={settings.currentSettings.applicationTitle}
|
||||
/>
|
||||
</div>
|
||||
{errors.botUsername && touched.botUsername && (
|
||||
<div className="error">{errors.botUsername}</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ const NotificationsEmail: React.FC = () => {
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.matches(
|
||||
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
intl.formatMessage(messages.validationSmtpHostRequired)
|
||||
),
|
||||
smtpPort: Yup.number().when('enabled', {
|
||||
|
||||
@@ -51,8 +51,8 @@ const NotificationsTelegram: React.FC = () => {
|
||||
otherwise: Yup.string().nullable(),
|
||||
}),
|
||||
chatId: Yup.string()
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
.when(['enabled', 'types'], {
|
||||
is: (enabled: boolean, types: number) => enabled && !!types,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationChatIdRequired)),
|
||||
|
||||
@@ -18,27 +18,38 @@ const JSONEditor = dynamic(() => import('../../../JSONEditor'), { ssr: false });
|
||||
|
||||
const defaultPayload = {
|
||||
notification_type: '{{notification_type}}',
|
||||
event: '{{event}}',
|
||||
subject: '{{subject}}',
|
||||
message: '{{message}}',
|
||||
image: '{{image}}',
|
||||
email: '{{notifyuser_email}}',
|
||||
username: '{{notifyuser_username}}',
|
||||
avatar: '{{notifyuser_avatar}}',
|
||||
'{{media}}': {
|
||||
media_type: '{{media_type}}',
|
||||
tmdbId: '{{media_tmdbid}}',
|
||||
imdbId: '{{media_imdbid}}',
|
||||
tvdbId: '{{media_tvdbid}}',
|
||||
status: '{{media_status}}',
|
||||
status4k: '{{media_status4k}}',
|
||||
},
|
||||
'{{extra}}': [],
|
||||
'{{request}}': {
|
||||
request_id: '{{request_id}}',
|
||||
requestedBy_email: '{{requestedBy_email}}',
|
||||
requestedBy_username: '{{requestedBy_username}}',
|
||||
requestedBy_avatar: '{{requestedBy_avatar}}',
|
||||
},
|
||||
'{{issue}}': {
|
||||
issue_id: '{{issue_id}}',
|
||||
issue_type: '{{issue_type}}',
|
||||
issue_status: '{{issue_status}}',
|
||||
reportedBy_email: '{{reportedBy_email}}',
|
||||
reportedBy_username: '{{reportedBy_username}}',
|
||||
reportedBy_avatar: '{{reportedBy_avatar}}',
|
||||
},
|
||||
'{{comment}}': {
|
||||
comment_message: '{{comment_message}}',
|
||||
commentedBy_email: '{{commentedBy_email}}',
|
||||
commentedBy_username: '{{commentedBy_username}}',
|
||||
commentedBy_avatar: '{{commentedBy_avatar}}',
|
||||
},
|
||||
'{{extra}}': [],
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
|
||||
@@ -26,7 +26,7 @@ const messages = defineMessages({
|
||||
editradarr: 'Edit Radarr Server',
|
||||
edit4kradarr: 'Edit 4K Radarr Server',
|
||||
validationNameRequired: 'You must provide a server name',
|
||||
validationHostnameRequired: 'You must provide a hostname or IP address',
|
||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||
validationPortRequired: 'You must provide a valid port number',
|
||||
validationApiKeyRequired: 'You must provide an API key',
|
||||
validationRootFolderRequired: 'You must select a root folder',
|
||||
@@ -113,7 +113,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
hostname: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationHostnameRequired))
|
||||
.matches(
|
||||
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
port: Yup.number()
|
||||
|
||||
@@ -321,7 +321,9 @@ const SettingsLogs: React.FC = () => {
|
||||
{row.level.toUpperCase()}
|
||||
</Badge>
|
||||
</Table.TD>
|
||||
<Table.TD className="text-gray-300">{row.label}</Table.TD>
|
||||
<Table.TD className="text-gray-300">
|
||||
{row.label ?? ''}
|
||||
</Table.TD>
|
||||
<Table.TD className="text-gray-300">{row.message}</Table.TD>
|
||||
<Table.TD className="flex flex-wrap items-center justify-end -m-1">
|
||||
{row.data && (
|
||||
|
||||
@@ -112,7 +112,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationHostnameRequired))
|
||||
.matches(
|
||||
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
port: Yup.number()
|
||||
|
||||
@@ -26,7 +26,7 @@ const messages = defineMessages({
|
||||
editsonarr: 'Edit Sonarr Server',
|
||||
edit4ksonarr: 'Edit 4K Sonarr Server',
|
||||
validationNameRequired: 'You must provide a server name',
|
||||
validationHostnameRequired: 'You must provide a hostname or IP address',
|
||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||
validationPortRequired: 'You must provide a valid port number',
|
||||
validationApiKeyRequired: 'You must provide an API key',
|
||||
validationRootFolderRequired: 'You must select a root folder',
|
||||
@@ -124,7 +124,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
hostname: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationHostnameRequired))
|
||||
.matches(
|
||||
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
port: Yup.number()
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { mutate } from 'swr';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import useLocale from '../../hooks/useLocale';
|
||||
import AppDataWarning from '../AppDataWarning';
|
||||
import Badge from '../Common/Badge';
|
||||
@@ -51,18 +51,21 @@ const Setup: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const { data: backdrops } = useSWR<string[]>('/api/v1/backdrops', {
|
||||
refreshInterval: 0,
|
||||
refreshWhenHidden: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col justify-center min-h-screen py-12 bg-gray-900">
|
||||
<PageTitle title={intl.formatMessage(messages.setup)} />
|
||||
<ImageFader
|
||||
backgroundImages={[
|
||||
'/images/rotate1.jpg',
|
||||
'/images/rotate2.jpg',
|
||||
'/images/rotate3.jpg',
|
||||
'/images/rotate4.jpg',
|
||||
'/images/rotate5.jpg',
|
||||
'/images/rotate6.jpg',
|
||||
]}
|
||||
backgroundImages={
|
||||
backdrops?.map(
|
||||
(backdrop) => `https://www.themoviedb.org/t/p/original${backdrop}`
|
||||
) ?? []
|
||||
}
|
||||
/>
|
||||
<div className="absolute z-50 top-4 right-4">
|
||||
<LanguagePicker />
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import {
|
||||
ArrowCircleRightIcon,
|
||||
CogIcon,
|
||||
ExclamationIcon,
|
||||
FilmIcon,
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
DocumentRemoveIcon,
|
||||
ExternalLinkIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import { hasFlag } from 'country-flag-icons';
|
||||
import 'country-flag-icons/3x2/flags.css';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
@@ -17,6 +14,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
|
||||
import { IssueStatus } from '../../../server/constants/issue';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import { Crew } from '../../../server/models/common';
|
||||
import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
|
||||
@@ -33,16 +31,14 @@ import Error from '../../pages/_error';
|
||||
import { sortCrewPriority } from '../../utils/creditHelpers';
|
||||
import Button from '../Common/Button';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import ConfirmButton from '../Common/ConfirmButton';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
import DownloadBlock from '../DownloadBlock';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
import IssueModal from '../IssueModal';
|
||||
import ManageSlideOver from '../ManageSlideOver';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
import PersonCard from '../PersonCard';
|
||||
import RequestBlock from '../RequestBlock';
|
||||
import RequestButton from '../RequestButton';
|
||||
import RequestModal from '../RequestModal';
|
||||
import Slider from '../Slider';
|
||||
@@ -58,29 +54,19 @@ const messages = defineMessages({
|
||||
similar: 'Similar Series',
|
||||
watchtrailer: 'Watch Trailer',
|
||||
overviewunavailable: 'Overview unavailable.',
|
||||
manageModalTitle: 'Manage Series',
|
||||
manageModalRequests: 'Requests',
|
||||
manageModalNoRequests: 'No requests.',
|
||||
manageModalClearMedia: 'Clear Media Data',
|
||||
manageModalClearMediaWarning:
|
||||
'* This will irreversibly remove all data for this series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
|
||||
originaltitle: 'Original Title',
|
||||
showtype: 'Series Type',
|
||||
anime: 'Anime',
|
||||
network: '{networkCount, plural, one {Network} other {Networks}}',
|
||||
viewfullcrew: 'View Full Crew',
|
||||
opensonarr: 'Open Series in Sonarr',
|
||||
opensonarr4k: 'Open Series in 4K Sonarr',
|
||||
downloadstatus: 'Download Status',
|
||||
playonplex: 'Play on Plex',
|
||||
play4konplex: 'Play in 4K on Plex',
|
||||
markavailable: 'Mark as Available',
|
||||
mark4kavailable: 'Mark as Available in 4K',
|
||||
allseasonsmarkedavailable: '* All seasons will be marked as available.',
|
||||
seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}',
|
||||
episodeRuntime: 'Episode Runtime',
|
||||
episodeRuntimeMinutes: '{runtime} minutes',
|
||||
streamingproviders: 'Currently Streaming On',
|
||||
productioncountries:
|
||||
'Production {countryCount, plural, one {Country} other {Countries}}',
|
||||
});
|
||||
|
||||
interface TvDetailsProps {
|
||||
@@ -95,6 +81,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
const { locale } = useLocale();
|
||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<TvDetailsType>(
|
||||
`/api/v1/tv/${router.query.tvId}`,
|
||||
@@ -156,20 +143,6 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data?.mediaInfo?.id) {
|
||||
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const markAvailable = async (is4k = false) => {
|
||||
await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, {
|
||||
is4k,
|
||||
});
|
||||
revalidate();
|
||||
};
|
||||
|
||||
const region = user?.settings?.region
|
||||
? user.settings.region
|
||||
: settings.currentSettings.region
|
||||
@@ -261,6 +234,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
</div>
|
||||
)}
|
||||
<PageTitle title={data.name} />
|
||||
<IssueModal
|
||||
onCancel={() => setShowIssueModal(false)}
|
||||
show={showIssueModal}
|
||||
mediaType="tv"
|
||||
tmdbId={data.id}
|
||||
/>
|
||||
<RequestModal
|
||||
tmdbId={data.id}
|
||||
show={showRequestModal}
|
||||
@@ -271,144 +250,13 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
}}
|
||||
onCancel={() => setShowRequestModal(false)}
|
||||
/>
|
||||
<SlideOver
|
||||
show={showManager}
|
||||
title={intl.formatMessage(messages.manageModalTitle)}
|
||||
<ManageSlideOver
|
||||
data={data}
|
||||
mediaType="tv"
|
||||
onClose={() => setShowManager(false)}
|
||||
subText={data.name}
|
||||
>
|
||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.downloadstatus)}
|
||||
</h3>
|
||||
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
|
||||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled)) && (
|
||||
<div className="mb-6">
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable()}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>{intl.formatMessage(messages.markavailable)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable(true)}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.mark4kavailable)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.allseasonsmarkedavailable)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalRequests)}
|
||||
</h3>
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.requests?.map((request) => (
|
||||
<li
|
||||
key={`manage-request-${request.id}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<RequestBlock request={request} onUpdate={() => revalidate()} />
|
||||
</li>
|
||||
))}
|
||||
{(data.mediaInfo?.requests ?? []).length === 0 && (
|
||||
<li className="py-4 text-center text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalNoRequests)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
|
||||
<div className="mt-8">
|
||||
{data?.mediaInfo?.serviceUrl && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block mb-2 last:mb-0"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.opensonarr)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{data?.mediaInfo?.serviceUrl4k && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.opensonarr4k)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo && (
|
||||
<div className="mt-8">
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<DocumentRemoveIcon />
|
||||
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalClearMediaWarning)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SlideOver>
|
||||
revalidate={() => revalidate()}
|
||||
show={showManager}
|
||||
/>
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
@@ -469,7 +317,9 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
.map((t, k) => <span key={k}>{t}</span>)
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev} | {curr}
|
||||
{prev}
|
||||
<span>|</span>
|
||||
{curr}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
@@ -484,13 +334,52 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
isShowComplete={isComplete}
|
||||
is4kShowComplete={is4kComplete}
|
||||
/>
|
||||
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
(settings.currentSettings.series4kEnabled &&
|
||||
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
(data.mediaInfo?.status4k === MediaStatus.AVAILABLE ||
|
||||
data?.mediaInfo?.status4k ===
|
||||
MediaStatus.PARTIALLY_AVAILABLE))) &&
|
||||
hasPermission(
|
||||
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
) && (
|
||||
<Button
|
||||
buttonType="warning"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowIssueModal(true)}
|
||||
>
|
||||
<ExclamationIcon className="w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2 first:ml-0"
|
||||
className="relative ml-2 first:ml-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<CogIcon />
|
||||
<CogIcon className="!mr-0" />
|
||||
{hasPermission(
|
||||
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
) &&
|
||||
(
|
||||
data.mediaInfo?.issues.filter(
|
||||
(issue) => issue.status === IssueStatus.OPEN
|
||||
) ?? []
|
||||
).length > 0 && (
|
||||
<>
|
||||
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1" />
|
||||
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1 animate-ping" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -648,6 +537,37 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.productionCountries.length > 0 && (
|
||||
<div className="media-fact">
|
||||
<span>
|
||||
{intl.formatMessage(messages.productioncountries, {
|
||||
countryCount: data.productionCountries.length,
|
||||
})}
|
||||
</span>
|
||||
<span className="media-fact-value">
|
||||
{data.productionCountries.map((c) => {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center justify-end"
|
||||
key={`prodcountry-${c.iso_3166_1}`}
|
||||
>
|
||||
{hasFlag(c.iso_3166_1) && (
|
||||
<span
|
||||
className={`mr-1.5 text-xs leading-5 flag:${c.iso_3166_1}`}
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{intl.formatDisplayName(c.iso_3166_1, {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}) ?? c.name}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.networks.length > 0 && (
|
||||
<div className="media-fact">
|
||||
<span>
|
||||
@@ -667,7 +587,10 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
))
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev}, {curr}
|
||||
{intl.formatMessage(globalMessages.delimitedlist, {
|
||||
a: prev,
|
||||
b: curr,
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
|
||||
@@ -46,8 +46,7 @@ const messages = defineMessages({
|
||||
totalrequests: 'Requests',
|
||||
accounttype: 'Type',
|
||||
role: 'Role',
|
||||
created: 'Created',
|
||||
lastupdated: 'Updated',
|
||||
created: 'Joined',
|
||||
bulkedit: 'Bulk Edit',
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
@@ -75,8 +74,7 @@ const messages = defineMessages({
|
||||
autogeneratepassword: 'Automatically Generate Password',
|
||||
autogeneratepasswordTip: 'Email a server-generated password to the user',
|
||||
validationEmail: 'You must provide a valid email address',
|
||||
sortCreated: 'Creation Date',
|
||||
sortUpdated: 'Last Updated',
|
||||
sortCreated: 'Join Date',
|
||||
sortDisplayName: 'Display Name',
|
||||
sortRequests: 'Request Count',
|
||||
localLoginDisabled:
|
||||
@@ -91,7 +89,7 @@ const UserList: React.FC = () => {
|
||||
const settings = useSettings();
|
||||
const { addToast } = useToasts();
|
||||
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
|
||||
const [currentSort, setCurrentSort] = useState<Sort>('created');
|
||||
const [currentSort, setCurrentSort] = useState<Sort>('displayname');
|
||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||
|
||||
const page = router.query.page ? Number(router.query.page) : 1;
|
||||
@@ -522,9 +520,6 @@ const UserList: React.FC = () => {
|
||||
<option value="created">
|
||||
{intl.formatMessage(messages.sortCreated)}
|
||||
</option>
|
||||
<option value="updated">
|
||||
{intl.formatMessage(messages.sortUpdated)}
|
||||
</option>
|
||||
<option value="requests">
|
||||
{intl.formatMessage(messages.sortRequests)}
|
||||
</option>
|
||||
@@ -556,7 +551,6 @@ const UserList: React.FC = () => {
|
||||
<Table.TH>{intl.formatMessage(messages.accounttype)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.role)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.created)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.lastupdated)}</Table.TH>
|
||||
<Table.TH className="text-right">
|
||||
{(data.results ?? []).length > 1 && (
|
||||
<Button
|
||||
@@ -652,13 +646,6 @@ const UserList: React.FC = () => {
|
||||
day: 'numeric',
|
||||
})}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{intl.formatDate(user.updatedAt, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</Table.TD>
|
||||
<Table.TD alignText="right">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user