Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb5a5c309b | |||
| 9dc5684a3e | |||
| 7083e2c90e | |||
| a2266afcdc | |||
| 16a4316854 | |||
| 727c3fecbb | |||
| 03f2a13dba | |||
| 068ff846e6 | |||
| 10923fdf05 | |||
| b1a2b3df8a | |||
| 7a8c433466 | |||
| 13410023e2 | |||
| 265fc4bafe | |||
| 46f5aea674 | |||
| 45e65c234a | |||
| eba923c929 | |||
| b7fe07a1ef | |||
| 19c4bc415e | |||
| 3684c79477 | |||
| b749bffa15 | |||
| b800369165 | |||
| 5ac07c0f6e | |||
| 434d3fca3d | |||
| 57c7fb1dec | |||
| f209709200 | |||
| f5b15854e0 | |||
| 6aec9a938a | |||
| 8180780c87 | |||
| a8b369fdb5 | |||
| 0c6a550871 | |||
| b006fafbdf | |||
| 3c0c014b6a | |||
| c086348a44 | |||
| 6bc18e61ed | |||
| fc714ebfe0 | |||
| 5018805ffe | |||
| c6c48950c4 | |||
| 75baa5468c | |||
| c462aca6ed | |||
| c5d817c68c | |||
| 4a7503bbfd | |||
| c08686a255 | |||
| 6cad057e58 | |||
| d62e911cc2 | |||
| 82b0941cd4 | |||
| 3a5ed7cdc0 | |||
| ae32bb4955 | |||
| 42ca418d55 | |||
| a4fab86472 | |||
| 0c5c9eba6e | |||
| a58d940473 | |||
| c415bf4c52 | |||
| cb59a9e6b1 | |||
| bedcfcc07c | |||
| a95365525a | |||
| f54c8feb2f | |||
| 4dee46a7f7 | |||
| a7e564af1e | |||
| f72fa54931 | |||
| 0089ea416e | |||
| a163e4c9be | |||
| b5028f5405 | |||
| 70ed52cec4 | |||
| 63700a5a1e | |||
| 60323ecd61 | |||
| 49b3c8e96a | |||
| 2d39e29db0 | |||
| 8c39ec881e | |||
| 3ea7001986 | |||
| ec4b9c993f | |||
| 248935c4e7 | |||
| cfbc8def98 | |||
| 6453f7c274 | |||
| 7261f2f454 | |||
| 1e6e22942c | |||
| 3b556ef5e7 | |||
| dc05c7aaeb | |||
| ef0d119907 | |||
| a12ac3c4a0 | |||
| 564788a4c8 | |||
| 06ddda1137 | |||
| 48a969b367 | |||
| 5c08980e27 | |||
| 96575c2c6d | |||
| ea3a5d99f0 | |||
| c47b9a07b8 | |||
| c8743fadb6 | |||
| 42f2cde43f | |||
| ac1ac76891 | |||
| 7abed85a56 | |||
| f20e52bbe6 | |||
| bea8294f26 | |||
| ca11301410 | |||
| b1f48309aa | |||
| d989f13621 | |||
| 85cd946619 | |||
| 40420aa14b | |||
| aec1014acf | |||
| fc49e9986f | |||
| c9e23e1eba | |||
| f992841314 | |||
| afb1fa6dfd | |||
| a58351c539 | |||
| d643d349ac | |||
| 4fdb3e9f9d | |||
| c1be94738b | |||
| ea66615f65 | |||
| 5b38f762ed | |||
| 2aa81f036e | |||
| cbf0120647 | |||
| 2865115b1f | |||
| 5cbc817089 |
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
image: sbondco/watcharr
|
||||
|
||||
jobs:
|
||||
release-dev-image:
|
||||
@@ -27,13 +27,19 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: sbondco
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -41,5 +47,9 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.image }}:dev
|
||||
labels: ${{ env.image }}:${{ github.sha }}
|
||||
tags: |
|
||||
${{ env.image }}:dev
|
||||
ghcr.io/${{ env.image }}:dev
|
||||
labels: |
|
||||
${{ env.image }}:${{ github.sha }}
|
||||
ghcr.io/${{ env.image }}:${{ github.sha }}
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- v*
|
||||
|
||||
env:
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
image: sbondco/watcharr
|
||||
|
||||
jobs:
|
||||
release-images:
|
||||
@@ -26,18 +26,26 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: sbondco
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.image }}
|
||||
images: |
|
||||
${{ env.image }}
|
||||
ghcr.io/${{ env.image }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -46,5 +54,9 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
ghcr.io/${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
ghcr.io/${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Importing",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Guides for importing your data from other services."
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,17 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# MyAnimeList
|
||||
|
||||
:::info Backup?
|
||||
You may consider a backup of your server before starting any import. They are not easily reversible, though we do our best to ensure they are accurate and bug free!
|
||||
:::
|
||||
|
||||
1. Export your list from MyAnimeList at: https://myanimelist.net/panel.php?go=export
|
||||
|
||||

|
||||
|
||||
2. Save the export anywhere and extract it to get your export `.xml` file.
|
||||
|
||||
3. Drag and drop your `.xml` file into Watcharr.
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
description: Install and setup with Docker Compose.
|
||||
---
|
||||
|
||||
# Docker Compose
|
||||
@@ -9,8 +10,6 @@ sidebar_position: 1
|
||||
Installing Watcharr with a docker compose file is easy. You can copy the example below to get started:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
watcharr:
|
||||
# The :latest tag is used for simplicity, it is recommended
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
sidebar_position: 11
|
||||
description: Building from source for developing upon Watcharr.
|
||||
---
|
||||
|
||||
# For Development
|
||||
|
||||
This guide will help you setup Watcharr for development in your local environment.
|
||||
|
||||
## Setup
|
||||
|
||||
This section assumes you will be forking the repo on Github, of course you can alter this section to work with your workflow, but if you intend to create a pull request with your work, it may be difficult if you're not also using Github (I don't know :()).
|
||||
|
||||
1. Fork the repository
|
||||
|
||||
On GitHub you can fork the repository with the 'Fork' button at the top.
|
||||
|
||||

|
||||
|
||||
2. Get the code by cloning your fork
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your username>/Watcharr.git && \
|
||||
cd Watcharr
|
||||
```
|
||||
|
||||
## Setting Up Dependencies
|
||||
|
||||
You only need to do this once after pulling the codebase for the first time, and again every time the dependencies are changed/updated.
|
||||
|
||||
1. Install frontend dependencies (in the project root folder)
|
||||
|
||||
```
|
||||
npm i
|
||||
```
|
||||
|
||||
2. Install server dependencies (in the `server` folder)
|
||||
|
||||
```
|
||||
go get .
|
||||
```
|
||||
|
||||
## Running The Code
|
||||
|
||||
To run the code, you will need to open up two terminals, one for the frontend and the other for the backend.
|
||||
|
||||
1. Run the frontend (first terminal, in the project root folder)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. Run the server (second terminal, in the project root folder)
|
||||
|
||||
```bash
|
||||
npm run server
|
||||
```
|
||||
|
||||
**Note:** If you're using Windows, running the server can be a little more complicated. You can follow this: https://github.com/sbondCo/Watcharr/discussions/430#discussioncomment-8894110 which amounts to these steps (the first 3 steps only need to be done once):
|
||||
|
||||
1. Set CGO_ENABLED environment variable with go
|
||||
|
||||
```
|
||||
go env -w CGO_ENABLED=1
|
||||
```
|
||||
|
||||
2. Install `gcc` (if you don't already have it installed) from https://jmeubank.github.io/tdm-gcc/ (found from this answer on stackoverflow: https://stackoverflow.com/a/43588236).
|
||||
|
||||
3. Install server dependencies again with `go get .` in the `server` folder.
|
||||
|
||||
4. Manually run the server, setting the MODE environment variable in powershell (from the `server` folder)
|
||||
|
||||
```
|
||||
cmd /V /C "set MODE=DEV&& go run ."
|
||||
```
|
||||
|
||||
3. Visit [http://localhost:5173/](http://localhost:5173/) and setup Watcharr.
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
description: Building and installing from source.
|
||||
---
|
||||
|
||||
# From Source
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,14 @@
|
||||
---
|
||||
sidebar_position: 11
|
||||
description: All environment variables you can use to start your server in a specific way.
|
||||
---
|
||||
|
||||
# Environment Variables
|
||||
|
||||
Currently there is only one, and maybe it will stay that way forever (because the admin interface can handle most settings). A very lonely page right now ;(.
|
||||
|
||||
## `WATCHARR_DATA`
|
||||
|
||||
Make Watcharr use a custom directory for all of its data.
|
||||
|
||||
Defaults to `./data`.
|
||||
@@ -1,18 +1,17 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
description: Setting up video game support.
|
||||
---
|
||||
|
||||
# Games Support (via IGDB/Twitch)
|
||||
|
||||
## Overview
|
||||
|
||||
Game support in Watcharr, currently, must be enabled by you.
|
||||
|
||||
This involves setting up a Twitch developer account (phone number needed for 2FA which is required) and entering application credentials into Watcharr, that can then be used for looking up game info via IGDB.
|
||||
|
||||
P.S. These steps are stolen from [igdb api docs](https://api-docs.igdb.com/#account-creation), I've tried to make it easier to follow with screenshots.
|
||||
|
||||
## Steps
|
||||
## Setup Steps
|
||||
|
||||
### Setup On Twitch
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
sidebar_position: 9
|
||||
description: Enabling Jellyfin as an authentication provider.
|
||||
---
|
||||
|
||||
# Jellyfin Authentication
|
||||
|
||||
Watcharr can use your Jellyfin server as an authentication provider. There is just one simple step to set this up.
|
||||
|
||||
:::danger Take care
|
||||
|
||||
It is not recommended that this value is changed once it has been set and users have signed up.
|
||||
|
||||
Changing this setting can cause users to be unable to login to the correct account.
|
||||
|
||||
:::
|
||||
|
||||
## Setup Steps
|
||||
|
||||
1. Login to Watcharr with an Admin account and navigate to server settings.
|
||||
|
||||

|
||||
|
||||
2. Scroll down to find the Jellyfin Host setting and enter the URL to your Jellyfin server.
|
||||
|
||||

|
||||
|
||||
3. Jellyfin authentication has now been enabled! Go back to the login screen and you will be able to login via Jellyfin.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
description: Enabling Plex as an authentication provider.
|
||||
---
|
||||
|
||||
# Plex Authentication
|
||||
|
||||
Watcharr can use Plex as an authentication provider. There is just one simple step to set this up.
|
||||
|
||||
## Setup Steps
|
||||
|
||||
1. Login to Watcharr with an Admin account and navigate to server settings.
|
||||
|
||||

|
||||
|
||||
2. Scroll down and find the Plex Host setting.
|
||||
|
||||

|
||||
|
||||
3. Enter the URL to your home Plex server.
|
||||
You will see your Plex machine identifier popup below the input after it's saved, if it worked.
|
||||
Only users with access to your library will be able to signup via Plex.
|
||||
|
||||

|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
description: Getting admin privileges for your account without already having access to an account with admin.
|
||||
---
|
||||
|
||||
# Restoring Admin Priviliges
|
||||
|
||||
This guide will help you restore/give admin access to any account without needing an account with admin. If you ever accidentally remove your permissions, this is the easiest way to get them back.
|
||||
|
||||
All you will need is access to the `watcharr.log` file.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Login with the account you'd like to give admin to.
|
||||
|
||||
2. Navigate to the `/request_admin` page manually (ex: `127.0.0.1:3080/request_admin`) and click the `request` button.
|
||||
|
||||

|
||||
|
||||
3. Check your `watcharr.log` file (or simply check `docker compose logs` if using docker compose) and look for the generated admin token (highlighted in the image below).
|
||||
|
||||

|
||||
|
||||
4. Enter the code from the log (you should have been taken here after clicking the request button in step 2).
|
||||
|
||||

|
||||
|
||||
5. Click `Check Token`, if it is correct and still valid (they expire after ~2 minutes or once used), your account will now have admin.
|
||||
@@ -6339,9 +6339,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
|
||||
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
|
||||
"version": "1.15.6",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
||||
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3"
|
||||
|
||||
# To test prod
|
||||
services:
|
||||
watcharr:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
watcharr:
|
||||
# The :latest tag is used for simplicity, it is recommended
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.35.2",
|
||||
"version": "1.39.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -13,28 +13,28 @@
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^4.0.1",
|
||||
"@sveltejs/adapter-node": "^5.0.1",
|
||||
"@sveltejs/kit": "^2.5.3",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.6.0",
|
||||
"@vite-pwa/sveltekit": "^0.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.1",
|
||||
"svelte": "^4.2.10",
|
||||
"svelte-check": "^3.6.4",
|
||||
"prettier-plugin-svelte": "^3.2.2",
|
||||
"svelte": "^4.2.12",
|
||||
"svelte-check": "^3.6.9",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"svelte-preprocess": "^5.1.3",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.6"
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.8"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"axios": "^1.6.8",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ type RadarrTestResponse struct {
|
||||
LanguageProfiles []arr.LanguageProfile `json:"languageProfiles"`
|
||||
}
|
||||
|
||||
// Response given to users with PERM_REQUEST_CONTENT - should never include sensitive info
|
||||
func testSonarr(p ArrTestParams) (SonarrTestResponse, error) {
|
||||
sonarr := arr.New(arr.SONARR, &p.Host, &p.Key)
|
||||
qps, err := sonarr.GetQualityProfiles()
|
||||
@@ -76,6 +77,7 @@ func testSonarr(p ArrTestParams) (SonarrTestResponse, error) {
|
||||
return SonarrTestResponse{QualityProfiles: qps, RootFolders: rfs, LanguageProfiles: lps}, nil
|
||||
}
|
||||
|
||||
// Response given to users with PERM_REQUEST_CONTENT - should never include sensitive info
|
||||
func testRadarr(p ArrTestParams) (RadarrTestResponse, error) {
|
||||
radarr := arr.New(arr.RADARR, &p.Host, &p.Key)
|
||||
qps, err := radarr.GetQualityProfiles()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type ArrType string
|
||||
@@ -37,6 +38,7 @@ type ArrRequest struct {
|
||||
AutomaticSearch bool `json:"automaticSearch"`
|
||||
Title string `json:"title"` // content name
|
||||
Year int `json:"year"` // content year
|
||||
TMDBID int `json:"tmdbId"`
|
||||
}
|
||||
|
||||
type SonarrRequest struct {
|
||||
@@ -49,7 +51,6 @@ type SonarrRequest struct {
|
||||
|
||||
type RadarrRequest struct {
|
||||
ArrRequest
|
||||
TMDBID int `json:"tmdbId"`
|
||||
}
|
||||
|
||||
type SonarrSeasons struct {
|
||||
@@ -66,9 +67,9 @@ func New(t ArrType, host *string, key *string) *Arr {
|
||||
}
|
||||
|
||||
func (a *Arr) GetQualityProfiles() ([]QualityProfile, error) {
|
||||
slog.Info("GetQualityProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
slog.Debug("GetQualityProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp []QualityProfile
|
||||
err := request(*a.Host, "/qualityprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
_, err := request(*a.Host, "/qualityprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetQualityProfiles request failed", "service", a.Type, "error", err)
|
||||
return []QualityProfile{}, errors.New("request to service failed")
|
||||
@@ -77,9 +78,9 @@ func (a *Arr) GetQualityProfiles() ([]QualityProfile, error) {
|
||||
}
|
||||
|
||||
func (a *Arr) GetRootFolders() ([]RootFolder, error) {
|
||||
slog.Info("GetRootFolders", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
slog.Debug("GetRootFolders", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp []RootFolder
|
||||
err := request(*a.Host, "/rootfolder", map[string]string{"apikey": *a.Key}, &resp)
|
||||
_, err := request(*a.Host, "/rootfolder", map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetRootFolders request failed", "service", a.Type, "error", err)
|
||||
return []RootFolder{}, errors.New("request to service failed")
|
||||
@@ -88,10 +89,11 @@ func (a *Arr) GetRootFolders() ([]RootFolder, error) {
|
||||
}
|
||||
|
||||
func (a *Arr) GetLangaugeProfiles() ([]LanguageProfile, error) {
|
||||
slog.Info("GetLangaugeProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
slog.Debug("GetLangaugeProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp []LanguageProfile
|
||||
// languageprofile supposedly deprecated.. but new language endpoint doesnt seem to work.. note probs to switch soon
|
||||
err := request(*a.Host, "/languageprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
// TODO languages are handled diffferently in Sonarr now, I think we can remove all language stuff, now controlled per profile.
|
||||
_, err := request(*a.Host, "/languageprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetLangaugeProfiles request failed", "service", a.Type, "error", err)
|
||||
return []LanguageProfile{}, errors.New("request to service failed")
|
||||
@@ -99,7 +101,69 @@ func (a *Arr) GetLangaugeProfiles() ([]LanguageProfile, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TODO do title! and year?
|
||||
func (a *Arr) RunCommand(name string) (CommandResponse, error) {
|
||||
slog.Debug("RunCommand", "name", name, "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp CommandResponse
|
||||
err := requestPost(*a.Host, "/command", *a.Key, map[string]interface{}{"name": name}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("RunCommand request failed", "name", name, "service", a.Type, "error", err)
|
||||
return CommandResponse{}, errors.New("request to service failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// arrId = movieId/seriesId on radarr/sonarr
|
||||
func (a *Arr) GetQueueDetails(arrId string, resp interface{}) error {
|
||||
slog.Debug("GetQueueDetails", "arrId", arrId, "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
p := map[string]string{"apikey": *a.Key}
|
||||
if a.Type == RADARR {
|
||||
p["movieId"] = arrId
|
||||
} else if a.Type == SONARR {
|
||||
p["seriesId"] = arrId
|
||||
} else {
|
||||
return errors.New("invalid arr type")
|
||||
}
|
||||
_, err := request(*a.Host, "/queue/details", p, resp)
|
||||
if err != nil {
|
||||
slog.Error("GetQueueDetails request failed", "arrId", arrId, "service", a.Type, "error", err)
|
||||
return errors.New("request to service failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get movie/show
|
||||
func (a *Arr) GetContent(arrId int) (MovieSerie, int, error) {
|
||||
slog.Debug("GetContent", "arrId", arrId, "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
e := "movie"
|
||||
if a.Type == SONARR {
|
||||
e = "series"
|
||||
}
|
||||
arrIdStr := strconv.Itoa(arrId)
|
||||
var resp MovieSerie
|
||||
respStatusCode, err := request(*a.Host, "/"+e+"/"+arrIdStr, map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetContent request failed", "arrId", arrId, "service", a.Type, "error", err)
|
||||
return MovieSerie{}, respStatusCode, errors.New("request to service failed")
|
||||
}
|
||||
return resp, respStatusCode, nil
|
||||
}
|
||||
|
||||
func (a *Arr) LookupByTmdbId(tmdbId int) ([]MovieSerie, error) {
|
||||
slog.Debug("LookupByTmdbId", "tmdbId", tmdbId, "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
e := "movie"
|
||||
if a.Type == SONARR {
|
||||
e = "series"
|
||||
}
|
||||
var resp []MovieSerie
|
||||
tmdbIdStr := strconv.Itoa(tmdbId)
|
||||
_, err := request(*a.Host, "/"+e+"/lookup", map[string]string{"apikey": *a.Key, "term": "tmdb:" + tmdbIdStr}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("LookupByTmdbId request failed", "tmdbId", tmdbId, "service", a.Type, "error", err)
|
||||
return []MovieSerie{}, errors.New("request to service failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *Arr) BuildAddShowBody(r SonarrRequest) map[string]interface{} {
|
||||
req := map[string]interface{}{
|
||||
"title": r.Title,
|
||||
@@ -135,26 +199,27 @@ func (a *Arr) BuildAddMovieBody(r RadarrRequest) map[string]interface{} {
|
||||
return req
|
||||
}
|
||||
|
||||
func (a *Arr) AddContent(b map[string]interface{}) error {
|
||||
func (a *Arr) AddContent(b map[string]interface{}) (map[string]interface{}, error) {
|
||||
ep := "series"
|
||||
if a.Type == RADARR {
|
||||
ep = "movie"
|
||||
}
|
||||
slog.Debug("AddContent", "type", ep, "body", b)
|
||||
var resp interface{}
|
||||
var resp map[string]interface{}
|
||||
err := requestPost(*a.Host, "/"+ep, *a.Key, b, &resp)
|
||||
if err != nil {
|
||||
slog.Error("AddContent request failed", "service", a.Type, "error", err)
|
||||
return errors.New("request to service failed")
|
||||
return map[string]interface{}{}, errors.New("request to service failed")
|
||||
}
|
||||
return nil
|
||||
slog.Debug("AddContent", "type", ep, "created_id", resp["id"])
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func request(host string, ep string, p map[string]string, resp interface{}) error {
|
||||
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
|
||||
func request(host string, ep string, p map[string]string, resp interface{}) (int, error) {
|
||||
slog.Debug("arrAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
return 0, errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
@@ -172,23 +237,26 @@ func request(host string, ep string, p map[string]string, resp interface{}) erro
|
||||
// Run get request
|
||||
res, err := http.Get(base.String())
|
||||
if err != nil {
|
||||
return err
|
||||
if res != nil {
|
||||
return res.StatusCode, err
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
return res.StatusCode, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
slog.Error("arr non 200 status code:", "status_code", res.StatusCode)
|
||||
return errors.New(string(body))
|
||||
return res.StatusCode, errors.New(string(body))
|
||||
}
|
||||
// slog.Info("", "body", body)
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
return res.StatusCode, err
|
||||
}
|
||||
return nil
|
||||
return res.StatusCode, nil
|
||||
}
|
||||
|
||||
func requestPost(host string, ep string, key string, p map[string]interface{}, resp interface{}) error {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package arr
|
||||
|
||||
import "time"
|
||||
|
||||
type QualityProfile struct {
|
||||
Name string `json:"name"`
|
||||
UpgradeAllowed bool `json:"upgradeAllowed"`
|
||||
@@ -43,3 +45,99 @@ type LanguageProfile struct {
|
||||
} `json:"languages"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type ArrCommandRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type CommandResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CommandName string `json:"commandName"`
|
||||
Message string `json:"message"`
|
||||
Body struct {
|
||||
SendUpdatesToClient bool `json:"sendUpdatesToClient"`
|
||||
LastExecutionTime time.Time `json:"lastExecutionTime"`
|
||||
LastStartTime time.Time `json:"lastStartTime"`
|
||||
Trigger string `json:"trigger"`
|
||||
SuppressMessages bool `json:"suppressMessages"`
|
||||
ClientUserAgent string `json:"clientUserAgent"`
|
||||
} `json:"body"`
|
||||
Priority string `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
Result string `json:"result"`
|
||||
Queued time.Time `json:"queued"`
|
||||
Started time.Time `json:"started"`
|
||||
Ended time.Time `json:"ended"`
|
||||
Duration struct {
|
||||
Ticks int `json:"ticks"`
|
||||
} `json:"duration"`
|
||||
Exception string `json:"exception"`
|
||||
Trigger string `json:"trigger"`
|
||||
ClientUserAgent string `json:"clientUserAgent"`
|
||||
StateChangeTime time.Time `json:"stateChangeTime"`
|
||||
SendUpdatesToClient bool `json:"sendUpdatesToClient"`
|
||||
UpdateScheduledTask bool `json:"updateScheduledTask"`
|
||||
LastExecutionTime time.Time `json:"lastExecutionTime"`
|
||||
}
|
||||
|
||||
type QueueDetail struct {
|
||||
Languages []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"languages"`
|
||||
Quality struct {
|
||||
Quality struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Resolution int `json:"resolution"`
|
||||
Modifier string `json:"modifier"`
|
||||
} `json:"quality"`
|
||||
Revision struct {
|
||||
Version int `json:"version"`
|
||||
Real int `json:"real"`
|
||||
IsRepack bool `json:"isRepack"`
|
||||
} `json:"revision"`
|
||||
} `json:"quality"`
|
||||
Size float64 `json:"size"`
|
||||
Title string `json:"title"`
|
||||
SizeLeft float64 `json:"sizeleft"`
|
||||
TimeLeft string `json:"timeleft"`
|
||||
EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"`
|
||||
Added time.Time `json:"added"`
|
||||
Status string `json:"status"`
|
||||
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
|
||||
TrackedDownloadState string `json:"trackedDownloadState"`
|
||||
StatusMessages []interface{} `json:"statusMessages"`
|
||||
DownloadID string `json:"downloadId"`
|
||||
Protocol string `json:"protocol"`
|
||||
DownloadClient string `json:"downloadClient"`
|
||||
DownloadClientHasPostImportCategory bool `json:"downloadClientHasPostImportCategory"`
|
||||
Indexer string `json:"indexer"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type RadarrQueueDetails []struct {
|
||||
QueueDetail
|
||||
MovieID int `json:"movieId"`
|
||||
}
|
||||
|
||||
type SonarrQueueDetails []struct {
|
||||
QueueDetail
|
||||
SeriesID int `json:"seriesId"`
|
||||
EpisodeID int `json:"episodeId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeHasFile bool `json:"episodeHasFile"`
|
||||
}
|
||||
|
||||
// From `GET /movie/{id}` or `GET /series/{id}`.
|
||||
// Not all fields described here, just the wanted ones.
|
||||
type MovieSerie struct {
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"originalTitle"`
|
||||
ID int `json:"id"`
|
||||
HasFile bool `json:"hasFile"`
|
||||
IsAvailable bool `json:"isAvailable"`
|
||||
Added time.Time `json:"added"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
)
|
||||
|
||||
type ArrDetailsResponse struct {
|
||||
Progress int `json:"progress"`
|
||||
EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"`
|
||||
Status string `json:"status"`
|
||||
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
|
||||
TrackedDownloadState string `json:"trackedDownloadState"`
|
||||
}
|
||||
|
||||
type SonarrDetailsResponseItem struct {
|
||||
ArrDetailsResponse
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type SonarrDetailsResponse struct {
|
||||
// Overall progress
|
||||
Progress int `json:"progress"`
|
||||
// Overall status
|
||||
Status string `json:"status"`
|
||||
// Each downloading episode
|
||||
Items []SonarrDetailsResponseItem `json:"items"`
|
||||
}
|
||||
|
||||
func getRadarrQueueDetails(serverName string, arrId string) (*ArrDetailsResponse, error) {
|
||||
server, err := getRadarr(serverName)
|
||||
if err != nil {
|
||||
slog.Error("getRadarrQueueDetails: Failed to get server", "error", err)
|
||||
return &ArrDetailsResponse{}, errors.New("failed to get server")
|
||||
}
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
// Run refresh downloads, likely won't be refreshed in time before we run GetQueueDetails below,
|
||||
// but if the user calls this again, it should be, which is better than waiting a whole minute for
|
||||
// refresh task to run automatically. This exists until a better solution is thought of.
|
||||
_, err = radarr.RunCommand("RefreshMonitoredDownloads")
|
||||
if err != nil {
|
||||
slog.Error("getRadarrQueueDetails: Failed to refresh monitored downloads.", "error", err)
|
||||
}
|
||||
resp := arr.RadarrQueueDetails{}
|
||||
err = radarr.GetQueueDetails(arrId, &resp)
|
||||
if err != nil {
|
||||
slog.Error("getRadarrQueueDetails: Failed to add content", "error", err)
|
||||
return &ArrDetailsResponse{}, errors.New("failed to add content")
|
||||
}
|
||||
if len(resp) <= 0 {
|
||||
slog.Error("getRadarrQueueDetails: No results in response")
|
||||
return &ArrDetailsResponse{}, errors.New("no details found")
|
||||
}
|
||||
adr := ArrDetailsResponse{}
|
||||
adr.Progress = int(math.Round((1 - (resp[0].SizeLeft / resp[0].Size)) * 100))
|
||||
adr.EstimatedCompletionTime = resp[0].EstimatedCompletionTime
|
||||
adr.TrackedDownloadStatus = resp[0].TrackedDownloadStatus
|
||||
adr.TrackedDownloadState = resp[0].TrackedDownloadState
|
||||
adr.Status = resp[0].Status
|
||||
return &adr, nil
|
||||
}
|
||||
|
||||
func getSonarrQueueDetails(serverName string, arrId string) (*SonarrDetailsResponse, error) {
|
||||
server, err := getSonarr(serverName)
|
||||
if err != nil {
|
||||
slog.Error("getSonarrQueueDetails: Failed to get server", "error", err)
|
||||
return &SonarrDetailsResponse{}, errors.New("failed to get server")
|
||||
}
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
// Run refresh downloads, likely won't be refreshed in time before we run GetQueueDetails below,
|
||||
// but if the user calls this again, it should be, which is better than waiting a whole minute for
|
||||
// refresh task to run automatically. This exists until a better solution is thought of.
|
||||
_, err = sonarr.RunCommand("RefreshMonitoredDownloads")
|
||||
if err != nil {
|
||||
slog.Error("getSonarrQueueDetails: Failed to refresh monitored downloads.", "error", err)
|
||||
}
|
||||
resp := arr.SonarrQueueDetails{}
|
||||
err = sonarr.GetQueueDetails(arrId, &resp)
|
||||
if err != nil {
|
||||
slog.Error("getSonarrQueueDetails: Failed to add content", "error", err)
|
||||
return &SonarrDetailsResponse{}, errors.New("failed to add content")
|
||||
}
|
||||
if len(resp) <= 0 {
|
||||
slog.Error("getSonarrQueueDetails: No results in response")
|
||||
return &SonarrDetailsResponse{}, errors.New("no details found")
|
||||
}
|
||||
dr := SonarrDetailsResponse{}
|
||||
totalSize := 0.0
|
||||
totalSizeLeft := 0.0
|
||||
for _, v := range resp {
|
||||
dr.Items = append(dr.Items, SonarrDetailsResponseItem{
|
||||
Title: "TODO :(", // TODO: Will need to do another request to get all episodes so we can get real episode names from EpisodeID
|
||||
ArrDetailsResponse: ArrDetailsResponse{
|
||||
Progress: int(math.Round((1 - (v.SizeLeft / v.Size)) * 100)),
|
||||
EstimatedCompletionTime: v.EstimatedCompletionTime,
|
||||
TrackedDownloadStatus: v.TrackedDownloadStatus,
|
||||
TrackedDownloadState: v.TrackedDownloadState,
|
||||
Status: v.Status,
|
||||
},
|
||||
})
|
||||
totalSize += v.Size
|
||||
totalSizeLeft += v.SizeLeft
|
||||
}
|
||||
dr.Progress = int(math.Round((1 - (totalSizeLeft / totalSize)) * 100))
|
||||
// HACK: Statuses are not great, no easy way to get a good status, since there are multiple items to consider.
|
||||
// At some point, we could add extra checks (ex. if all items (episodes) are paused, overall status can be set to paused).
|
||||
// For now, this is good enough.
|
||||
dr.Status = "requested"
|
||||
if dr.Progress > 0 {
|
||||
dr.Status = "downloading"
|
||||
}
|
||||
return &dr, nil
|
||||
}
|
||||
|
||||
// Refresh download queues for our sonarr/radarr servers.
|
||||
// If the queues don't refresh regularly, our queue detail
|
||||
// calls will just always return the same info.
|
||||
func refreshArrQueues() {
|
||||
slog.Debug("refreshArrQueues: Refreshing queues for all configured arr servers.")
|
||||
// We don't care about responses, errors will be logged by the RunCommand func.
|
||||
for _, v := range Config.RADARR {
|
||||
radarr := arr.New(arr.RADARR, &v.Host, &v.Key)
|
||||
radarr.RunCommand("RefreshMonitoredDownloads")
|
||||
}
|
||||
for _, v := range Config.SONARR {
|
||||
sonarr := arr.New(arr.SONARR, &v.Host, &v.Key)
|
||||
sonarr.RunCommand("RefreshMonitoredDownloads")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ArrRequestStatus string
|
||||
|
||||
const (
|
||||
// Pending approval from an admin.
|
||||
ARR_REQUEST_PENDING ArrRequestStatus = "PENDING"
|
||||
// Request has been approved and should be added to sonarr/radarr.
|
||||
ARR_REQUEST_APPROVED ArrRequestStatus = "APPROVED"
|
||||
ARR_REQUEST_AUTO_APPROVED ArrRequestStatus = "AUTO_APPROVED"
|
||||
// Request has been denied, not adding content.
|
||||
ARR_REQUEST_DENIED ArrRequestStatus = "DENIED"
|
||||
// Content was found on sonarr/radarr already, nothing needs to be done.
|
||||
ARR_REQUEST_FOUND ArrRequestStatus = "FOUND"
|
||||
)
|
||||
|
||||
type ArrRequest struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
// Username of `User`.
|
||||
// We don't want to send back the entire user object, just their name.
|
||||
// Not stored in DB, only used for our response from api.
|
||||
Username string `json:"username" gorm:"-"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:sn_to_cid;not null"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
// Server names are used as an identifier
|
||||
ServerName string `json:"serverName" gorm:"uniqueIndex:sn_to_cid;not null"`
|
||||
// Sonarr/Radarrs seriesId/movieId
|
||||
ArrID int `json:"arrId"`
|
||||
// Tracked request status
|
||||
Status ArrRequestStatus `json:"status" gorm:"default:PENDING"`
|
||||
// Full request made by user (arr.SonarrRequest / arr.RadarrRequest)
|
||||
// so we know how to fulfil the request if approved.
|
||||
RequestJson string `json:"requestJson"`
|
||||
}
|
||||
|
||||
func deleteArrRequest(db *gorm.DB, id uint) error {
|
||||
resp := db.Delete(&ArrRequest{ID: id})
|
||||
if resp.Error != nil {
|
||||
slog.Error("deleteArrRequest: Failed to remove from db", "error", resp.Error)
|
||||
return errors.New("failed when removing request")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gets all requests.
|
||||
func getArrRequests(db *gorm.DB) ([]ArrRequest, error) {
|
||||
var req []ArrRequest
|
||||
resp := db.Preload("Content").Preload("User").Find(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getArrRequests: Failed to search for requests in db", "error", resp.Error)
|
||||
return []ArrRequest{}, errors.New("failed to find requests")
|
||||
}
|
||||
for i := range req {
|
||||
req[i].Username = req[i].User.Username
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func getArrRequest(db *gorm.DB, requestId uint) (ArrRequest, error) {
|
||||
var req ArrRequest
|
||||
resp := db.Where("id = ?", requestId).Take(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getArrRequest: Failed to search for request in db", "error", resp.Error)
|
||||
return ArrRequest{}, errors.New("failed to find request")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func getArrRequestByTmdbId(db *gorm.DB, contentType ContentType, tmdbId int) (ArrRequest, error) {
|
||||
var req ArrRequest
|
||||
resp := db.Joins("JOIN contents ON contents.id = arr_requests.content_id AND contents.tmdb_id = ? AND contents.type = ?", tmdbId, contentType).Find(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getArrRequestByTmdbId: Failed to search for request in db", "error", resp.Error)
|
||||
return ArrRequest{}, errors.New("failed to find request")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func createArrRequest(db *gorm.DB, userId uint, serverName string, contentType ContentType, tmdbId int, reqJson string) (*ArrRequest, error) {
|
||||
content, err := getOrCacheContent(db, contentType, tmdbId)
|
||||
if err != nil {
|
||||
slog.Error("createArrRequest: getOrCacheContent errored.")
|
||||
return &ArrRequest{}, err
|
||||
}
|
||||
req := ArrRequest{UserID: userId, ServerName: serverName, ContentID: &content.ID, RequestJson: reqJson}
|
||||
resp := db.Create(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("createArrRequest: Failed when inserting request into db.", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when adding request")
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func createSonarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.SonarrRequest) (*ArrRequest, error) {
|
||||
server, err := getSonarr(ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("createSonarrRequest: Failed to get server", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to get server")
|
||||
}
|
||||
reqJson, err := json.Marshal(ur)
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed when marshalling json request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when processing request")
|
||||
}
|
||||
// Since we create the request in the db now, we don't have to check for duplicates, a unique constraint will error us here if hit.
|
||||
arrReq, err := createArrRequest(db, userId, ur.ServerName, SHOW, ur.TMDBID, string(reqJson[:]))
|
||||
if err != nil {
|
||||
slog.Error("createSonarrRequest: Failed when creating arr request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when creating request")
|
||||
}
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
// 1. Lookup on Sonarr to check if the show has already been added (via method other than watcharr).
|
||||
lookupRes, err := sonarr.LookupByTmdbId(ur.TMDBID)
|
||||
if err == nil && len(lookupRes) == 1 {
|
||||
slog.Debug("createSonarrRequest: Lookup returned results.")
|
||||
found := lookupRes[0] // There should only be one result when looking up by id.
|
||||
// If it has an ID, then it will have already been added to Sonarr.
|
||||
if found.ID != 0 {
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", found.ID).Update("status", ARR_REQUEST_FOUND)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createSonarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
} else {
|
||||
slog.Debug("createSonarrRequest: Result from lookup had an ID. Request in database has been updated with it.", "arr_id", found.ID)
|
||||
arrReq.ArrID = found.ID
|
||||
return arrReq, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. If user has auto approve perms, add movie to sonarr.
|
||||
if hasPermission(userPerms, PERM_REQUEST_CONTENT_AUTO_APPROVE) {
|
||||
slog.Debug("createSonarrRequest: User has auto approve permission.. sending request to Sonarr.")
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := sonarr.AddContent(sonarr.BuildAddShowBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("createSonarrRequest: Failed to add content", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_AUTO_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createSonarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("createSonarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return &ArrRequest{}, errors.New("failed to get arr id")
|
||||
}
|
||||
arrReq.ArrID = int(arrId)
|
||||
arrReq.Status = ARR_REQUEST_AUTO_APPROVED
|
||||
}
|
||||
return arrReq, nil
|
||||
}
|
||||
|
||||
func createRadarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.RadarrRequest) (*ArrRequest, error) {
|
||||
server, err := getRadarr(ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed to get server", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to get server")
|
||||
}
|
||||
reqJson, err := json.Marshal(ur)
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed when marshalling json request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when processing request")
|
||||
}
|
||||
// Since we create the request in the db now, we don't have to check for duplicates, a unique constraint will error us here if hit.
|
||||
arrReq, err := createArrRequest(db, userId, ur.ServerName, MOVIE, ur.TMDBID, string(reqJson[:]))
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed when creating arr request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when creating request")
|
||||
}
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
// 1. Lookup on Radarr to check if the movie has already been added (via method other than watcharr).
|
||||
lookupRes, err := radarr.LookupByTmdbId(ur.TMDBID)
|
||||
if err == nil && len(lookupRes) == 1 {
|
||||
slog.Debug("createRadarrRequest: Lookup returned results.")
|
||||
found := lookupRes[0] // There should only be one result when looking up by id.
|
||||
// If it has an ID, then it will have already been added to Radarr.
|
||||
if found.ID != 0 {
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", found.ID).Update("status", ARR_REQUEST_FOUND)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createRadarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
} else {
|
||||
slog.Debug("createRadarrRequest: Result from lookup had an ID. Request in database has been updated with it.", "arr_id", found.ID)
|
||||
arrReq.ArrID = found.ID
|
||||
return arrReq, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. If user has auto approve perms, add movie to radarr.
|
||||
if hasPermission(userPerms, PERM_REQUEST_CONTENT_AUTO_APPROVE) {
|
||||
slog.Debug("createRadarrRequest: User has auto approve permission.. sending request to Radarr.")
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := radarr.AddContent(radarr.BuildAddMovieBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed to add content", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_AUTO_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createRadarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("createRadarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return &ArrRequest{}, errors.New("failed to get arr id")
|
||||
}
|
||||
arrReq.ArrID = int(arrId)
|
||||
arrReq.Status = ARR_REQUEST_AUTO_APPROVED
|
||||
}
|
||||
return arrReq, nil
|
||||
}
|
||||
|
||||
func getRadarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
if requestId == 0 {
|
||||
slog.Error("sonarr info: No request id provided")
|
||||
return arr.MovieSerie{}, errors.New("no request id provided")
|
||||
}
|
||||
arrRequest, err := getArrRequest(db, requestId)
|
||||
if err != nil {
|
||||
slog.Error("radarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
server, err := getRadarr(arrRequest.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("radarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
resp, respStatusCode, err := radarr.GetContent(arrRequest.ArrID)
|
||||
if err != nil {
|
||||
slog.Error("radarr info: Failed to get info", "error", err)
|
||||
if (arrRequest.Status == ARR_REQUEST_APPROVED || arrRequest.Status == ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
|
||||
slog.Error("radarr info: 404 returned.. content must've been removed.. removing request.")
|
||||
err := deleteArrRequest(db, arrRequest.ID)
|
||||
if err != nil {
|
||||
return arr.MovieSerie{}, errors.New("failed to delete request for removed content")
|
||||
} else {
|
||||
return arr.MovieSerie{}, errors.New("request deleted")
|
||||
}
|
||||
}
|
||||
return arr.MovieSerie{}, errors.New("failed to get info")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func getSonarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
if requestId == 0 {
|
||||
slog.Error("sonarr info: No request id provided")
|
||||
return arr.MovieSerie{}, errors.New("no request id provided")
|
||||
}
|
||||
arrRequest, err := getArrRequest(db, requestId)
|
||||
if err != nil {
|
||||
slog.Error("sonarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
server, err := getSonarr(arrRequest.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("sonarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
resp, respStatusCode, err := sonarr.GetContent(arrRequest.ArrID)
|
||||
if err != nil {
|
||||
slog.Error("sonarr info: Failed to get info", "error", err)
|
||||
if (arrRequest.Status == ARR_REQUEST_APPROVED || arrRequest.Status == ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
|
||||
slog.Error("sonarr info: 404 returned.. content must've been removed.. removing request.")
|
||||
err := deleteArrRequest(db, arrRequest.ID)
|
||||
if err != nil {
|
||||
return arr.MovieSerie{}, errors.New("failed to delete request for removed content")
|
||||
} else {
|
||||
return arr.MovieSerie{}, errors.New("request deleted")
|
||||
}
|
||||
}
|
||||
return arr.MovieSerie{}, errors.New("failed to get info")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Deny an arr request
|
||||
func denyArrRequest(db *gorm.DB, id uint) error {
|
||||
resp := db.Model(&ArrRequest{}).Where("id = ?", id).Update("status", ARR_REQUEST_DENIED)
|
||||
if resp.Error != nil {
|
||||
slog.Error("denyArrRequest: Failed to update status to denied", "error", resp.Error)
|
||||
return errors.New("failed when updating request status")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Approve radarr movie
|
||||
func approveRadarrRequest(db *gorm.DB, reqId uint, ur arr.RadarrRequest) (int, error) {
|
||||
_, err := getArrRequest(db, reqId)
|
||||
if err != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to get request from db", "error", err)
|
||||
return 0, errors.New("failed to get request")
|
||||
}
|
||||
// Get server in request
|
||||
server, err := getRadarr(ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to get server", "error", err)
|
||||
return 0, errors.New("failed to get server")
|
||||
}
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := radarr.AddContent(radarr.BuildAddMovieBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to add content", "error", err)
|
||||
return 0, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", reqId).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to update request in db", "error", err)
|
||||
return 0, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("approveRadarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return 0, errors.New("content added, but failed to get arr id")
|
||||
}
|
||||
return int(arrId), nil
|
||||
}
|
||||
|
||||
// Approve sonarr movie
|
||||
func approveSonarrRequest(db *gorm.DB, reqId uint, ur arr.SonarrRequest) (int, error) {
|
||||
_, err := getArrRequest(db, reqId)
|
||||
if err != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to get request from db", "error", err)
|
||||
return 0, errors.New("failed to get request")
|
||||
}
|
||||
// Get server in request
|
||||
server, err := getSonarr(ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to get server", "error", err)
|
||||
return 0, errors.New("failed to get server")
|
||||
}
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := sonarr.AddContent(sonarr.BuildAddShowBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to add content", "error", err)
|
||||
return 0, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", reqId).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to update request in db", "error", err)
|
||||
return 0, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("approveSonarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return 0, errors.New("failed to get arr id")
|
||||
}
|
||||
return int(arrId), nil
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
PERM_NONE int = 1 << iota
|
||||
PERM_ADMIN
|
||||
PERM_REQUEST_CONTENT
|
||||
PERM_REQUEST_CONTENT_AUTO_APPROVE
|
||||
)
|
||||
|
||||
// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
|
||||
@@ -88,6 +89,8 @@ type UserSettings struct {
|
||||
// even if the watched item state has since been changed.
|
||||
// Also if user wants to show in watched stats.
|
||||
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
|
||||
// User's country to get correct content streaming providers.
|
||||
Country *string `gorm:"default:'US'" json:"country"`
|
||||
}
|
||||
|
||||
// Holds third party service auth tokens for users.
|
||||
@@ -147,6 +150,7 @@ type AvailableAuthProvidersResponse struct {
|
||||
AvailableAuthProviders []string `json:"available"`
|
||||
SignupEnabled bool `json:"signupEnabled"`
|
||||
IsInSetup bool `json:"isInSetup"`
|
||||
UseEmby bool `json:"useEmby"`
|
||||
}
|
||||
|
||||
type ArgonParams struct {
|
||||
@@ -254,6 +258,21 @@ func AdminRequired() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Specific perm only middleware (use after AuthRequired with extra info!)
|
||||
func PermRequired(perm int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.GetUint("userId")
|
||||
perms := c.GetInt("userPermissions")
|
||||
if hasPermission(perms, perm) {
|
||||
slog.Debug("PermRequired: User has permission to access perm only route", "user_id", userId, "required_perm", perm)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
slog.Info("PermRequired: User denied permission to access perm only route", "user_id", userId, "required_perm", perm)
|
||||
c.AbortWithStatus(401)
|
||||
}
|
||||
}
|
||||
|
||||
func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthResponse, error) {
|
||||
if !Config.SIGNUP_ENABLED {
|
||||
slog.Warn("Register called, but signing up is disabled.")
|
||||
@@ -275,6 +294,8 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
|
||||
user.Permissions = initialPerm
|
||||
}
|
||||
|
||||
user.Country = &Config.DEFAULT_COUNTRY
|
||||
|
||||
res := db.Create(&user)
|
||||
if res.Error != nil {
|
||||
// If error is because unique contraint failed.. user already exists
|
||||
@@ -406,6 +427,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
dbUser.ThirdPartyAuth = resp.AccessToken
|
||||
dbUser.Username = resp.User.Name
|
||||
dbUser.Type = JELLYFIN_USER
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
@@ -447,28 +469,28 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
slog.Error("loginPlex: Username or id missing from account response:", "username", account.Username, "id", account.Id)
|
||||
return AuthResponse{}, errors.New("data is missing from the plex account response")
|
||||
}
|
||||
// Get users auth token against our home plex server.
|
||||
// If no auth token, assume they don't have access to our plex server.
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil || homeAuthToken == "" {
|
||||
slog.Error("loginPlex: Failed to get home server auth token for user! If not because the request failed, then ensure the user has access to our home servers library.", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to verify plex access")
|
||||
}
|
||||
dbUser := new(User)
|
||||
userIdQ := db.Select("user_id").Where("name = ? AND client_id = ?", "plex", account.Id).Table("user_services")
|
||||
dbRes := db.Where("type = ?", PLEX_USER).Where("id = (?)", userIdQ).Preload("UserServices").Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
slog.Debug("loginPlex: New plex user attempted login.. creating Watcharr account now.")
|
||||
if err := plexUserHasAccessToPlexHost(lr.AuthToken); err != nil {
|
||||
slog.Error("loginPlex: Cannot register Plex user. Failed to verify they have access to our home plex server.", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to verify plex access")
|
||||
}
|
||||
dbUser.Username = account.Username
|
||||
dbUser.Type = PLEX_USER
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to get home server auth token for the new user! User will still be created, a re-login may fix this issue.", "error", err)
|
||||
}
|
||||
dbUser.UserServices = append(dbUser.UserServices, UserServices{
|
||||
Name: "plex",
|
||||
ClientID: strconv.FormatUint(account.Id, 10),
|
||||
AuthToken: lr.AuthToken,
|
||||
AuthToken2: homeAuthToken,
|
||||
})
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
slog.Error("loginPlex: Failed to create new user in db from plex response", "error", dbRes.Error)
|
||||
@@ -480,10 +502,6 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
}
|
||||
} else {
|
||||
// If user exists.. update their access tokens in db
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to get home server auth token!", "error", err)
|
||||
}
|
||||
for i, v := range dbUser.UserServices {
|
||||
if v.Name == "plex" {
|
||||
slog.Info("loginPlex: Found plex user service.. attemping to update")
|
||||
|
||||
@@ -6,23 +6,37 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DataPath = "./data"
|
||||
var DataPath = func() string {
|
||||
path := os.Getenv("WATCHARR_DATA")
|
||||
if path == "" {
|
||||
path = "./data"
|
||||
}
|
||||
return path
|
||||
}()
|
||||
|
||||
type ServerConfig struct {
|
||||
// Used to sign JWT tokens. Make sure to make
|
||||
// it strong, just like a very long, complicated password.
|
||||
JWT_SECRET string `json:",omitempty"`
|
||||
|
||||
// Default country for new users. This is used to set the default
|
||||
// region to get correct content streaming providers.
|
||||
DEFAULT_COUNTRY string `json:",omitempty"`
|
||||
|
||||
// Optional: Point to your Jellyfin install
|
||||
// to enable it as an auth provider.
|
||||
JELLYFIN_HOST string `json:",omitempty"`
|
||||
|
||||
// Optional: Use Emby instead of Jellyfin branding in the ui.
|
||||
USE_EMBY bool
|
||||
|
||||
// Enable/disable signup functionality.
|
||||
// Set to `false` to disable registering an account.
|
||||
SIGNUP_ENABLED bool
|
||||
@@ -60,7 +74,9 @@ type ServerConfig struct {
|
||||
func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
return ServerConfig{
|
||||
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
|
||||
DEFAULT_COUNTRY: c.DEFAULT_COUNTRY,
|
||||
JELLYFIN_HOST: c.JELLYFIN_HOST,
|
||||
USE_EMBY: c.USE_EMBY,
|
||||
TMDB_KEY: c.TMDB_KEY,
|
||||
PLEX_HOST: c.PLEX_HOST,
|
||||
PLEX_MACHINE_ID: c.PLEX_MACHINE_ID,
|
||||
@@ -82,7 +98,7 @@ var (
|
||||
// Read config file
|
||||
// Calls generateConfig if file doesn't exist
|
||||
func readConfig() error {
|
||||
cfg, err := os.Open("./data/watcharr.json")
|
||||
cfg, err := os.Open(path.Join(DataPath, "watcharr.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
slog.Info("Config file doesn't exist... generating.")
|
||||
@@ -119,14 +135,15 @@ func generateConfig() error {
|
||||
cfg := ServerConfig{
|
||||
JWT_SECRET: key,
|
||||
// Other defaults..
|
||||
SIGNUP_ENABLED: true,
|
||||
DEFAULT_COUNTRY: "US",
|
||||
SIGNUP_ENABLED: true,
|
||||
}
|
||||
barej, err := json.MarshalIndent(cfg, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Config = cfg
|
||||
return os.WriteFile("./data/watcharr.json", barej, 0755)
|
||||
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
|
||||
}
|
||||
|
||||
// Update server config property
|
||||
@@ -137,6 +154,8 @@ func updateConfig(k string, v any) error {
|
||||
}
|
||||
if k == "JELLYFIN_HOST" {
|
||||
Config.JELLYFIN_HOST = v.(string)
|
||||
} else if k == "USE_EMBY" {
|
||||
Config.USE_EMBY = v.(bool)
|
||||
} else if k == "SIGNUP_ENABLED" {
|
||||
Config.SIGNUP_ENABLED = v.(bool)
|
||||
} else if k == "TMDB_KEY" {
|
||||
@@ -144,6 +163,8 @@ func updateConfig(k string, v any) error {
|
||||
} else if k == "DEBUG" {
|
||||
Config.DEBUG = v.(bool)
|
||||
setLoggingLevel()
|
||||
} else if k == "DEFAULT_COUNTRY" {
|
||||
Config.DEFAULT_COUNTRY = v.(string)
|
||||
} else {
|
||||
return errors.New("invalid setting")
|
||||
}
|
||||
@@ -156,7 +177,7 @@ func writeConfig() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile("./data/watcharr.json", barej, 0755)
|
||||
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
|
||||
}
|
||||
|
||||
type ServerFeatures struct {
|
||||
@@ -173,16 +194,13 @@ func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
|
||||
f.Games = true
|
||||
}
|
||||
// https://github.com/sbondCo/Watcharr/issues/211
|
||||
// Currently requesting permissions have not been setup, only admins for now.
|
||||
if !hasPermission(userPerms, PERM_ADMIN) {
|
||||
return f
|
||||
}
|
||||
if len(Config.SONARR) > 0 {
|
||||
f.Sonarr = true
|
||||
}
|
||||
if len(Config.RADARR) > 0 {
|
||||
f.Radarr = true
|
||||
if hasPermission(userPerms, PERM_REQUEST_CONTENT) {
|
||||
if len(Config.SONARR) > 0 {
|
||||
f.Sonarr = true
|
||||
}
|
||||
if len(Config.RADARR) > 0 {
|
||||
f.Radarr = true
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -85,7 +87,7 @@ func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
slog.Debug("saveContent: Downloading poster.")
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join("./data/img", c.PosterPath))
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join(DataPath, "img", c.PosterPath))
|
||||
if err != nil {
|
||||
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
@@ -170,6 +172,50 @@ func cacheContentMovie(db *gorm.DB, content TMDBMovieDetails, onlyUpdate bool) (
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Get content from our cache, or cache it if it doesn't exist.
|
||||
func getOrCacheContent(db *gorm.DB, contentType ContentType, tmdbId int) (Content, error) {
|
||||
var content Content
|
||||
// Look in db for content.
|
||||
db.Where("type = ? AND tmdb_id = ?", contentType, tmdbId).Find(&content)
|
||||
// Create content if not found from our db.
|
||||
if content == (Content{}) {
|
||||
slog.Debug("Content not in db, fetching...", "type", contentType, "tmdbId", tmdbId)
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(contentType)+"/"+strconv.Itoa(tmdbId), map[string]string{})
|
||||
if err != nil {
|
||||
slog.Error("getOrCacheContent: content tmdb api request failed", "error", err)
|
||||
return Content{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
if contentType == "movie" {
|
||||
c := new(TMDBMovieDetails)
|
||||
err := json.Unmarshal([]byte(resp), &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return Content{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
content, err = cacheContentMovie(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("getOrCacheContent: failed to cache movie content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
} else {
|
||||
c := new(TMDBShowDetails)
|
||||
err := json.Unmarshal(resp, &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal tv details", "error", err)
|
||||
return Content{}, errors.New("failed to process tv details response")
|
||||
}
|
||||
content, err = cacheContentTv(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("getOrCacheContent: failed to cache tv content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
@@ -331,3 +377,13 @@ func upcomingTv() (TMDBUpcomingShows, error) {
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func regions() (TMDBRegions, error) {
|
||||
resp := new(TMDBRegions)
|
||||
err := tmdbRequest("/watch/providers/regions", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete regions request!", "error", err.Error())
|
||||
return TMDBRegions{}, errors.New("failed to complete regions request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, a
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Take(&watched)
|
||||
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND game_id = ?", userId, watched.GameID).Take(&watched)
|
||||
if res.Error != nil {
|
||||
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, a
|
||||
return Watched{}, errors.New("content already on watched list")
|
||||
} else {
|
||||
slog.Info("addPlayed: Watched list item for this content exists as soft deleted record.. attempting to restore")
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND game_id = ?", userId, watched.GameID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
if res.Error != nil {
|
||||
|
||||
@@ -5,19 +5,19 @@ go 1.21
|
||||
require (
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
github.com/gin-contrib/cache v1.2.0
|
||||
github.com/gin-contrib/cors v1.7.0
|
||||
github.com/gin-contrib/cors v1.7.1
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/crypto v0.22.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.5
|
||||
gorm.io/gorm v1.25.7
|
||||
gorm.io/gorm v1.25.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/bytedance/sonic v1.11.2 // indirect
|
||||
github.com/bytedance/sonic v1.11.3 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
@@ -39,13 +39,13 @@ require (
|
||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -4,8 +4,8 @@ github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZ
|
||||
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/bytedance/sonic v1.11.3 h1:jRN+yEjakWh8aK5FzrciUHG8OFXK+4/KrAX/ysEtHAA=
|
||||
github.com/bytedance/sonic v1.11.3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
@@ -21,8 +21,8 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cache v1.2.0 h1:WA+AJR4kmHDTaLLShCHo/IeWVmmGRZ3Lsr3JQ46tFlE=
|
||||
github.com/gin-contrib/cache v1.2.0/go.mod h1:2KkFL8PSnPF3Tt5E2Jpc3HWuBAUKqGZnClCFMm0tXQI=
|
||||
github.com/gin-contrib/cors v1.7.0 h1:wZX2wuZ0o7rV2/1i7gb4Jn+gW7HBqaP91fizJkBUJOA=
|
||||
github.com/gin-contrib/cors v1.7.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
|
||||
github.com/gin-contrib/cors v1.7.1 h1:s9SIppU/rk8enVvkzwiC2VK3UZ/0NNGsWfUKvV55rqs=
|
||||
github.com/gin-contrib/cors v1.7.1/go.mod h1:n/Zj7B4xyrgk/cX1WCX2dkzFfaNm/xJb6oIUk7WTtps=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
@@ -73,8 +73,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
@@ -84,13 +84,15 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
@@ -98,14 +100,14 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
@@ -120,7 +122,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
|
||||
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -27,14 +27,17 @@ var (
|
||||
)
|
||||
|
||||
type ImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating int8 `json:"rating"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
DatesWatched []time.Time `json:"datesWatched"`
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating int8 `json:"rating"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
DatesWatched []time.Time `json:"datesWatched"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
WatchedSeason []WatchedSeason `json:"watchedSeasons"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
@@ -170,5 +173,59 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add all activity passed in.
|
||||
// Probably was is a Watcharr export being imported, so it'll have all it's activity too.
|
||||
if len(ar.Activity) > 0 {
|
||||
slog.Debug("successfulImport: Importing activity")
|
||||
for i, v := range ar.Activity {
|
||||
activityDate := ar.Activity[i].CustomDate
|
||||
if activityDate == nil || activityDate.IsZero() {
|
||||
activityDate = &ar.Activity[i].CreatedAt
|
||||
}
|
||||
addedActivity, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: v.Type, Data: v.Data, CustomDate: activityDate})
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
slog.Error("successfulImport: Failed to add imported activity.", "full_object", v, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Import watched seasons, if any
|
||||
if len(ar.WatchedSeason) > 0 {
|
||||
slog.Debug("successfulImport: Importing watched seasons")
|
||||
for _, v := range ar.WatchedSeason {
|
||||
ws, err := addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: v.SeasonNumber,
|
||||
Status: v.Status,
|
||||
Rating: v.Rating,
|
||||
addActivityDate: v.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add watched season.", "error", err)
|
||||
continue
|
||||
}
|
||||
w.WatchedSeasons = ws.WatchedSeasons
|
||||
}
|
||||
}
|
||||
// Import watched episodes, if any
|
||||
if len(ar.WatchedEpisodes) > 0 {
|
||||
slog.Debug("successfulImport: Importing watched episodes")
|
||||
for _, v := range ar.WatchedEpisodes {
|
||||
ws, err := addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: v.SeasonNumber,
|
||||
EpisodeNumber: v.EpisodeNumber,
|
||||
Status: v.Status,
|
||||
Rating: v.Rating,
|
||||
addActivityDate: v.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add watched episodes.", "error", err)
|
||||
continue
|
||||
}
|
||||
w.WatchedEpisodes = ws.WatchedEpisodes
|
||||
}
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@ import (
|
||||
|
||||
// Location middleware
|
||||
func WhereaboutsRequired() gin.HandlerFunc {
|
||||
// TODO: This should also take into account a user
|
||||
// preference (of location.. which also needs to be
|
||||
// added) and server preference (env var). The US is
|
||||
// good enough for now as a default.
|
||||
return func(c *gin.Context) {
|
||||
slog.Debug("WhereaboutsRequired middleware hit", "country", "US")
|
||||
c.Set("userCountry", "US")
|
||||
region := c.Query("region")
|
||||
slog.Debug("WhereaboutsRequired: middleware hit", "region", region)
|
||||
if region == "" {
|
||||
// If no region is passed, default to server region.
|
||||
if Config.DEFAULT_COUNTRY != "" {
|
||||
slog.Debug("WhereaboutsRequired: Using server default country.", "default_country", Config.DEFAULT_COUNTRY)
|
||||
c.Set("userCountry", Config.DEFAULT_COUNTRY)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// If no server region set, default to US.
|
||||
slog.Debug("WhereaboutsRequired: Using hard coded default (US).")
|
||||
c.Set("userCountry", "US")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Set("userCountry", region)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -39,50 +38,6 @@ type PlexHostConfigUpdateResponse struct {
|
||||
PLEX_MACHINE_ID string
|
||||
}
|
||||
|
||||
type PlexUsersResponse struct {
|
||||
XMLName xml.Name `xml:"MediaContainer"`
|
||||
Text string `xml:",chardata"`
|
||||
FriendlyName string `xml:"friendlyName,attr"`
|
||||
Identifier string `xml:"identifier,attr"`
|
||||
MachineIdentifier string `xml:"machineIdentifier,attr"`
|
||||
TotalSize string `xml:"totalSize,attr"`
|
||||
Size string `xml:"size,attr"`
|
||||
User []struct {
|
||||
Text string `xml:",chardata"`
|
||||
ID string `xml:"id,attr"`
|
||||
Title string `xml:"title,attr"`
|
||||
Username string `xml:"username,attr"`
|
||||
Email string `xml:"email,attr"`
|
||||
RecommendationsPlaylistId string `xml:"recommendationsPlaylistId,attr"`
|
||||
Thumb string `xml:"thumb,attr"`
|
||||
Protected string `xml:"protected,attr"`
|
||||
Home string `xml:"home,attr"`
|
||||
AllowTuners string `xml:"allowTuners,attr"`
|
||||
AllowSync string `xml:"allowSync,attr"`
|
||||
AllowCameraUpload string `xml:"allowCameraUpload,attr"`
|
||||
AllowChannels string `xml:"allowChannels,attr"`
|
||||
AllowSubtitleAdmin string `xml:"allowSubtitleAdmin,attr"`
|
||||
FilterAll string `xml:"filterAll,attr"`
|
||||
FilterMovies string `xml:"filterMovies,attr"`
|
||||
FilterMusic string `xml:"filterMusic,attr"`
|
||||
FilterPhotos string `xml:"filterPhotos,attr"`
|
||||
FilterTelevision string `xml:"filterTelevision,attr"`
|
||||
Restricted string `xml:"restricted,attr"`
|
||||
Server []struct {
|
||||
Text string `xml:",chardata"`
|
||||
ID string `xml:"id,attr"`
|
||||
ServerId string `xml:"serverId,attr"`
|
||||
MachineIdentifier string `xml:"machineIdentifier,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
LastSeenAt string `xml:"lastSeenAt,attr"`
|
||||
NumLibraries string `xml:"numLibraries,attr"`
|
||||
AllLibraries string `xml:"allLibraries,attr"`
|
||||
Owned string `xml:"owned,attr"`
|
||||
Pending string `xml:"pending,attr"`
|
||||
} `xml:"Server"`
|
||||
} `xml:"User"`
|
||||
}
|
||||
|
||||
// Plex get libraries response
|
||||
// /library/sections
|
||||
type PlexLibrariesResponse struct {
|
||||
@@ -454,7 +409,6 @@ func fetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
slog.Info(string(body))
|
||||
defer resp.Body.Close()
|
||||
var pa PlexAccountResponse
|
||||
err = json.Unmarshal(body, &pa)
|
||||
@@ -488,54 +442,6 @@ func updateConfigPlexHost(v string) (PlexHostConfigUpdateResponse, error) {
|
||||
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: Config.PLEX_MACHINE_ID}, nil
|
||||
}
|
||||
|
||||
// If a plex user has access to our home plex server (PLEX_HOST).
|
||||
func plexUserHasAccessToPlexHost(token string) error {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/api/users", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Plex-Token", token)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pa PlexUsersResponse
|
||||
err = xml.Unmarshal(body, &pa)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(pa.User) <= 0 {
|
||||
return errors.New("found no users in response")
|
||||
}
|
||||
|
||||
// Now check if any of the users servers include our home server machine id
|
||||
homeServerFound := false
|
||||
userLoop:
|
||||
for _, user := range pa.User {
|
||||
for _, server := range user.Server {
|
||||
if server.MachineIdentifier == Config.PLEX_MACHINE_ID {
|
||||
slog.Debug("plexUserHasAccessToPlexHost: Processing a server.", "server", server.MachineIdentifier)
|
||||
homeServerFound = true
|
||||
break userLoop
|
||||
}
|
||||
}
|
||||
if homeServerFound {
|
||||
break
|
||||
}
|
||||
}
|
||||
if homeServerFound {
|
||||
return nil
|
||||
}
|
||||
return errors.New("user does not have access to home plex server")
|
||||
}
|
||||
|
||||
func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections", nil)
|
||||
@@ -638,6 +544,7 @@ func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryI
|
||||
|
||||
// Gets users auth token for local plex server,
|
||||
// so they can authenticate against it for api requests.
|
||||
// If no auth token is returned or errored, assume user doesn't have access to home plex server library.
|
||||
func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
|
||||
@@ -666,11 +573,14 @@ func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, e
|
||||
if v.ClientIdentifier == Config.PLEX_MACHINE_ID {
|
||||
slog.Debug("getPlexHomeServerAuthToken: Found entry with clientIdentifier matching home server machine id.")
|
||||
if v.AccessToken == "" {
|
||||
slog.Warn("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
|
||||
slog.Error("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
|
||||
continue
|
||||
}
|
||||
authToken = v.AccessToken
|
||||
}
|
||||
}
|
||||
if authToken == "" {
|
||||
slog.Error("getPlexHomeServerAuthToken: No authToken retrieved!")
|
||||
}
|
||||
return authToken, nil
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get movie details (for movie page)
|
||||
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/movie/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -122,7 +122,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get tv details (for tv page)
|
||||
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/tv/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -240,6 +240,16 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Available regions for watch providers
|
||||
content.GET("/regions", func(c *gin.Context) {
|
||||
r, err := regions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addGameRoutes() {
|
||||
@@ -388,12 +398,8 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
|
||||
watched.DELETE(":id", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
if err == nil {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := removeWatched(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
@@ -616,6 +622,7 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
AvailableAuthProviders: availableAuthProviders,
|
||||
SignupEnabled: Config.SIGNUP_ENABLED,
|
||||
IsInSetup: ServerInSetup,
|
||||
UseEmby: Config.USE_EMBY,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -961,6 +968,38 @@ func (b *BaseRouter) addServerRoutes() {
|
||||
server.GET("/stats", cache.CachePage(b.ms, time.Minute*5, func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, getServerStats(b.db))
|
||||
}))
|
||||
|
||||
// Get all server users (for manage users page)
|
||||
server.GET("/users", func(c *gin.Context) {
|
||||
resp, err := getAllUsers(b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// Edit a user (for manage users page)
|
||||
server.POST("/users/:id", func(c *gin.Context) {
|
||||
userId, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("/users/:id failed to parse id as a uint", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to parse id"})
|
||||
return
|
||||
}
|
||||
var ur UpdateUserRequest
|
||||
err = c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := manageUser(b.db, uint(userId), ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addFeatureRoutes() {
|
||||
@@ -973,10 +1012,12 @@ func (b *BaseRouter) addFeatureRoutes() {
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addSonarrRoutes() {
|
||||
s := b.rg.Group("/arr/son").Use(AuthRequired(b.db), AdminRequired())
|
||||
s := b.rg.Group("/arr/son").Use(AuthRequired(b.db))
|
||||
|
||||
// Routes are manually given AdminRequired or PermRequired middleware.
|
||||
|
||||
// Test configuration
|
||||
s.POST("/test", func(c *gin.Context) {
|
||||
s.POST("/test", AdminRequired(), func(c *gin.Context) {
|
||||
var ur ArrTestParams
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -992,7 +1033,7 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Used to get config for specific server (quality profile, root folder, etc)
|
||||
s.GET("/config/:name", func(c *gin.Context) {
|
||||
s.GET("/config/:name", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
server, err := getSonarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -1007,7 +1048,7 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Add sonarr server into config
|
||||
s.POST("/add", func(c *gin.Context) {
|
||||
s.POST("/add", AdminRequired(), func(c *gin.Context) {
|
||||
var ur SonarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -1023,7 +1064,7 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Edit sonarr servers config
|
||||
s.POST("/edit", func(c *gin.Context) {
|
||||
s.POST("/edit", AdminRequired(), func(c *gin.Context) {
|
||||
var ur SonarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -1039,7 +1080,7 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Remove sonarr server
|
||||
s.POST("/rm/:name", func(c *gin.Context) {
|
||||
s.POST("/rm/:name", AdminRequired(), func(c *gin.Context) {
|
||||
err := rmSonarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -1049,40 +1090,105 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Get safe config for all sonarr servers
|
||||
s.GET("", func(c *gin.Context) {
|
||||
s.GET("", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
response := getSonarrsSafe()
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Request a show
|
||||
s.POST("/request", func(c *gin.Context) {
|
||||
s.POST("/request", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
var ur arr.SonarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
server, err := getSonarr(ur.ServerName)
|
||||
userId := c.MustGet("userId").(uint)
|
||||
perms := c.GetInt("userPermissions")
|
||||
response, err := createSonarrRequest(b.db, userId, perms, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
err = sonarr.AddContent(sonarr.BuildAddShowBody(ur))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.GET("/request/:tmdbId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
tmdbId, err := strconv.Atoi(c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse tmdbId", "tmdbId", tmdbId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getArrRequestByTmdbId(b.db, SHOW, tmdbId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.POST("/request/approve/:id", PermRequired(PERM_ADMIN), func(c *gin.Context) {
|
||||
var ur arr.SonarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
requestId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse request id", "request_id", requestId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := approveSonarrRequest(b.db, uint(requestId), ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.GET("/status/:serverName/:arrId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
response, err := getSonarrQueueDetails(c.Param("serverName"), c.Param("arrId"))
|
||||
if err != nil {
|
||||
if err.Error() == "no details found" {
|
||||
c.Status(http.StatusNoContent) // Item not found in queue.. missing
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.GET("/info/:requestId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
requestId, err := strconv.ParseUint(c.Param("requestId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("/info/:requestId - requestId could not be parsed", "requestId", requestId)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
response, err := getSonarrRequestInfo(b.db, uint(requestId))
|
||||
if err != nil {
|
||||
if err.Error() == "request deleted" {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "request deleted"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addRadarrRoutes() {
|
||||
s := b.rg.Group("/arr/rad").Use(AuthRequired(b.db), AdminRequired())
|
||||
s := b.rg.Group("/arr/rad").Use(AuthRequired(b.db))
|
||||
|
||||
// Routes are manually given AdminRequired or PermRequired middleware.
|
||||
|
||||
// Test configuration
|
||||
s.POST("/test", func(c *gin.Context) {
|
||||
s.POST("/test", AdminRequired(), func(c *gin.Context) {
|
||||
var ur ArrTestParams
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -1098,7 +1204,7 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
})
|
||||
|
||||
// Get config for specific server
|
||||
s.GET("/config/:name", func(c *gin.Context) {
|
||||
s.GET("/config/:name", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
server, err := getRadarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -1112,7 +1218,7 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
s.POST("/add", func(c *gin.Context) {
|
||||
s.POST("/add", AdminRequired(), func(c *gin.Context) {
|
||||
var ur RadarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -1127,7 +1233,7 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.POST("/edit", func(c *gin.Context) {
|
||||
s.POST("/edit", AdminRequired(), func(c *gin.Context) {
|
||||
var ur RadarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -1142,7 +1248,7 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.POST("/rm/:name", func(c *gin.Context) {
|
||||
s.POST("/rm/:name", AdminRequired(), func(c *gin.Context) {
|
||||
err := rmRadarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -1151,40 +1257,136 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
s.GET("", func(c *gin.Context) {
|
||||
s.GET("", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
response := getRadarrsSafe()
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.POST("/request", func(c *gin.Context) {
|
||||
s.POST("/request", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
var ur arr.RadarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
server, err := getRadarr(ur.ServerName)
|
||||
userId := c.MustGet("userId").(uint)
|
||||
perms := c.GetInt("userPermissions")
|
||||
response, err := createRadarrRequest(b.db, userId, perms, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
err = radarr.AddContent(radarr.BuildAddMovieBody(ur))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.GET("/request/:tmdbId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
tmdbId, err := strconv.Atoi(c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse tmdbId", "tmdbId", tmdbId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getArrRequestByTmdbId(b.db, MOVIE, tmdbId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.POST("/request/approve/:id", PermRequired(PERM_ADMIN), func(c *gin.Context) {
|
||||
var ur arr.RadarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
requestId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse request id", "request_id", requestId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := approveRadarrRequest(b.db, uint(requestId), ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.GET("/status/:serverName/:arrId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
response, err := getRadarrQueueDetails(c.Param("serverName"), c.Param("arrId"))
|
||||
if err != nil {
|
||||
if err.Error() == "no details found" {
|
||||
c.Status(http.StatusNoContent) // Item not found in queue.. missing
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.GET("/info/:requestId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
requestId, err := strconv.ParseUint(c.Param("requestId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("/info/:requestId - requestId could not be parsed", "requestId", requestId)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
response, err := getRadarrRequestInfo(b.db, uint(requestId))
|
||||
if err != nil {
|
||||
if err.Error() == "request deleted" {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "request deleted"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addArrRequestRoutes() {
|
||||
s := b.rg.Group("/arr/request").Use(AuthRequired(b.db))
|
||||
|
||||
// Get all requests (for manage_requests view), only for admins.
|
||||
s.GET("/", AdminRequired(), func(c *gin.Context) {
|
||||
response, err := getArrRequests(b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Deny a request (for manage_requests view), only for admins.
|
||||
s.POST("/deny/:id", AdminRequired(), func(c *gin.Context) {
|
||||
requestId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse request id", "request_id", requestId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
err = denyArrRequest(b.db, uint(requestId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addJobRoutes() {
|
||||
job := b.rg.Group("/job").Use(AuthRequired(nil))
|
||||
|
||||
job.GET("/:id", func(c *gin.Context) {
|
||||
// Uses wildcard so it still works in cases where the job id includes a /.
|
||||
// (yes i changed this instead of not allowing a / when we generate a job id becuz easier)
|
||||
job.GET("/*id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getJob(c.Param("id"), userId)
|
||||
// When we get id param, don't include first letter, which will be the beginning '/'.
|
||||
response, err := getJob(c.Param("id")[1:], userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
|
||||
@@ -21,6 +21,7 @@ func setupMinutelyTasks(db *gorm.DB) {
|
||||
// Runs funcs that are in the place where we are cleaning.
|
||||
// Bit cleaner and we can keep the related code close to its home.
|
||||
cleanupTokens(db)
|
||||
refreshArrQueues()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -491,6 +491,14 @@ type TMDBKeywords struct {
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
type TMDBRegions struct {
|
||||
Results []struct {
|
||||
ISO3166_1 string `json:"iso_3166_1"`
|
||||
English_Name string `json:"english_name"`
|
||||
Native_Name string `json:"native_name"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func getTMDBKey() string {
|
||||
if Config.TMDB_KEY != "" {
|
||||
return Config.TMDB_KEY
|
||||
|
||||
@@ -58,12 +58,16 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
|
||||
if ur.IncludePreviouslyWatched != nil {
|
||||
user.IncludePreviouslyWatched = ur.IncludePreviouslyWatched
|
||||
}
|
||||
if ur.Country != nil {
|
||||
user.Country = ur.Country
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
Country: user.Country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -80,6 +84,7 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
Country: user.Country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User details wanted for management views.
|
||||
type ManagedUser struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
Private bool `json:"private"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Permissions *int `json:"permissions"`
|
||||
}
|
||||
|
||||
func getAllUsers(db *gorm.DB) ([]ManagedUser, error) {
|
||||
users := []ManagedUser{}
|
||||
if res := db.Model(&User{}).Find(&users); res.Error != nil {
|
||||
slog.Error("getAllUsers: Failed to fetch users from database", "error", res.Error)
|
||||
return []ManagedUser{}, errors.New("failed to fetch users from database")
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Update a user. For management views, for admin to update another user.
|
||||
func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
|
||||
// Error now if no userId or any UpdateUserRequest property was provided.
|
||||
if userId == 0 || (ur.Permissions == nil) {
|
||||
slog.Error("manageUser: invalid arguments", "user_id", userId)
|
||||
return errors.New("invalid arguments, ensure a valid userId and at least one property has been provided for updating")
|
||||
}
|
||||
toUpdate := map[string]interface{}{}
|
||||
if ur.Permissions != nil {
|
||||
if *ur.Permissions == 0 {
|
||||
// If removing all perms, set to default of 1 (PERM_NONE).
|
||||
// Will avoid confusion and possibly bugs later on, though I doubt
|
||||
// we'd ever be (directly) checking a user to ensure they have no perms.
|
||||
toUpdate["permissions"] = PERM_NONE
|
||||
} else {
|
||||
toUpdate["permissions"] = *ur.Permissions
|
||||
}
|
||||
}
|
||||
if res := db.Model(&User{}).Where("id = ?", userId).Updates(toUpdate); res.Error != nil {
|
||||
slog.Error("manageUser: failed to update user in database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to update user in database")
|
||||
}
|
||||
slog.Debug("manageUser: A user has been updated", "user_id", userId)
|
||||
return nil
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func main() {
|
||||
setLoggingLevel()
|
||||
|
||||
// Ensure data dir exists
|
||||
err = ensureDirExists("./data")
|
||||
err = ensureDirExists(DataPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create data dir:", err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func main() {
|
||||
isProd = false
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{TranslateError: true})
|
||||
db, err := gorm.Open(sqlite.Open(path.Join(DataPath, "watcharr.db")), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
@@ -81,6 +81,7 @@ func main() {
|
||||
&Follow{},
|
||||
&Image{},
|
||||
&Game{},
|
||||
&ArrRequest{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
@@ -140,6 +141,7 @@ func main() {
|
||||
br.addFeatureRoutes()
|
||||
br.addSonarrRoutes()
|
||||
br.addRadarrRoutes()
|
||||
br.addArrRequestRoutes()
|
||||
br.addJobRoutes()
|
||||
br.rg.Static("/img", path.Join(DataPath, "img"))
|
||||
|
||||
@@ -152,7 +154,7 @@ func main() {
|
||||
func setupLogging() io.Writer {
|
||||
// logLevel = new(slog.LevelVar)
|
||||
multiw := io.MultiWriter(&lumberjack.Logger{
|
||||
Filename: "./data/watcharr.log",
|
||||
Filename: path.Join(DataPath, "watcharr.log"),
|
||||
MaxSize: 1, // megabytes
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28, // days
|
||||
|
||||
@@ -30,6 +30,7 @@ type Watched struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Pinned bool `json:"pinned" gorm:"default:false;not null"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
@@ -52,10 +53,11 @@ type WatchedAddRequest struct {
|
||||
}
|
||||
|
||||
type WatchedUpdateRequest struct {
|
||||
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts"`
|
||||
Rating int8 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts"`
|
||||
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts"`
|
||||
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts Pinned"`
|
||||
Rating int8 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
|
||||
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts Pinned"`
|
||||
RemoveThoughts bool `json:"removeThoughts"`
|
||||
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
|
||||
}
|
||||
|
||||
type WatchedUpdateResponse struct {
|
||||
@@ -85,16 +87,16 @@ func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, err
|
||||
// if this is a thing we need but its here.. for now at least.
|
||||
res := db.Where("id = ? AND username = ?", userId, username).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to get user for getPublicWatched request")
|
||||
slog.Error("Failed to get user for getPublicWatched request", "user_id", userId)
|
||||
return []Watched{}, errors.New("failed to check privacy settings")
|
||||
}
|
||||
if user.Private != nil && *user.Private {
|
||||
slog.Error("getPublicWatched attempted to get a private list")
|
||||
slog.Error("getPublicWatched attempted to get a private list", "user_id", userId)
|
||||
return []Watched{}, errors.New("this watched list is private")
|
||||
}
|
||||
// Now we know the user is public, return their list
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Where("user_id = ?", userId).Find(&watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
@@ -103,45 +105,10 @@ func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, err
|
||||
|
||||
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType) (Watched, error) {
|
||||
slog.Debug("Adding watched item", "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
|
||||
var content Content
|
||||
db.Where("type = ? AND tmdb_id = ?", ar.ContentType, ar.ContentID).Find(&content)
|
||||
|
||||
// Create content if not found from our db
|
||||
if content == (Content{}) {
|
||||
slog.Debug("Content not in db, fetching...")
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(ar.ContentType)+"/"+strconv.Itoa(ar.ContentID), map[string]string{})
|
||||
if err != nil {
|
||||
slog.Error("addWatched content tmdb api request failed", "error", err)
|
||||
return Watched{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
if ar.ContentType == "movie" {
|
||||
c := new(TMDBMovieDetails)
|
||||
err := json.Unmarshal([]byte(resp), &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return Watched{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
content, err = cacheContentMovie(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("addWatched failed to cache movie content", "content_id", ar.ContentID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
} else {
|
||||
c := new(TMDBShowDetails)
|
||||
err := json.Unmarshal(resp, &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal tv details", "error", err)
|
||||
return Watched{}, errors.New("failed to process tv details response")
|
||||
}
|
||||
content, err = cacheContentTv(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("addWatched failed to cache tv content", "content_id", ar.ContentID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
// Get content cache (or cache it if we don't have it locally)
|
||||
content, err := getOrCacheContent(db, ar.ContentType, ar.ContentID)
|
||||
if err != nil {
|
||||
return Watched{}, err
|
||||
}
|
||||
// Error if content has no id
|
||||
if content.ID == 0 {
|
||||
@@ -229,6 +196,9 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
|
||||
if ar.RemoveThoughts {
|
||||
upwat.Thoughts = ""
|
||||
}
|
||||
if ar.Pinned != nil {
|
||||
upwat.Pinned = *ar.Pinned
|
||||
}
|
||||
res = db.Save(upwat)
|
||||
if res.RowsAffected <= 0 {
|
||||
return WatchedUpdateResponse{}, errors.New("no watched entry found")
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { DropDownItem } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let options: string[] | DropDownItem[];
|
||||
// If we are using DropDownItems[] as options.
|
||||
export let isDropDownItem = false;
|
||||
export let active: string | number | undefined = undefined;
|
||||
export let placeholder: string;
|
||||
export let blendIn: boolean = false;
|
||||
@@ -11,15 +14,54 @@
|
||||
|
||||
let activeValue: string;
|
||||
let open = false;
|
||||
let ulElement: HTMLUListElement;
|
||||
let mainElement: HTMLDivElement;
|
||||
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
if (!open || disabled) return; // Don't handle if closed or disabled
|
||||
|
||||
const pressedLetter = event.key.toLowerCase();
|
||||
|
||||
// Filter options that start with the pressed letter (ignoring case)
|
||||
const filteredOptions = options.filter((o) =>
|
||||
typeof o === "string"
|
||||
? o.toLowerCase().startsWith(pressedLetter)
|
||||
: o.value.toLowerCase().startsWith(pressedLetter)
|
||||
);
|
||||
|
||||
// If there are filtered options, select the first one
|
||||
let f: string | number | undefined;
|
||||
if (filteredOptions.length > 0) {
|
||||
f = typeof filteredOptions[0] === "string" ? filteredOptions[0] : filteredOptions[0].id;
|
||||
}
|
||||
|
||||
// Find first button with text content starting with letter pressed and scroll it into view.
|
||||
const btns = ulElement?.querySelectorAll("button");
|
||||
for (let i = 0; i < btns.length; i++) {
|
||||
const btn = btns[i];
|
||||
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
|
||||
btn.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (typeof active === "string") {
|
||||
if (typeof active === "string" && !isDropDownItem) {
|
||||
activeValue = active;
|
||||
} else {
|
||||
const v = options.find((o) => (typeof o !== "string" ? o.id === active : false));
|
||||
if (v && typeof v !== "string") activeValue = v.value;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mainElement.addEventListener("keypress", handleKeyPress);
|
||||
|
||||
return () => {
|
||||
mainElement.removeEventListener("keypress", handleKeyPress);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -28,12 +70,13 @@
|
||||
typeof active === "undefined" ? "placeholder-shown" : "",
|
||||
blendIn ? "blend-in" : ""
|
||||
].join(" ")}
|
||||
bind:this={mainElement}
|
||||
>
|
||||
<button on:click={() => (open = !open)} {disabled}>
|
||||
{activeValue ? activeValue : placeholder}
|
||||
<Icon i="chevron" facing={open ? "up" : "down"} />
|
||||
</button>
|
||||
<ul>
|
||||
<ul bind:this={ulElement}>
|
||||
{#each options.filter((o) => (typeof o === "string" ? o !== active : o.id !== active)) as o}
|
||||
<li>
|
||||
<button
|
||||
@@ -111,6 +154,8 @@
|
||||
border-bottom-right-radius: 5px;
|
||||
background-color: $bg-color;
|
||||
z-index: 99;
|
||||
max-height: 20vh;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
|
||||
@@ -58,34 +58,27 @@
|
||||
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0C25.384 18.573 15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189.518 1.04 7.348 1.027 7.86 0 .511-1.027-2.874-7.19-3.93-7.19z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "emby"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={wh} height={wh}>
|
||||
<path
|
||||
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "plex"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d" />
|
||||
</svg>
|
||||
{:else if i === "trash"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M296 64h-80a7.91 7.91 0 00-8 8v24h96V72a7.91 7.91 0 00-8-8z" fill="none" />
|
||||
<path
|
||||
d="M112 112l20 320c.95 18.49 14.4 32 32 32h184c17.67 0 30.87-13.51 32-32l20-320"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M80 112h352"
|
||||
/>
|
||||
<path
|
||||
d="M192 112V72h0a23.93 23.93 0 0124-24h80a23.93 23.93 0 0124 24h0v40M256 176v224M184 176l8 224M328 176l-8 224"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M432 96h-96V72a40 40 0 00-40-40h-80a40 40 0 00-40 40v24H80a16 16 0 000 32h17l19 304.92c1.42 26.85 22 47.08 48 47.08h184c26.13 0 46.3-19.78 48-47l19-305h17a16 16 0 000-32zM192.57 416H192a16 16 0 01-16-15.43l-8-224a16 16 0 1132-1.14l8 224A16 16 0 01192.57 416zM272 400a16 16 0 01-32 0V176a16 16 0 0132 0zm32-304h-96V72a7.91 7.91 0 018-8h80a7.91 7.91 0 018 8zm32 304.57A16 16 0 01320 416h-.58A16 16 0 01304 399.43l8-224a16 16 0 1132 1.14z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "close"}
|
||||
@@ -100,9 +93,9 @@
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "filter"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
|
||||
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "reel"}
|
||||
@@ -200,9 +193,9 @@
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "sort"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
|
||||
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "eye-closed"}
|
||||
@@ -214,6 +207,12 @@
|
||||
d="M491 273.36a32.2 32.2 0 00-.1-34.76c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.68 96a226.54 226.54 0 00-71.82 11.79 4 4 0 00-1.56 6.63l47.24 47.24a4 4 0 003.82 1.05 96 96 0 01116 116 4 4 0 001.05 3.81l67.95 68a4 4 0 005.4.24 343.81 343.81 0 0067.24-77.4zM256 352a96 96 0 01-93.3-118.63 4 4 0 00-1.05-3.81l-66.84-66.87a4 4 0 00-5.41-.23c-24.39 20.81-47 46.13-67.67 75.72a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.39 76.14 98.28 100.65C162.06 402 207.92 416 255.68 416a238.22 238.22 0 0072.64-11.55 4 4 0 001.61-6.64l-47.47-47.46a4 4 0 00-3.81-1.05A96 96 0 01256 352z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "people-nocircle"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M336 256c-20.56 0-40.44-9.18-56-25.84-15.13-16.25-24.37-37.92-26-61-1.74-24.62 5.77-47.26 21.14-63.76S312 80 336 80c23.83 0 45.38 9.06 60.7 25.52 15.47 16.62 23 39.22 21.26 63.63-1.67 23.11-10.9 44.77-26 61C376.44 246.82 356.57 256 336 256zm66-88zM467.83 432H204.18a27.71 27.71 0 01-22-10.67 30.22 30.22 0 01-5.26-25.79c8.42-33.81 29.28-61.85 60.32-81.08C264.79 297.4 299.86 288 336 288c36.85 0 71 9 98.71 26.05 31.11 19.13 52 47.33 60.38 81.55a30.27 30.27 0 01-5.32 25.78A27.68 27.68 0 01467.83 432zM147 260c-35.19 0-66.13-32.72-69-72.93-1.42-20.6 5-39.65 18-53.62 12.86-13.83 31-21.45 51-21.45s38 7.66 50.93 21.57c13.1 14.08 19.5 33.09 18 53.52-2.87 40.2-33.8 72.91-68.93 72.91zM212.66 291.45c-17.59-8.6-40.42-12.9-65.65-12.9-29.46 0-58.07 7.68-80.57 21.62-25.51 15.83-42.67 38.88-49.6 66.71a27.39 27.39 0 004.79 23.36A25.32 25.32 0 0041.72 400h111a8 8 0 007.87-6.57c.11-.63.25-1.26.41-1.88 8.48-34.06 28.35-62.84 57.71-83.82a8 8 0 00-.63-13.39c-1.57-.92-3.37-1.89-5.42-2.89z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "people"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
@@ -268,6 +267,12 @@
|
||||
d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-18.08L153 310.35 23 221.2a16 16 0 019-29.2h160.38l48.4-148.95a16 16 0 0130.44 0l48.4 149H480a16 16 0 019.05 29.2L359 310.35l50.13 148.53A16 16 0 01394 480z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "refresh"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.32 48 256c0 114.86 93.14 208 208 208 114.69 0 208-93.31 208-208 0-114.87-93.13-208-208-208zm0 313a94 94 0 010-188h4.21l-14.11-14.1a14 14 0 0119.8-19.8l40 40a14 14 0 010 19.8l-40 40a14 14 0 01-19.8-19.8l18-18c-2.38-.1-5.1-.1-8.1-.1a66 66 0 1066 66 14 14 0 0128 0 94.11 94.11 0 01-94 94z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "movary"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -290,6 +295,43 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{:else if i === "gamepad"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M483.13 245.38C461.92 149.49 430 98.31 382.65 84.33A107.13 107.13 0 00352 80c-13.71 0-25.65 3.34-38.28 6.88C298.5 91.15 281.21 96 256 96s-42.51-4.84-57.76-9.11C185.6 83.34 173.67 80 160 80a115.74 115.74 0 00-31.73 4.32c-47.1 13.92-79 65.08-100.52 161C4.61 348.54 16 413.71 59.69 428.83a56.62 56.62 0 0018.64 3.22c29.93 0 53.93-24.93 70.33-45.34 18.53-23.1 40.22-34.82 107.34-34.82 59.95 0 84.76 8.13 106.19 34.82 13.47 16.78 26.2 28.52 38.9 35.91 16.89 9.82 33.77 12 50.16 6.37 25.82-8.81 40.62-32.1 44-69.24 2.57-28.48-1.39-65.89-12.12-114.37zM208 240h-32v32a16 16 0 01-32 0v-32h-32a16 16 0 010-32h32v-32a16 16 0 0132 0v32h32a16 16 0 010 32zm84 4a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-19.95A20 20 0 01336 288zm0-88a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-20 20 20 0 01-20 20z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "film"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M436 80H76a44.05 44.05 0 00-44 44v264a44.05 44.05 0 0044 44h360a44.05 44.05 0 0044-44V124a44.05 44.05 0 00-44-44zM112 388a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm241.68 124H158.32a16 16 0 010-32h195.36a16 16 0 110 32zM448 388a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "tv"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M447.86 384H64.14A48.2 48.2 0 0116 335.86V128.14A48.2 48.2 0 0164.14 80h383.72A48.2 48.2 0 01496 128.14v207.72A48.2 48.2 0 01447.86 384z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M128 416h256"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "pin"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 -960 960 960">
|
||||
<path
|
||||
d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "unpin"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 -960 960 960">
|
||||
<path
|
||||
d="M680-840v80h-40v327L313-760l-33-33v-47h400ZM480-40l-40-40v-240H240v-80l80-80v-46L56-792l56-56 736 736-58 56-264-264h-6v240l-40 40Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
@media screen and (max-width: 420px) {
|
||||
font-size: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import DropDown from "./DropDown.svelte";
|
||||
import type { DropDownItem, TMDBRegions } from "@/types";
|
||||
import Error from "./Error.svelte";
|
||||
|
||||
export let selectedCountry: string = "US";
|
||||
export let disabled = false;
|
||||
export let onChange: (country: string) => void;
|
||||
|
||||
let mappedCountries: DropDownItem[];
|
||||
|
||||
async function getCountries() {
|
||||
const c = (await axios.get(`/content/regions`)).data as TMDBRegions;
|
||||
mappedCountries = c.results
|
||||
.map((cc) => {
|
||||
return {
|
||||
id: cc.iso_3166_1,
|
||||
value: cc.english_name
|
||||
} as DropDownItem;
|
||||
})
|
||||
.sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getCountries() then}
|
||||
<DropDown
|
||||
placeholder="Select a country"
|
||||
bind:active={selectedCountry}
|
||||
options={mappedCountries}
|
||||
onChange={() => onChange(selectedCountry)}
|
||||
isDropDownItem={true}
|
||||
{disabled}
|
||||
/>
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to load countries!" />
|
||||
{/await}
|
||||
@@ -7,7 +7,7 @@
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 13px;
|
||||
height: 12px;
|
||||
border: 2px solid #000;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
import Poster from "@/lib/poster/Poster.svelte";
|
||||
import PosterList from "@/lib/poster/PosterList.svelte";
|
||||
import { activeFilters, activeSort, serverFeatures, userSettings } from "@/store";
|
||||
import type { Watched, WatchedEpisode, WatchedSeason } from "@/types";
|
||||
import type { Watched } from "@/types";
|
||||
import GamePoster from "./poster/GamePoster.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { seasonAndEpToReadable } from "./util/helpers";
|
||||
import { getLatestWatchedInTv } from "./util/helpers";
|
||||
import { notify } from "./util/notify";
|
||||
|
||||
export let list: Watched[];
|
||||
export let isPublicList: boolean = false;
|
||||
@@ -57,140 +58,104 @@
|
||||
$: (watched, filters, sort), filt();
|
||||
|
||||
function filt() {
|
||||
// Set watched to list and sort it.
|
||||
watched = list.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
|
||||
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
|
||||
if (sort[1] === "UP") {
|
||||
return atitle.localeCompare(btitle);
|
||||
} else if (sort[1] === "DOWN") {
|
||||
return btitle.localeCompare(atitle);
|
||||
try {
|
||||
// Set watched to list and sort it.
|
||||
watched = list
|
||||
.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
|
||||
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
|
||||
if (sort[1] === "UP") {
|
||||
return atitle.localeCompare(btitle);
|
||||
} else if (sort[1] === "DOWN") {
|
||||
return btitle.localeCompare(atitle);
|
||||
}
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
} else if (sort[0] === "LASTFIN") {
|
||||
const aLastFinishActivity = a.activity
|
||||
?.sort(
|
||||
(aa, bb) =>
|
||||
Date.parse(bb.customDate ?? bb.updatedAt) -
|
||||
Date.parse(aa.customDate ?? aa.updatedAt)
|
||||
)
|
||||
?.find(
|
||||
(aa) =>
|
||||
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
|
||||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
|
||||
);
|
||||
const bLastFinishActivity = b.activity
|
||||
?.sort(
|
||||
(aa, bb) =>
|
||||
Date.parse(bb.customDate ?? bb.updatedAt) -
|
||||
Date.parse(aa.customDate ?? aa.updatedAt)
|
||||
)
|
||||
?.find(
|
||||
(aa) =>
|
||||
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
|
||||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
|
||||
);
|
||||
if (!aLastFinishActivity) return 1;
|
||||
if (!bLastFinishActivity) return -1;
|
||||
const alfaDate = aLastFinishActivity.customDate ?? aLastFinishActivity.updatedAt;
|
||||
const blfaDate = bLastFinishActivity.customDate ?? bLastFinishActivity.updatedAt;
|
||||
if (sort[1] === "UP") return Date.parse(alfaDate) - Date.parse(blfaDate);
|
||||
else if (sort[1] === "DOWN") return Date.parse(blfaDate) - Date.parse(alfaDate);
|
||||
} else if (sort[0] === "RATING") {
|
||||
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
|
||||
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
|
||||
if (!features.games) {
|
||||
const af = get(activeFilters);
|
||||
af.type = af.type?.filter((a) => a !== "game");
|
||||
filters.type = filters.type.filter((f) => f !== "game");
|
||||
}
|
||||
// Now apply filters to watch list.
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
// If status and type filters applied, combine both.
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
}
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
} else if (sort[0] === "RATING") {
|
||||
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
|
||||
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
});
|
||||
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
|
||||
if (!features.games) {
|
||||
const af = get(activeFilters);
|
||||
af.type = af.type?.filter((a) => a !== "game");
|
||||
filters.type = filters.type.filter((f) => f !== "game");
|
||||
}
|
||||
// Now apply filters to watch list.
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
// If status and type filters applied, combine both.
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
} else if (filters.type.length > 0) {
|
||||
// Only filter type
|
||||
watched = watched.filter((w) =>
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
}
|
||||
} else if (filters.type.length > 0) {
|
||||
// Only filter type
|
||||
watched = watched.filter((w) =>
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else if (filters.status.length > 0) {
|
||||
// Only filter status
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get biggest season watching or biggest season watched.
|
||||
// This could probably be simpler but -_-
|
||||
function getLatestWatchedInTv(
|
||||
ws: WatchedSeason[] | undefined,
|
||||
we: WatchedEpisode[] | undefined
|
||||
): string {
|
||||
if ((!ws || ws.length <= 0) && (!we || we.length <= 0)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let biggestSeasonWatched = -1;
|
||||
let biggestSeasonWatching = -1;
|
||||
if (ws && ws.length > 0) {
|
||||
for (let i = 0; i < ws.length; i++) {
|
||||
const s = ws[i];
|
||||
if (s.status === "WATCHING") {
|
||||
if (s.seasonNumber > biggestSeasonWatching) {
|
||||
biggestSeasonWatching = s.seasonNumber;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (s.seasonNumber > biggestSeasonWatched) {
|
||||
biggestSeasonWatched = s.seasonNumber;
|
||||
}
|
||||
} else if (filters.status.length > 0) {
|
||||
// Only filter status
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
const season = biggestSeasonWatching >= 0 ? biggestSeasonWatching : biggestSeasonWatched;
|
||||
|
||||
// Look for biggest watched/watching episode in season if any.
|
||||
// Does same thing as above.
|
||||
let episode: WatchedEpisode | undefined;
|
||||
if (we && we.length > 0) {
|
||||
let biggestEpisodeWatched: WatchedEpisode | undefined;
|
||||
let biggestEpisodeWatching: WatchedEpisode | undefined;
|
||||
for (let i = 0; i < we.length; i++) {
|
||||
const s = we[i];
|
||||
if (season >= 0 && s.seasonNumber !== season) continue;
|
||||
if (s.status === "WATCHING") {
|
||||
if (!biggestEpisodeWatching) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatching.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatching.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (!biggestEpisodeWatched) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatched.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatched.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (biggestEpisodeWatched || biggestEpisodeWatching) {
|
||||
episode =
|
||||
biggestEpisodeWatching !== undefined ? biggestEpisodeWatching : biggestEpisodeWatched;
|
||||
}
|
||||
}
|
||||
|
||||
if (season >= 0 && episode) {
|
||||
return seasonAndEpToReadable(season, episode.episodeNumber);
|
||||
} else if (season >= 0) {
|
||||
return `Season ${season}`;
|
||||
} else if (episode) {
|
||||
return seasonAndEpToReadable(episode.seasonNumber, episode.episodeNumber);
|
||||
} else {
|
||||
return "";
|
||||
} catch (err) {
|
||||
console.error("filt: Failed to filter/sort current list!", err);
|
||||
notify({ text: "Failed to filter/sort list!", type: "error", time: 6000 });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -216,6 +181,8 @@
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt
|
||||
}}
|
||||
fluidSize={true}
|
||||
pinned={w.pinned}
|
||||
/>
|
||||
{:else if w.content}
|
||||
<Poster
|
||||
@@ -237,6 +204,8 @@
|
||||
dateModified: w.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes)
|
||||
}}
|
||||
fluidSize={true}
|
||||
pinned={w.pinned}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -13,9 +13,10 @@ export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
|
||||
const show = () => {
|
||||
if (!condition) return;
|
||||
const trimmedText = text?.trim();
|
||||
if (!condition || !trimmedText) return;
|
||||
if (tooltip) {
|
||||
tooltip.innerHTML = text;
|
||||
tooltip.innerHTML = trimmedText;
|
||||
const nrect = node.getBoundingClientRect();
|
||||
const trect = tooltip.getBoundingClientRect();
|
||||
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { notify } from "../util/notify";
|
||||
|
||||
export let contentTitle: string;
|
||||
export let thoughts: string;
|
||||
export let onChange: (newThoughts: string) => Promise<boolean>;
|
||||
|
||||
let modalOpen = false;
|
||||
let textarea: HTMLTextAreaElement | undefined;
|
||||
$: thoughtsToDisplay = thoughts ? thoughts : `Set thoughts on ${contentTitle}`;
|
||||
|
||||
function resizeTextarea() {
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
textarea.style.height = "";
|
||||
textarea.style.height = textarea.scrollHeight + "px";
|
||||
}
|
||||
|
||||
$: textarea, resizeTextarea();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={`plain thoughts${thoughtsToDisplay?.length > 100 ? " long" : ""}${thoughts ? "" : " placeholdered"}`}
|
||||
on:click={() => {
|
||||
modalOpen = !modalOpen;
|
||||
if (modalOpen) {
|
||||
resizeTextarea();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i><Icon i="pencil" wh={24} /></i>
|
||||
<p>{thoughtsToDisplay}</p>
|
||||
</button>
|
||||
|
||||
{#if modalOpen}
|
||||
<Modal
|
||||
title="Your Thoughts"
|
||||
desc="View or modify your thoughts on {contentTitle}"
|
||||
onClose={async () => {
|
||||
if (!textarea) {
|
||||
notify({
|
||||
text: "Failed to find the text box! Please copy your changes to avoid losing them and try again!"
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If thoughts weren't changed or changes saved successfully.
|
||||
if (thoughts === textarea.value || (await onChange(textarea.value))) {
|
||||
modalOpen = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
name="Thoughts"
|
||||
rows="3"
|
||||
placeholder={`My thoughts on ${contentTitle}`}
|
||||
value={thoughts}
|
||||
bind:this={textarea}
|
||||
on:input={resizeTextarea}
|
||||
/>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
button.thoughts {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
padding: 7px 10px;
|
||||
border: 2px solid $text-color;
|
||||
border-radius: 5px;
|
||||
max-height: 100px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 150ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 5px;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition:
|
||||
opacity 150ms ease-in-out,
|
||||
transform 150ms ease-in-out;
|
||||
}
|
||||
|
||||
&:hover i {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.placeholdered {
|
||||
padding: 12px 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
max-height: 90px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.long p {
|
||||
mask: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 0,
|
||||
rgba(0, 0, 0, 1) 40%,
|
||||
rgba(0, 0, 0, 0) 95%,
|
||||
rgba(0, 0, 0, 0) 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -98,6 +98,24 @@
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "BBC iPlayer"}
|
||||
<a href="https://www.bbc.co.uk/iplayer" target="_blank">
|
||||
<svg width={80} height={wh} viewBox="0 0 605 107" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M.022 34.586h22.003V106.3H.022zM108.188 9.069C100.852 3.025 90.348 0 76.67 0H38.461v106.299h22.598V70.024h15.462c9.118 0 16.848-1.362 23.192-4.088 6.342-2.725 11.174-6.691 14.496-11.894 3.319-5.204 4.98-11.523 4.98-18.956 0-11.299-3.668-19.97-11.001-26.017Zm-17.47 38.877c-3.519 2.728-8.648 4.088-15.387 4.088H61.059V17.989H75.48c6.639 0 11.719 1.39 15.238 4.163 3.518 2.776 5.279 7.088 5.279 12.935 0 5.848-1.761 10.135-5.279 12.859ZM156.058 0h-22.895v106.299h73.295V86.378h-50.4zm127.661 0H259.04c-7.136 16.751-14.198 33.997-21.186 51.737-6.987 17.743-13.852 35.93-20.59 54.562h24.53c2.576-7.731 5.152-15.41 7.731-23.044h43.114c2.576 7.533 5.152 15.216 7.731 23.044h25.126c-6.541-17.641-13.306-35.358-20.294-53.149-6.987-17.789-14.149-35.506-21.483-53.15Zm-27.652 64.225c4.855-13.775 9.862-27.452 15.016-41.033 2.476 6.642 4.98 13.406 7.507 20.294 2.528 6.89 4.981 13.803 7.36 20.739h-29.883Zm106.708-12.121c-4.362-7.631-21.508-44.273-25.571-52.103h-26.018l39.843 71.961V106.3h22.895V71.962L413.917.001h-25.423c-4.163 7.83-21.259 44.372-25.719 52.103Zm84.238 9h44.601v-19.03h-44.601V19.03h50.25V0h-73.145v106.299h75.97V87.27h-53.075zm144.253 23.415c-4.362-7.085-8.772-14.147-13.232-21.186 6.144-2.676 10.876-6.49 14.199-11.447 3.319-4.955 4.98-10.951 4.98-17.989 0-10.902-3.742-19.276-11.224-25.126C578.504 2.924 567.777 0 553.802 0h-38.804v106.299h22.598V67.645h18.287c7.631 12.688 15.015 25.571 22.151 38.654h26.315c-4.363-7.433-8.723-14.692-13.083-21.78Zm-22.747-38.654c-3.668 2.528-9.02 3.791-16.056 3.791h-14.867V17.989h15.016c7.036 0 12.362 1.29 15.982 3.865 3.616 2.579 5.427 6.593 5.427 12.043-.001 5.452-1.836 9.441-5.502 11.968ZM0 0h22.025v18.15H0z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Hulu"}
|
||||
<a href="https://hulu.com" target="_blank">
|
||||
<svg width={wh} height={wh} viewBox="0 0 64 23" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.0599 6.54678H6.7997C5.65659 6.54678 5.08178 6.85375 5.08178 6.85375V0H0V20.98H5.08178V12.5141C5.08178 11.7654 5.69215 11.1591 6.44385 11.1591H9.40148C10.1544 11.1591 10.763 11.7654 10.763 12.5141V20.98H15.8462V11.8589C15.8462 8.02298 13.2767 6.54678 10.0599 6.54678ZM58.9167 6.54678V15.0115C58.9167 15.7614 58.3078 16.368 57.5556 16.368H54.5982C53.8453 16.368 53.2367 15.7614 53.2367 15.0115V6.54678H48.1526V15.4338C48.1526 19.0662 50.4883 20.98 53.9404 20.98H58.9173V20.9541C62.0966 20.9541 64 18.7023 64 15.4338V6.54678H58.9167ZM30.2441 15.0115C30.2441 15.7614 29.6347 16.368 28.8824 16.368H25.9247C25.1719 16.368 24.563 15.7614 24.563 15.0115V6.54678H19.4797V15.4338C19.4797 19.0662 21.8154 20.98 25.2673 20.98H30.2441V20.9541C33.4237 20.9541 35.3274 18.7023 35.3274 15.4338V6.54678H30.2441V15.0115ZM39.2015 20.98H44.2853V0H39.2015V20.98Z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<span>{i}</span>
|
||||
{/if}
|
||||
|
||||
@@ -5,18 +5,25 @@
|
||||
export let providers: TMDBContentWatchProviders;
|
||||
</script>
|
||||
|
||||
{#if providers?.flatrate?.length > 0}
|
||||
{#if providers?.flatrate?.length > 0 || providers?.free?.length > 0}
|
||||
<div class="streaming-providers">
|
||||
{#each providers.flatrate as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{#if providers?.flatrate?.length > 0}
|
||||
{#each providers.flatrate as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if providers?.free?.length > 0}
|
||||
{#each providers.free as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.streaming-providers {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: auto;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { wlDetailedView } from "@/store";
|
||||
import type { WLDetailedViewOption } from "@/types";
|
||||
import { get } from "svelte/store";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
function detailClicked(d: WLDetailedViewOption) {
|
||||
let dv = get(wlDetailedView);
|
||||
@@ -16,7 +17,7 @@
|
||||
$: dve = $wlDetailedView;
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
<div class={`menu${$page.url?.pathname.startsWith("/search/") ? " on-search-page" : ""}`}>
|
||||
<div class="inner">
|
||||
<h4 class="norm sm-caps">Shown Details</h4>
|
||||
<button
|
||||
@@ -56,6 +57,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
div.menu.on-search-page:before {
|
||||
left: 86px;
|
||||
}
|
||||
|
||||
div.inner {
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
|
||||
@@ -85,10 +85,9 @@
|
||||
<style lang="scss">
|
||||
div.menu {
|
||||
width: 200px;
|
||||
right: 35px;
|
||||
|
||||
&:before {
|
||||
left: 23px;
|
||||
left: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
>
|
||||
Last Changed
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "LASTFIN" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("LASTFIN")}
|
||||
>
|
||||
Last Finished
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "RATING" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("RATING")}
|
||||
@@ -63,6 +69,7 @@
|
||||
<style lang="scss">
|
||||
div {
|
||||
width: 180px;
|
||||
right: 53px;
|
||||
|
||||
&:before {
|
||||
left: 21px;
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
export let disableInteraction = false;
|
||||
export let hideButtons = false;
|
||||
export let extraDetails: ExtraDetailsGame | undefined = undefined;
|
||||
export let fluidSize = false;
|
||||
export let pinned = false;
|
||||
// When provided, default click handlers will instead run this callback.
|
||||
export let onClick: (() => void) | undefined = undefined;
|
||||
|
||||
@@ -107,8 +109,13 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (small && containerEl) {
|
||||
containerEl.classList.add("small");
|
||||
if (containerEl) {
|
||||
if (small) {
|
||||
containerEl.classList.add("small");
|
||||
}
|
||||
if (fluidSize) {
|
||||
containerEl.classList.add("fluid-size");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -128,7 +135,7 @@
|
||||
on:mouseleave={() => (posterActive = false)}
|
||||
on:click={() => (posterActive = true)}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
class={`${posterActive ? "active " : ""}`}
|
||||
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}`}
|
||||
>
|
||||
<div
|
||||
class={`container${!poster || (!media.coverId && !media.poster?.path) ? " details-shown" : ""}`}
|
||||
@@ -208,6 +215,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
li.pinned:not(.active) .container {
|
||||
outline: 3px solid gold;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@@ -222,6 +233,11 @@
|
||||
// aspect-ratio: 2/3;
|
||||
transition: transform 150ms ease;
|
||||
|
||||
&.fluid-size {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
export let disableInteraction = false;
|
||||
export let hideButtons = false;
|
||||
export let extraDetails: ExtraDetails | undefined = undefined;
|
||||
export let fluidSize = false;
|
||||
export let pinned = false;
|
||||
// When provided, default click handlers will instead run this callback.
|
||||
export let onClick: (() => void) | undefined = undefined;
|
||||
|
||||
@@ -99,8 +101,13 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (small && containerEl) {
|
||||
containerEl.classList.add("small");
|
||||
if (containerEl) {
|
||||
if (small) {
|
||||
containerEl.classList.add("small");
|
||||
}
|
||||
if (fluidSize) {
|
||||
containerEl.classList.add("fluid-size");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -120,7 +127,7 @@
|
||||
on:mouseleave={() => (posterActive = false)}
|
||||
on:click={() => (posterActive = true)}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
class={`${posterActive ? "active " : ""}`}
|
||||
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}`}
|
||||
>
|
||||
<div
|
||||
class={`container${!poster || !media.poster_path ? " details-shown" : ""}`}
|
||||
@@ -140,7 +147,7 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if $page.url?.pathname === "/" && extraDetails && dve && dve.length > 0}
|
||||
{#if ($page.url?.pathname === "/" || $page.url?.pathname.startsWith("/search/")) && extraDetails && dve && dve.length > 0}
|
||||
<div class="extra-details">
|
||||
<!--
|
||||
This is one line because svelte leaves whitespace,
|
||||
@@ -196,6 +203,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
li.pinned:not(.active) .container {
|
||||
outline: 3px solid gold;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@@ -203,13 +214,17 @@
|
||||
overflow: hidden;
|
||||
flex: 1 1;
|
||||
border-radius: 5px;
|
||||
min-width: 170px;
|
||||
width: 170px;
|
||||
height: 100%;
|
||||
min-height: 256.367px;
|
||||
position: relative;
|
||||
// aspect-ratio: 2/3;
|
||||
aspect-ratio: 170000/256367;
|
||||
transition: transform 150ms ease;
|
||||
|
||||
&.fluid-size {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -41,5 +41,26 @@
|
||||
padding: 15px 8px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
&:global(.wrapped li) {
|
||||
@media screen and (max-width: 390px) {
|
||||
flex: 0 48.5%;
|
||||
}
|
||||
@media screen and (max-width: 352px) {
|
||||
flex: 0 48%;
|
||||
}
|
||||
@media screen and (width <= 349px) {
|
||||
flex: unset;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.wrapped .container) {
|
||||
min-width: 150px !important;
|
||||
|
||||
@media screen and (width <= 349px), screen and (width > 390px) {
|
||||
width: 170px !important;
|
||||
min-height: 256.367px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
ArrDetailsResponse,
|
||||
ArrInfoResponse,
|
||||
ArrRequestResponse,
|
||||
ArrRequestStatus,
|
||||
ContentType
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { onMount } from "svelte";
|
||||
import Icon from "../Icon.svelte";
|
||||
import tooltip from "../actions/tooltip";
|
||||
import { msToAmountsOfTime } from "../util/helpers";
|
||||
import { notify } from "../util/notify";
|
||||
|
||||
export let type: ContentType;
|
||||
export let tmdbId: number;
|
||||
export let openRequestModal: () => void;
|
||||
|
||||
let existingRequest: ArrRequestResponse | undefined;
|
||||
let info: ArrInfoResponse | undefined;
|
||||
// The status, extra added string types are set in this file.
|
||||
let status: ArrDetailsResponse | "available" | "requested" | ArrRequestStatus | undefined;
|
||||
let estimatedCompletionIn: string | undefined;
|
||||
|
||||
async function getInfo() {
|
||||
try {
|
||||
if (!existingRequest) {
|
||||
console.warn("getInfo called before existingRequest exists.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
existingRequest.status !== "APPROVED" &&
|
||||
existingRequest.status !== "AUTO_APPROVED" &&
|
||||
existingRequest.status !== "FOUND"
|
||||
) {
|
||||
console.debug("getInfo: Request is not in an approved state.. not continuing.");
|
||||
return;
|
||||
}
|
||||
const resp = await axios.get<ArrInfoResponse>(
|
||||
`/arr/${type === "movie" ? "rad" : "son"}/info/${existingRequest.id}`
|
||||
);
|
||||
if (resp?.data) {
|
||||
info = resp.data;
|
||||
if (info.hasFile) {
|
||||
status = "available";
|
||||
} else {
|
||||
getStatus();
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 404 && err?.response?.data?.error === "request deleted") {
|
||||
return;
|
||||
}
|
||||
console.error("ArrRequestButton: getInfo failed!", err);
|
||||
notify({
|
||||
text: `Failed when getting info from ${type === "movie" ? "Radarr" : "Sonarr"}`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getStatus(ev: MouseEvent | undefined = undefined) {
|
||||
try {
|
||||
if (!existingRequest) {
|
||||
console.warn("getStatus called before existingRequest exists.");
|
||||
return;
|
||||
}
|
||||
const statusResp = await axios.get<ArrDetailsResponse>(
|
||||
`/arr/${type === "movie" ? "rad" : "son"}/status/${existingRequest.serverName}/${existingRequest.arrId}`
|
||||
);
|
||||
if (statusResp.status === 204) {
|
||||
status = "requested";
|
||||
} else if (statusResp?.data) {
|
||||
status = statusResp.data;
|
||||
const estMs =
|
||||
new Date(status.estimatedCompletionTime).getTime() - new Date(Date.now()).getTime();
|
||||
const est = msToAmountsOfTime(estMs);
|
||||
if (est.days > 0) {
|
||||
estimatedCompletionIn = `${est.days} Day${est.days > 1 ? "s" : ""}`;
|
||||
} else if (est.hours > 0) {
|
||||
estimatedCompletionIn = `${est.hours} Hour${est.hours > 1 ? "s" : ""}`;
|
||||
} else if (est.minutes > 0) {
|
||||
estimatedCompletionIn = `${est.minutes} Minute${est.minutes > 1 ? "s" : ""}`;
|
||||
} else if (est.seconds > 0) {
|
||||
estimatedCompletionIn = `${est.seconds} Second${est.seconds > 1 ? "s" : ""}`;
|
||||
} else {
|
||||
estimatedCompletionIn = undefined;
|
||||
}
|
||||
}
|
||||
if (ev?.target) {
|
||||
setTimeout(() => {
|
||||
(ev?.target as HTMLButtonElement)?.blur();
|
||||
}, 250);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("ArrRequestButton: getStatus failed!", err);
|
||||
notify({
|
||||
text: `Failed to fetch content status from ${type === "movie" ? "Radarr" : "Sonarr"}`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function lookForExisting() {
|
||||
try {
|
||||
const existingRequestResp = await axios.get<ArrRequestResponse>(
|
||||
`/arr/${type === "movie" ? "rad" : "son"}/request/${tmdbId}`
|
||||
);
|
||||
if (existingRequestResp?.data && existingRequestResp?.data?.arrId) {
|
||||
existingRequest = existingRequestResp?.data;
|
||||
getInfo();
|
||||
} else if (existingRequestResp?.data) {
|
||||
// If no arrId, use status in request (pending, denied, etc)
|
||||
console.log("No arrId in request resp.. using request status for btn status if set.");
|
||||
if (existingRequestResp?.data?.status) {
|
||||
status = existingRequestResp?.data?.status;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("ArrRequestButton: lookForExisting failed!", err);
|
||||
notify({
|
||||
text: `Failed when looking for an existing request for this content`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Used by Request modals to set existing request data in this component from their request response.
|
||||
export function setExistingRequest(r: ArrRequestResponse) {
|
||||
if (existingRequest) {
|
||||
console.error(
|
||||
"ArrRequestButton: setExistingRequest: Existing request has already been defined! Not continuing."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!r) {
|
||||
console.error("ArrRequestButton: setExistingRequest: No response passed in.", r);
|
||||
return;
|
||||
}
|
||||
console.debug("ArrRequestButton: setExistingRequest: Running..", r);
|
||||
existingRequest = r;
|
||||
if (existingRequest?.status) {
|
||||
status = existingRequest?.status;
|
||||
}
|
||||
getInfo();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
lookForExisting();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if typeof status === "object" || status === "requested"}
|
||||
<button
|
||||
on:click={getStatus}
|
||||
use:tooltip={{
|
||||
text:
|
||||
status === "requested"
|
||||
? "Waiting for approval or download to start"
|
||||
: status.status === "downloading"
|
||||
? estimatedCompletionIn
|
||||
? `Done in ${estimatedCompletionIn}`
|
||||
: "Estimation Unavailable"
|
||||
: status.status === "paused"
|
||||
? "Download has been paused"
|
||||
: "",
|
||||
pos: "bot"
|
||||
}}
|
||||
>
|
||||
<div><Icon i="refresh" /></div>
|
||||
<span>
|
||||
{#if status === "requested"}
|
||||
Requested
|
||||
{:else}
|
||||
{status.status}
|
||||
{status.status === "downloading" ? (status?.progress ? `(${status?.progress}%)` : "") : ""}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{:else if status === "available" || status === "FOUND"}
|
||||
<button disabled>Available</button>
|
||||
{:else if status === "PENDING"}
|
||||
<button disabled>Pending</button>
|
||||
{:else if status === "APPROVED"}
|
||||
<button disabled>Approved</button>
|
||||
{:else if status === "AUTO_APPROVED"}
|
||||
<button disabled>Auto Approved</button>
|
||||
{:else if status === "DENIED"}
|
||||
<button disabled>Denied</button>
|
||||
{:else}
|
||||
<button on:click={openRequestModal}>Request</button>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
max-width: fit-content;
|
||||
text-transform: capitalize;
|
||||
position: relative;
|
||||
|
||||
& > div {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
&:hover:not(:focus) {
|
||||
& > div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
& > span {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,22 @@
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import Modal from "../Modal.svelte";
|
||||
import type { RadarrSettings, RadarrTestResponse, TMDBMovieDetails } from "@/types";
|
||||
import type {
|
||||
ArrRequestResponse,
|
||||
RadarrSettings,
|
||||
RadarrTestResponse,
|
||||
TMDBMovieDetails
|
||||
} from "@/types";
|
||||
import { notify } from "../util/notify";
|
||||
import DropDown from "../DropDown.svelte";
|
||||
import Setting from "../settings/Setting.svelte";
|
||||
import Spinner from "../Spinner.svelte";
|
||||
|
||||
export let content: TMDBMovieDetails;
|
||||
export let onClose: () => void;
|
||||
export let onClose: (r: ArrRequestResponse | undefined) => void;
|
||||
|
||||
export let approveMode = false;
|
||||
export let originalRequest: ArrRequestResponse | undefined = undefined;
|
||||
|
||||
let servarrs: RadarrSettings[];
|
||||
let selectedServarrIndex: number;
|
||||
@@ -27,7 +35,9 @@
|
||||
notify({ text: "No servers found", type: "error" });
|
||||
}
|
||||
inputsDisabled = false;
|
||||
processOriginalRequest();
|
||||
} catch (err) {
|
||||
console.error("Failed to get servers!", err);
|
||||
notify({ text: "Failed to load servers", type: "error" });
|
||||
}
|
||||
}
|
||||
@@ -38,7 +48,10 @@
|
||||
const r = await axios.get<RadarrTestResponse>(`/arr/rad/config/${name}`);
|
||||
selectedServerCfg = r.data;
|
||||
inputsDisabled = false;
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
console.error("Failed to get config!", err);
|
||||
notify({ text: "Failed to load config", type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
async function request() {
|
||||
@@ -66,17 +79,22 @@
|
||||
notify({ id: nid, text: "No Root Folder Found", type: "error" });
|
||||
return;
|
||||
}
|
||||
await axios.post("/arr/rad/request", {
|
||||
serverName: server.name,
|
||||
title: content.title,
|
||||
year: new Date(content.release_date)?.getFullYear(),
|
||||
tmdbId: content.id,
|
||||
qualityProfile: server.qualityProfile,
|
||||
rootFolder: rootFolder.path
|
||||
});
|
||||
notify({ id: nid, text: "Request complete", type: "success" });
|
||||
const resp = await axios.post<ArrRequestResponse>(
|
||||
`/arr/rad/request${approveMode && originalRequest ? `/approve/${originalRequest.id}` : ""}`,
|
||||
{
|
||||
serverName: server.name,
|
||||
title: content.title,
|
||||
year: new Date(content.release_date)?.getFullYear(),
|
||||
tmdbId: content.id,
|
||||
qualityProfile: server.qualityProfile,
|
||||
rootFolder: rootFolder.path
|
||||
}
|
||||
);
|
||||
addRequestRunning = false;
|
||||
onClose();
|
||||
if (resp.data) {
|
||||
notify({ id: nid, text: "Request complete", type: "success" });
|
||||
onClose(resp.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("content request failed!", err);
|
||||
addRequestRunning = false;
|
||||
@@ -84,6 +102,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
function processOriginalRequest() {
|
||||
if (!originalRequest) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (originalRequest.requestJson) {
|
||||
const ogr = JSON.parse(originalRequest.requestJson);
|
||||
if (!ogr) {
|
||||
console.info("processOriginalRequest: No json.", ogr);
|
||||
return;
|
||||
}
|
||||
if (ogr?.serverName) {
|
||||
console.debug("processOriginalRequest: restoring server name:", ogr?.serverName);
|
||||
const idx = servarrs?.findIndex((s) => s.name === ogr?.serverName);
|
||||
if (idx !== -1) {
|
||||
selectedServarrIndex = idx;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Full original request could not be restored. You may continue, but prefilled settings may not be true to the original request.",
|
||||
time: 10000
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("processOriginalRequest: Failed!", err);
|
||||
notify({ text: "Failed when processing original request!", type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (typeof selectedServarrIndex !== "undefined" && servarrs?.length > 0) {
|
||||
const s = servarrs[selectedServarrIndex];
|
||||
@@ -98,7 +147,11 @@
|
||||
getServers();
|
||||
</script>
|
||||
|
||||
<Modal title="Request" desc={content.title} {onClose}>
|
||||
<Modal
|
||||
title={approveMode ? "Approve Request" : "Request"}
|
||||
desc={content.title}
|
||||
onClose={() => onClose(undefined)}
|
||||
>
|
||||
<div class="req-ctr">
|
||||
{#if servarrs}
|
||||
{@const server = servarrs[selectedServarrIndex]}
|
||||
@@ -117,7 +170,9 @@
|
||||
</Setting>
|
||||
{/if}
|
||||
|
||||
<button on:click={request} disabled={addRequestRunning}>Request</button>
|
||||
<button on:click={request} disabled={addRequestRunning}>
|
||||
{approveMode ? "Approve" : "Request"}
|
||||
</button>
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import Modal from "../Modal.svelte";
|
||||
import type { ListBoxItem, SonarrSettings, SonarrTestResponse, TMDBShowDetails } from "@/types";
|
||||
import type {
|
||||
ArrRequestResponse,
|
||||
ListBoxItem,
|
||||
SonarrSettings,
|
||||
SonarrTestResponse,
|
||||
TMDBShowDetails
|
||||
} from "@/types";
|
||||
import { notify } from "../util/notify";
|
||||
import DropDown from "../DropDown.svelte";
|
||||
import Setting from "../settings/Setting.svelte";
|
||||
@@ -11,7 +17,10 @@
|
||||
const animeKeywordId = 210024;
|
||||
|
||||
export let content: TMDBShowDetails;
|
||||
export let onClose: () => void;
|
||||
export let onClose: (r: ArrRequestResponse | undefined) => void;
|
||||
|
||||
export let approveMode = false;
|
||||
export let originalRequest: ArrRequestResponse | undefined = undefined;
|
||||
|
||||
let servarrs: SonarrSettings[];
|
||||
let selectedServarrIndex: number;
|
||||
@@ -37,7 +46,9 @@
|
||||
notify({ text: "No servers found", type: "error" });
|
||||
}
|
||||
inputsDisabled = false;
|
||||
processOriginalRequest();
|
||||
} catch (err) {
|
||||
console.error("Failed to get servers!", err);
|
||||
notify({ text: "Failed to load servers", type: "error" });
|
||||
}
|
||||
}
|
||||
@@ -48,7 +59,10 @@
|
||||
const r = await axios.get<SonarrTestResponse>(`/arr/son/config/${name}`);
|
||||
selectedServerCfg = r.data;
|
||||
inputsDisabled = false;
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
console.error("Failed to get config!", err);
|
||||
notify({ text: "Failed to load config", type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
async function request() {
|
||||
@@ -76,27 +90,33 @@
|
||||
notify({ id: nid, text: "No Root Folder Found", type: "error" });
|
||||
return;
|
||||
}
|
||||
await axios.post("/arr/son/request", {
|
||||
serverName: server.name,
|
||||
title: content.name,
|
||||
year: new Date(content.first_air_date)?.getFullYear(),
|
||||
tvdbId: content.external_ids.tvdb_id,
|
||||
seriesType: content.keywords.results?.find((k) => k.id == animeKeywordId)
|
||||
? "anime"
|
||||
: "standard",
|
||||
qualityProfile: server.qualityProfile,
|
||||
rootFolder: rootFolder.path,
|
||||
languageProfile: server.languageProfile,
|
||||
seasons: seasonItems.map((s) => {
|
||||
return {
|
||||
seasonNumber: s.id,
|
||||
monitored: s.value
|
||||
};
|
||||
})
|
||||
});
|
||||
notify({ id: nid, text: "Request complete", type: "success" });
|
||||
const resp = await axios.post(
|
||||
`/arr/son/request${approveMode && originalRequest ? `/approve/${originalRequest.id}` : ""}`,
|
||||
{
|
||||
serverName: server.name,
|
||||
title: content.name,
|
||||
year: new Date(content.first_air_date)?.getFullYear(),
|
||||
tmdbId: content.id,
|
||||
tvdbId: content.external_ids.tvdb_id,
|
||||
seriesType: content.keywords.results?.find((k) => k.id == animeKeywordId)
|
||||
? "anime"
|
||||
: "standard",
|
||||
qualityProfile: server.qualityProfile,
|
||||
rootFolder: rootFolder.path,
|
||||
languageProfile: server.languageProfile,
|
||||
seasons: seasonItems.map((s) => {
|
||||
return {
|
||||
seasonNumber: s.id,
|
||||
monitored: s.value
|
||||
};
|
||||
})
|
||||
}
|
||||
);
|
||||
addRequestRunning = false;
|
||||
onClose();
|
||||
if (resp?.data) {
|
||||
notify({ id: nid, text: "Request complete", type: "success" });
|
||||
onClose(resp.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("content request failed!", err);
|
||||
addRequestRunning = false;
|
||||
@@ -104,6 +124,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
function processOriginalRequest() {
|
||||
if (!originalRequest) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (originalRequest.requestJson) {
|
||||
const ogr = JSON.parse(originalRequest.requestJson);
|
||||
if (!ogr) {
|
||||
console.info("processOriginalRequest: No json.", ogr);
|
||||
return;
|
||||
}
|
||||
if (ogr?.seasons?.length > 0) {
|
||||
console.debug("processOriginalRequest: Found seasons.. restoring.");
|
||||
for (let i = 0; i < ogr.seasons.length; i++) {
|
||||
const s = ogr.seasons[i];
|
||||
// Default is not monitored, so no point going through the whole rigmarole to 'restore' the default value.
|
||||
if (!s.monitored) {
|
||||
continue;
|
||||
}
|
||||
const sItem = seasonItems?.find((si) => si.id === s.seasonNumber);
|
||||
if (sItem) {
|
||||
sItem.value = s.monitored;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ogr?.serverName) {
|
||||
console.debug("processOriginalRequest: restoring server name:", ogr?.serverName);
|
||||
const idx = servarrs?.findIndex((s) => s.name === ogr?.serverName);
|
||||
if (idx !== -1) {
|
||||
selectedServarrIndex = idx;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Full original request could not be restored. You may continue, but prefilled settings may not be true to the original request.",
|
||||
time: 10000
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("processOriginalRequest: Failed!", err);
|
||||
notify({ text: "Failed when processing original request!", type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (typeof selectedServarrIndex !== "undefined" && servarrs?.length > 0) {
|
||||
const s = servarrs[selectedServarrIndex];
|
||||
@@ -118,7 +183,11 @@
|
||||
getServers();
|
||||
</script>
|
||||
|
||||
<Modal title="Request" desc={content.name} {onClose}>
|
||||
<Modal
|
||||
title={approveMode ? "Approve Request" : "Request"}
|
||||
desc={content.name}
|
||||
onClose={() => onClose(undefined)}
|
||||
>
|
||||
<div class="req-ctr">
|
||||
{#if servarrs}
|
||||
{@const server = servarrs[selectedServarrIndex]}
|
||||
@@ -141,7 +210,9 @@
|
||||
</Setting>
|
||||
{/if}
|
||||
|
||||
<button on:click={request} disabled={addRequestRunning}>Request</button>
|
||||
<button on:click={request} disabled={addRequestRunning}>
|
||||
{approveMode ? "Approve" : "Request"}
|
||||
</button>
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
||||
|
||||
@@ -22,27 +22,34 @@ export const baseURL = MODE === "development" ? "http://127.0.0.1:3080/api" : "/
|
||||
|
||||
/**
|
||||
* Updates watched item with new status, rating or thoughts.
|
||||
* @returns Was success?
|
||||
*/
|
||||
function _updateWatched(
|
||||
async function _updateWatched(
|
||||
wEntry: Watched,
|
||||
status?: WatchedStatus,
|
||||
rating?: number,
|
||||
thoughts?: string
|
||||
) {
|
||||
thoughts?: string,
|
||||
pinned?: boolean
|
||||
): Promise<boolean> {
|
||||
if (!status && !rating && typeof thoughts === "undefined" && typeof pinned === "undefined") {
|
||||
console.warn("_updateWatched: Nothing was provided, so nothing can be updated!!!!");
|
||||
return false;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
if (!status && !rating && typeof thoughts === "undefined") return;
|
||||
const obj = {} as WatchedUpdateRequest;
|
||||
if (status) obj.status = status;
|
||||
if (rating) obj.rating = rating;
|
||||
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
|
||||
if (thoughts === "") obj.removeThoughts = true;
|
||||
axios
|
||||
if (typeof pinned !== "undefined") obj.pinned = pinned;
|
||||
return await axios
|
||||
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
|
||||
.then((resp) => {
|
||||
if (status) wEntry.status = status;
|
||||
if (rating) wEntry.rating = rating;
|
||||
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
|
||||
if (resp?.data?.newActivity) {
|
||||
if (typeof pinned !== "undefined") wEntry.pinned = pinned;
|
||||
if (resp?.data?.newActivity && resp?.data?.newActivity?.id) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(resp.data.newActivity);
|
||||
} else {
|
||||
@@ -55,10 +62,12 @@ function _updateWatched(
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
return true;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,27 +77,27 @@ function _updateWatched(
|
||||
* @param contentType show/movie
|
||||
* @param status
|
||||
* @param rating
|
||||
* @returns
|
||||
* @returns Was success?
|
||||
*/
|
||||
export function updateWatched(
|
||||
export async function updateWatched(
|
||||
contentId: number,
|
||||
contentType: MediaType,
|
||||
status?: WatchedStatus,
|
||||
rating?: number,
|
||||
thoughts?: string
|
||||
) {
|
||||
thoughts?: string,
|
||||
pinned?: boolean
|
||||
): Promise<boolean> {
|
||||
// If item is already in watched store, run update request instead
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find(
|
||||
(w) => w.content?.tmdbId === contentId && w.content?.type === contentType
|
||||
);
|
||||
if (wEntry?.id) {
|
||||
_updateWatched(wEntry, status, rating, thoughts);
|
||||
return;
|
||||
return await _updateWatched(wEntry, status, rating, thoughts, pinned);
|
||||
}
|
||||
// Add new watched item
|
||||
const nid = notify({ text: `Adding`, type: "loading" });
|
||||
axios
|
||||
return await axios
|
||||
.post("/watched", {
|
||||
contentId,
|
||||
contentType,
|
||||
@@ -100,10 +109,12 @@ export function updateWatched(
|
||||
wList.push(resp.data as Watched);
|
||||
watchedList.update(() => wList);
|
||||
notify({ id: nid, text: `Added!`, type: "success" });
|
||||
return true;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Add!", type: "error" });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,22 +145,22 @@ export function removeWatched(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlayed(
|
||||
export async function updatePlayed(
|
||||
igdbId: number,
|
||||
status?: WatchedStatus,
|
||||
rating?: number,
|
||||
thoughts?: string
|
||||
) {
|
||||
thoughts?: string,
|
||||
pinned?: boolean
|
||||
): Promise<boolean> {
|
||||
// If item is already in watched store, run update request instead
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.game?.igdbId === igdbId);
|
||||
if (wEntry?.id) {
|
||||
_updateWatched(wEntry, status, rating, thoughts);
|
||||
return;
|
||||
return await _updateWatched(wEntry, status, rating, thoughts, pinned);
|
||||
}
|
||||
// Add new played item
|
||||
const nid = notify({ text: `Adding`, type: "loading" });
|
||||
axios
|
||||
return await axios
|
||||
.post("/game/played", {
|
||||
igdbId,
|
||||
rating,
|
||||
@@ -160,10 +171,12 @@ export function updatePlayed(
|
||||
wList.push(resp.data as Watched);
|
||||
watchedList.update(() => wList);
|
||||
notify({ id: nid, text: `Added!`, type: "success" });
|
||||
return true;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Add!", type: "error" });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
type Theme,
|
||||
type TokenClaims,
|
||||
type Watched,
|
||||
type WatchedStatus
|
||||
type WatchedStatus,
|
||||
type WatchedEpisode,
|
||||
type WatchedSeason
|
||||
} from "@/types";
|
||||
|
||||
export const watchedStatuses: {
|
||||
@@ -63,7 +65,12 @@ export function getWatchedDependedProps(wid: number, wtype: MediaType, list: Wat
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
rating: wel.rating
|
||||
rating: wel.rating,
|
||||
extraDetails: {
|
||||
dateAdded: wel.createdAt,
|
||||
dateModified: wel.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(wel.watchedSeasons, wel.watchedEpisodes)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +84,82 @@ export function getPlayedDependedProps(wid: number, list: Watched[]) {
|
||||
};
|
||||
}
|
||||
|
||||
// Get biggest season watching or biggest season watched.
|
||||
// This could probably be simpler but -_-
|
||||
export function getLatestWatchedInTv(
|
||||
ws: WatchedSeason[] | undefined,
|
||||
we: WatchedEpisode[] | undefined
|
||||
): string {
|
||||
if ((!ws || ws.length <= 0) && (!we || we.length <= 0)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let biggestSeasonWatched = -1;
|
||||
let biggestSeasonWatching = -1;
|
||||
if (ws && ws.length > 0) {
|
||||
for (let i = 0; i < ws.length; i++) {
|
||||
const s = ws[i];
|
||||
if (s.status === "WATCHING") {
|
||||
if (s.seasonNumber > biggestSeasonWatching) {
|
||||
biggestSeasonWatching = s.seasonNumber;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (s.seasonNumber > biggestSeasonWatched) {
|
||||
biggestSeasonWatched = s.seasonNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const season = biggestSeasonWatching >= 0 ? biggestSeasonWatching : biggestSeasonWatched;
|
||||
|
||||
// Look for biggest watched/watching episode in season if any.
|
||||
// Does same thing as above.
|
||||
let episode: WatchedEpisode | undefined;
|
||||
if (we && we.length > 0) {
|
||||
let biggestEpisodeWatched: WatchedEpisode | undefined;
|
||||
let biggestEpisodeWatching: WatchedEpisode | undefined;
|
||||
for (let i = 0; i < we.length; i++) {
|
||||
const s = we[i];
|
||||
if (season >= 0 && s.seasonNumber !== season) continue;
|
||||
if (s.status === "WATCHING") {
|
||||
if (!biggestEpisodeWatching) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatching.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatching.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (!biggestEpisodeWatched) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatched.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatched.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (biggestEpisodeWatched || biggestEpisodeWatching) {
|
||||
episode =
|
||||
biggestEpisodeWatching !== undefined ? biggestEpisodeWatching : biggestEpisodeWatched;
|
||||
}
|
||||
}
|
||||
|
||||
if (season >= 0 && episode) {
|
||||
return seasonAndEpToReadable(season, episode.episodeNumber);
|
||||
} else if (season >= 0) {
|
||||
return `Season ${season}`;
|
||||
} else if (episode) {
|
||||
return seasonAndEpToReadable(episode.seasonNumber, episode.episodeNumber);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a watched status into understandable text
|
||||
* depending on if the status is for a game or not.
|
||||
@@ -238,3 +321,28 @@ export function userHasPermission(perms: UserPermission, reqPerm: UserPermission
|
||||
export function seasonAndEpToReadable(season: number | undefined, episode: number | undefined) {
|
||||
return `S${typeof season === "number" ? String(season) : "(unknown)"}E${typeof episode === "number" ? String(episode) : "(unknown)"}`;
|
||||
}
|
||||
|
||||
export function msToAmountsOfTime(ms: number) {
|
||||
const seconds = Math.floor((ms / 1000) % 60);
|
||||
const minutes = Math.floor((ms / (1000 * 60)) % 60);
|
||||
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
|
||||
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||
return { days, hours, minutes, seconds };
|
||||
}
|
||||
|
||||
/** I'm no warden of time, this is as relative as it's getting. */
|
||||
export function toRelativeDate(d: Date): string {
|
||||
if (!d) {
|
||||
return "Unknown";
|
||||
}
|
||||
const dn = new Date(Date.now());
|
||||
if (d.getFullYear() === dn.getFullYear()) {
|
||||
if (d.getMonth() === dn.getMonth()) {
|
||||
if (d.getDate() === dn.getDate()) {
|
||||
return "Today";
|
||||
}
|
||||
}
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]}`;
|
||||
}
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
@@ -333,3 +333,96 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(table) {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
border-spacing: 0px;
|
||||
border: 1px solid $accent-color;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
|
||||
th {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
transition: padding 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
border-top-left-radius: 10px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
|
||||
&.loading-col {
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
th {
|
||||
background-color: $accent-color;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
td:first-of-type {
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
td:last-of-type {
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(odd) td {
|
||||
background-color: $accent-color;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 5px;
|
||||
|
||||
&.icon-cell {
|
||||
padding-right: 3px;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
color: $text-color;
|
||||
border: 0;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
padding: 7px 10px;
|
||||
transition: padding 100ms ease;
|
||||
|
||||
&[type="number"] {
|
||||
appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
&.delete {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: $placeholder-color;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { afterNavigate, goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
@@ -23,12 +23,12 @@
|
||||
userSettings,
|
||||
watchedList
|
||||
} from "@/store";
|
||||
import { type Filters, UserPermission } from "@/types";
|
||||
import { UserPermission } from "@/types";
|
||||
import axios from "axios";
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
let navEl: HTMLElement;
|
||||
let mainSearchEl: HTMLInputElement;
|
||||
let searchTimeout: NodeJS.Timeout;
|
||||
let subMenuShown = false;
|
||||
let filterMenuShown = false;
|
||||
@@ -72,7 +72,13 @@
|
||||
// https://github.com/sbondCo/Watcharr/issues/169
|
||||
target.autofocus = true;
|
||||
goto(`/search/${query}`).then(() => {
|
||||
target?.focus();
|
||||
// Use mainSearchEl if nav not split, otherwise use ev target.
|
||||
if (!document.body.classList.contains("split-nav")) {
|
||||
mainSearchEl?.focus();
|
||||
mainSearchEl.autofocus = false;
|
||||
} else {
|
||||
target?.focus();
|
||||
}
|
||||
target.autofocus = false;
|
||||
});
|
||||
}
|
||||
@@ -97,6 +103,16 @@
|
||||
subMenuShown = false;
|
||||
}
|
||||
|
||||
function userManagement() {
|
||||
goto("/manage_users");
|
||||
subMenuShown = false;
|
||||
}
|
||||
|
||||
function requestManagement() {
|
||||
goto("/arr_requests");
|
||||
subMenuShown = false;
|
||||
}
|
||||
|
||||
function shareWatchedList() {
|
||||
const nid = notify({ type: "loading", text: "Getting link" });
|
||||
const ud = parseTokenPayload();
|
||||
@@ -159,8 +175,38 @@
|
||||
if (except !== "detailed") detailedMenuShown = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or removed `split-nav` tag to body depending
|
||||
* on how big the main search bar is.
|
||||
*/
|
||||
function decideOnNavSplit() {
|
||||
if (window.innerWidth <= 260) {
|
||||
document.body.classList.add("split-nav");
|
||||
return;
|
||||
}
|
||||
const bigInput = navEl?.querySelector("input:not(.small)");
|
||||
if (bigInput) {
|
||||
const b = bigInput.getBoundingClientRect();
|
||||
// console.debug("decideOnNavSplit: bigInput bounds:", b);
|
||||
if (b.width <= 45) {
|
||||
document.body.classList.add("split-nav");
|
||||
} else {
|
||||
document.body.classList.remove("split-nav");
|
||||
}
|
||||
} else {
|
||||
console.warn("decideOnNavSplit: bigInput not found!", bigInput);
|
||||
}
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
decideOnNavSplit();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (navEl) {
|
||||
decideOnNavSplit();
|
||||
window.addEventListener("resize", decideOnNavSplit);
|
||||
|
||||
let scroll = window.scrollY;
|
||||
window.document.addEventListener("scroll", (ev: Event) => {
|
||||
if (scroll > window.scrollY) {
|
||||
@@ -180,108 +226,131 @@
|
||||
</script>
|
||||
|
||||
<nav bind:this={navEl}>
|
||||
<a href="/">
|
||||
<span class="large">Watcharr</span>
|
||||
<span class="small">W</span>
|
||||
</a>
|
||||
<input type="text" placeholder="Search" bind:value={$searchQuery} on:keydown={handleSearch} />
|
||||
<div class="btns">
|
||||
<!-- Detailed posters only supported on own watched list currently -->
|
||||
{#if $page.url?.pathname === "/"}
|
||||
<button
|
||||
class="plain other detailedView"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("detailed");
|
||||
detailedMenuShown = !detailedMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Detailed View", pos: "bot", condition: !detailedMenuShown }}
|
||||
>
|
||||
<Icon i="eye" />
|
||||
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
|
||||
<div class="indicator"></div>
|
||||
<div class="wrapper">
|
||||
<a href="/">
|
||||
<span class="large">Watcharr</span>
|
||||
<span class="small">W</span>
|
||||
</a>
|
||||
<div class="search">
|
||||
<input
|
||||
bind:this={mainSearchEl}
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
bind:value={$searchQuery}
|
||||
on:keydown={handleSearch}
|
||||
/>
|
||||
<Icon i="search" wh={19} />
|
||||
</div>
|
||||
<div class="btns">
|
||||
<!-- Detailed posters only supported on own watched list currently -->
|
||||
{#if $page.url?.pathname === "/" || $page.url?.pathname.startsWith("/search/")}
|
||||
<button
|
||||
class="plain other detailedView"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("detailed");
|
||||
detailedMenuShown = !detailedMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Detailed View", pos: "bot", condition: !detailedMenuShown }}
|
||||
>
|
||||
<Icon i="eye" />
|
||||
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
|
||||
<div class="indicator"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if detailedMenuShown}
|
||||
<DetailedMenu />
|
||||
{/if}
|
||||
</button>
|
||||
{#if detailedMenuShown}
|
||||
<DetailedMenu />
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- Show on watched list and shared/followed watched lists -->
|
||||
{#if $page.url?.pathname === "/" || $page.url?.pathname.includes("/lists/")}
|
||||
<button
|
||||
class="plain other filter"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("filter");
|
||||
filterMenuShown = !filterMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Filter", pos: "bot", condition: !filterMenuShown }}
|
||||
>
|
||||
<Icon i="filter" />
|
||||
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
|
||||
<div class="indicator"></div>
|
||||
<!-- Show on watched list and shared/followed watched lists -->
|
||||
{#if $page.url?.pathname === "/" || $page.url?.pathname.includes("/lists/")}
|
||||
<button
|
||||
class="plain other sort"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("sort");
|
||||
sortMenuShown = !sortMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Sort", pos: "bot", condition: !sortMenuShown }}
|
||||
>
|
||||
<Icon i="sort" />
|
||||
<!-- Show indicator if not equal to default and second item in array is not falsy -->
|
||||
{#if $activeSort?.length === 2 && $activeSort[1] && JSON.stringify($activeSort) !== JSON.stringify(defaultSort)}
|
||||
<div class="indicator"></div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="plain other filter"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("filter");
|
||||
filterMenuShown = !filterMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Filter", pos: "bot", condition: !filterMenuShown }}
|
||||
>
|
||||
<Icon i="filter" />
|
||||
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
|
||||
<div class="indicator"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if sortMenuShown}
|
||||
<SortMenu />
|
||||
{/if}
|
||||
{#if filterMenuShown}
|
||||
<FilterMenu />
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
class="plain other discover"
|
||||
on:click={() => goto("/discover")}
|
||||
use:tooltip={{ text: "Discover", pos: "bot" }}
|
||||
>
|
||||
<Icon i="compass" wh={26} />
|
||||
</button>
|
||||
<button
|
||||
class="plain other sort"
|
||||
class="plain other following"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("sort");
|
||||
sortMenuShown = !sortMenuShown;
|
||||
closeAllSubMenus("following");
|
||||
followingMenuShown = !followingMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Sort", pos: "bot", condition: !sortMenuShown }}
|
||||
use:tooltip={{ text: "Following", pos: "bot", condition: !followingMenuShown }}
|
||||
>
|
||||
<Icon i="sort" />
|
||||
<!-- Show indicator if not equal to default and second item in array is not falsy -->
|
||||
{#if $activeSort?.length === 2 && $activeSort[1] && JSON.stringify($activeSort) !== JSON.stringify(defaultSort)}
|
||||
<div class="indicator"></div>
|
||||
{/if}
|
||||
<Icon i="people" wh={26} />
|
||||
</button>
|
||||
{#if sortMenuShown}
|
||||
<SortMenu />
|
||||
{#if followingMenuShown}
|
||||
<FollowingMenu close={() => (followingMenuShown = false)} />
|
||||
{/if}
|
||||
{#if filterMenuShown}
|
||||
<FilterMenu />
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
class="plain other discover"
|
||||
on:click={() => goto("/discover")}
|
||||
use:tooltip={{ text: "Discover", pos: "bot" }}
|
||||
>
|
||||
<Icon i="compass" wh={26} />
|
||||
</button>
|
||||
<button
|
||||
class="plain other following"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("following");
|
||||
followingMenuShown = !followingMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Following", pos: "bot", condition: !followingMenuShown }}
|
||||
>
|
||||
<Icon i="people" wh={26} />
|
||||
</button>
|
||||
{#if followingMenuShown}
|
||||
<FollowingMenu close={() => (followingMenuShown = false)} />
|
||||
{/if}
|
||||
<button class="plain face" on:click={handleProfileClick}>:)</button>
|
||||
{#if subMenuShown}
|
||||
<div class="menu face-menu">
|
||||
<div>
|
||||
{#if user?.username}
|
||||
<h5 title={user.username}>Hi {user.username}!</h5>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => profile()}>Profile</button>
|
||||
{#if !settings?.private}
|
||||
<button class="plain" on:click={() => shareWatchedList()}>Share List</button>
|
||||
{/if}
|
||||
{#if user && userHasPermission(user.permissions, UserPermission.PERM_ADMIN)}
|
||||
<button class="plain" on:click={() => serverSettings()}>Settings</button>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => logout()}>Logout</button>
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<span>v{__WATCHARR_VERSION__}</span>
|
||||
<button class="plain face" on:click={handleProfileClick}>:)</button>
|
||||
{#if subMenuShown}
|
||||
<div class="menu face-menu">
|
||||
<div>
|
||||
{#if user?.username}
|
||||
<h5 title={user.username}>Hi {user.username}!</h5>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => profile()}>Profile</button>
|
||||
{#if !settings?.private}
|
||||
<button class="plain" on:click={() => shareWatchedList()}>Share List</button>
|
||||
{/if}
|
||||
{#if user && userHasPermission(user.permissions, UserPermission.PERM_ADMIN)}
|
||||
<button class="plain" on:click={() => serverSettings()}>Settings</button>
|
||||
<button class="plain" on:click={() => userManagement()}>Users</button>
|
||||
{#if $serverFeatures.sonarr || $serverFeatures.radarr}
|
||||
<!-- At least one (sonarr/radarr) should be enabled for requests menu item to display. -->
|
||||
<button class="plain" on:click={() => requestManagement()}>Requests</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="plain" on:click={() => logout()}>Logout</button>
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<span>v{__WATCHARR_VERSION__}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
class="small"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
bind:value={$searchQuery}
|
||||
on:keydown={handleSearch}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{#await getInitialData()}
|
||||
@@ -295,20 +364,50 @@
|
||||
<style lang="scss">
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-flow: column;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 20px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
gap: 20px;
|
||||
gap: 3px;
|
||||
z-index: 99990;
|
||||
backdrop-filter: blur(2.5px) saturate(120%);
|
||||
background-color: $nav-color;
|
||||
transition: top 200ms ease-in-out;
|
||||
|
||||
&:global(.scrolled-down) {
|
||||
top: -71px;
|
||||
top: -110px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@media screen and (max-width: 425px) {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* Slowly decrease the gap to ensure the main search bar doesn't get big enough again and pop back up in the nav. */
|
||||
body.split-nav & {
|
||||
@media screen and (max-width: 380px) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 375px) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 370px) {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 350px) {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -351,19 +450,90 @@
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
// Make the box look a little more centered, inline with the rest of the nav items.
|
||||
margin-bottom: 2px;
|
||||
|
||||
:global(svg) {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input:focus-within + :global(svg),
|
||||
input:not(:placeholder-shown) + :global(svg) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 666px) {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 666px) {
|
||||
& input:not(.small) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:focus-within + .btns button:not(.face) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 415px) {
|
||||
:global(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.split-nav & {
|
||||
.search {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
input.small {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
width: 250px;
|
||||
width: 100%;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
box-shadow: 4px 4px 0px 0px $text-color;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
width 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
|
||||
&.small {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
box-shadow: 2px 2px 0px 0px $text-color;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 290px) {
|
||||
&.small {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btns {
|
||||
@@ -469,15 +639,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 666px) {
|
||||
input {
|
||||
width: 100%;
|
||||
|
||||
&:focus + .btns button:not(.face) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
<script lang="ts">
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import RequestMovie from "@/lib/request/RequestMovie.svelte";
|
||||
import RequestShow from "@/lib/request/RequestShow.svelte";
|
||||
import { baseURL } from "@/lib/util/api";
|
||||
import { toRelativeDate } from "@/lib/util/helpers";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import { type ArrRequestResponse, type TMDBMovieDetails, type TMDBShowDetails } from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
let allRequests: ArrRequestResponse[];
|
||||
let showBeingApproved: TMDBShowDetails | undefined;
|
||||
let movieBeingApproved: TMDBMovieDetails | undefined;
|
||||
let beingApprovedOriginalRequest: ArrRequestResponse | undefined;
|
||||
|
||||
async function getRequests() {
|
||||
try {
|
||||
allRequests = (await axios.get(`/arr/request/`)).data as ArrRequestResponse[];
|
||||
if (allRequests?.length > 0) {
|
||||
allRequests = allRequests?.sort((a, b) => {
|
||||
if (b.status === "PENDING") return 1;
|
||||
if (a.status === "PENDING") return -1;
|
||||
return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to get requests!", err);
|
||||
notify({ type: "error", text: "Failed when getting all requests!" });
|
||||
}
|
||||
}
|
||||
|
||||
async function deny(r: ArrRequestResponse) {
|
||||
try {
|
||||
await axios.post(`/arr/request/deny/${r.id}`);
|
||||
getRequests();
|
||||
} catch (err) {
|
||||
console.error("Failed to deny request!", err);
|
||||
notify({ type: "error", text: "Failed when denying request!" });
|
||||
}
|
||||
}
|
||||
|
||||
async function approve(r: ArrRequestResponse) {
|
||||
console.debug("Approving request:", r);
|
||||
if (r.content.type === "tv") {
|
||||
showBeingApproved = (await axios.get(`/content/tv/${r.content.tmdbId}`))
|
||||
.data as TMDBShowDetails;
|
||||
} else if (r.content.type === "movie") {
|
||||
movieBeingApproved = (await axios.get(`/content/movie/${r.content.tmdbId}`))
|
||||
.data as TMDBMovieDetails;
|
||||
} else {
|
||||
notify({ type: "error", text: "Unknown content type, can't continue approval!" });
|
||||
return;
|
||||
}
|
||||
beingApprovedOriginalRequest = r;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<h2>Media Requests</h2>
|
||||
<h5 class="norm">Manage and view all of your media requests.</h5>
|
||||
|
||||
{#await getRequests()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
<div class="request-container">
|
||||
{#each allRequests as r}
|
||||
<div class={`request ${r.content.type}`}>
|
||||
<div class="poster">
|
||||
<img src={`${baseURL}/img${r.content?.poster_path}`} alt="" loading="lazy" />
|
||||
<span title={r.serverName}>{r.serverName}</span>
|
||||
</div>
|
||||
<img
|
||||
class="backdrop"
|
||||
src={`${baseURL}/img${r.content?.poster_path}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="wordsnstuff">
|
||||
<h2 class="norm">
|
||||
<a
|
||||
data-sveltekit-preload-data="tap"
|
||||
href={`/${r.content.type}/${r.content.tmdbId}`}
|
||||
class="plain"
|
||||
>
|
||||
{r.content.title}
|
||||
</a>
|
||||
{#if r.content.release_date}
|
||||
<span>{new Date(r.content.release_date).getFullYear()}</span>
|
||||
{/if}
|
||||
</h2>
|
||||
<p>{r.content.overview}</p>
|
||||
<div class="btns">
|
||||
{#if r.username}
|
||||
<span
|
||||
style="font-size: 12px; margin-top: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
>
|
||||
By {r.username}  •  {toRelativeDate(new Date(r.createdAt))}
|
||||
</span>
|
||||
{/if}
|
||||
{#if r.status === "PENDING"}
|
||||
<button class="decline" on:click={() => deny(r)}>Decline</button>
|
||||
<button class="approve" on:click={() => approve(r)}>Approve</button>
|
||||
{:else}
|
||||
<button disabled>{r.status}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:catch err}
|
||||
<PageError error={err} pretty="Failed to fetch requests!" />
|
||||
{/await}
|
||||
|
||||
{#if showBeingApproved}
|
||||
<RequestShow
|
||||
content={showBeingApproved}
|
||||
approveMode={true}
|
||||
originalRequest={beingApprovedOriginalRequest}
|
||||
onClose={() => {
|
||||
showBeingApproved = undefined;
|
||||
// HACK
|
||||
getRequests();
|
||||
}}
|
||||
/>
|
||||
{:else if movieBeingApproved}
|
||||
<RequestMovie
|
||||
content={movieBeingApproved}
|
||||
approveMode={true}
|
||||
originalRequest={beingApprovedOriginalRequest}
|
||||
onClose={() => {
|
||||
movieBeingApproved = undefined;
|
||||
// HACK
|
||||
getRequests();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.request-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.content .request {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
position: relative;
|
||||
gap: 10px;
|
||||
max-height: 245px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background-color: $img-blend-multiply-bg-col;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 150px;
|
||||
height: 225px;
|
||||
border-radius: 6px;
|
||||
|
||||
&.backdrop {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
transform: translateY(-25%);
|
||||
filter: blur(4px) grayscale(80%);
|
||||
mix-blend-mode: multiply;
|
||||
z-index: -2;
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
transform: unset;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.tv {
|
||||
.poster > span {
|
||||
background-color: #35c5f4;
|
||||
}
|
||||
}
|
||||
|
||||
.poster {
|
||||
position: relative;
|
||||
|
||||
> span {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
left: 3px;
|
||||
color: black;
|
||||
background-color: #ffc230;
|
||||
padding: 3px 5px;
|
||||
font-size: 11px;
|
||||
border-radius: 5px;
|
||||
max-width: calc(100% - 6px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
|
||||
&.wordsnstuff {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& > h2 {
|
||||
font-size: 22px;
|
||||
white-space: wrap;
|
||||
overflow: visible;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
& span:last-of-type {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
& > p {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 5px;
|
||||
margin-top: auto;
|
||||
padding-top: 3px;
|
||||
|
||||
& button {
|
||||
width: fit-content;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&.approve {
|
||||
background-color: $success;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
& button:first-of-type {
|
||||
margin-left: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
flex-flow: column;
|
||||
max-height: unset;
|
||||
align-items: center;
|
||||
|
||||
.wordsnstuff p {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 30px 30px 30px;
|
||||
|
||||
.inner {
|
||||
min-width: 700px;
|
||||
max-width: 700px;
|
||||
overflow: hidden;
|
||||
|
||||
h2 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& > div:not(:first-of-type) {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 740px) {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,9 +15,12 @@
|
||||
import { page } from "$app/stores";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import FollowedThoughts from "@/lib/content/FollowedThoughts.svelte";
|
||||
import { updatePlayed } from "@/lib/util/api.js";
|
||||
import { removeWatched, updatePlayed } from "@/lib/util/api.js";
|
||||
import GamePoster from "@/lib/poster/GamePoster.svelte";
|
||||
import { getPlayedDependedProps } from "@/lib/util/helpers";
|
||||
import tooltip from "@/lib/actions/tooltip.js";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import MyThoughts from "@/lib/content/MyThoughts.svelte";
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -67,12 +70,17 @@
|
||||
})();
|
||||
}
|
||||
|
||||
function contentChanged(newStatus?: WatchedStatus, newRating?: number, newThoughts?: string) {
|
||||
async function contentChanged(
|
||||
newStatus?: WatchedStatus,
|
||||
newRating?: number,
|
||||
newThoughts?: string,
|
||||
pinned?: boolean
|
||||
): Promise<boolean> {
|
||||
if (!gameId) {
|
||||
console.error("contentChanged: no gameId");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
updatePlayed(gameId, newStatus, newRating, newThoughts);
|
||||
return await updatePlayed(gameId, newStatus, newRating, newThoughts, pinned);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -151,6 +159,35 @@
|
||||
<VideoEmbedModal embed={trailer} closed={() => (trailerShown = false)} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if wListItem}
|
||||
<div class="other-side">
|
||||
<button
|
||||
on:click={() => {
|
||||
if (wListItem?.pinned) {
|
||||
contentChanged(undefined, undefined, undefined, false);
|
||||
} else {
|
||||
contentChanged(undefined, undefined, undefined, true);
|
||||
}
|
||||
}}
|
||||
use:tooltip={{
|
||||
text: `${wListItem?.pinned ? "Unpin from" : "Pin to"} top of list`,
|
||||
pos: "bot"
|
||||
}}
|
||||
>
|
||||
<Icon i={wListItem?.pinned ? "unpin" : "pin"} wh={19} />
|
||||
</button>
|
||||
<button
|
||||
class="delete-btn"
|
||||
on:click={() =>
|
||||
wListItem
|
||||
? removeWatched(wListItem.id)
|
||||
: console.error("no wlistItem.. can't delete")}
|
||||
use:tooltip={{ text: "Delete", pos: "bot" }}
|
||||
>
|
||||
<Icon i="trash" wh={19} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <ProvidersList providers={game["watch/providers"]} /> -->
|
||||
@@ -163,17 +200,11 @@
|
||||
<Rating rating={wListItem?.rating} onChange={(n) => contentChanged(undefined, n)} />
|
||||
<Status status={wListItem?.status} isForGame={true} onChange={(n) => contentChanged(n)} />
|
||||
{#if wListItem}
|
||||
<textarea
|
||||
name="Thoughts"
|
||||
rows="3"
|
||||
placeholder={`My thoughts on ${game.name}`}
|
||||
value={wListItem?.thoughts}
|
||||
on:blur={(e) => {
|
||||
if (wListItem?.thoughts === e.currentTarget.value) {
|
||||
// thoughts didn't change
|
||||
return;
|
||||
}
|
||||
contentChanged(undefined, undefined, e.currentTarget?.value);
|
||||
<MyThoughts
|
||||
contentTitle={game.name}
|
||||
thoughts={wListItem?.thoughts}
|
||||
onChange={(newThoughts) => {
|
||||
return contentChanged(undefined, undefined, newThoughts);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -268,13 +299,15 @@
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
margin-top: auto;
|
||||
|
||||
a.btn,
|
||||
button {
|
||||
@@ -295,6 +328,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.other-side {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
|
||||
@media screen and (min-width: 900px) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
&:hover {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +378,10 @@
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
max-width: 380px;
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
max-width: 340px;
|
||||
}
|
||||
}
|
||||
|
||||
.creators {
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
import { importedList } from "@/store";
|
||||
import { onMount } from "svelte";
|
||||
import papa from "papaparse";
|
||||
import type { ImportedList, MovaryHistory, MovaryRatings, MovaryWatchlist } from "@/types";
|
||||
import { json } from "@sveltejs/kit";
|
||||
import type {
|
||||
ImportedList,
|
||||
MovaryHistory,
|
||||
MovaryRatings,
|
||||
MovaryWatchlist,
|
||||
Watched
|
||||
} from "@/types";
|
||||
|
||||
let isDragOver = false;
|
||||
let isLoading = false;
|
||||
@@ -234,6 +239,136 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function processWatcharrFile(files?: FileList | null) {
|
||||
try {
|
||||
console.log("processWatcharrFile", files);
|
||||
if (!files || files?.length <= 0) {
|
||||
console.error("processWatcharrFile", "No files to process!");
|
||||
notify({
|
||||
type: "error",
|
||||
text: "File not found in dropped items. Please try again or refresh.",
|
||||
time: 6000
|
||||
});
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
if (files.length > 1) {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Only one file at a time is supported. Continuing with the first.",
|
||||
time: 6000
|
||||
});
|
||||
}
|
||||
// Currently only support for importing one file at a time
|
||||
const file = files[0];
|
||||
if (file.type !== "application/json") {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Must be a Watcharr JSON export file"
|
||||
});
|
||||
isLoading = false;
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
// Build toImport array
|
||||
const toImport: ImportedList[] = [];
|
||||
const fileText = await readFile(new FileReader(), file);
|
||||
const jsonData = JSON.parse(fileText) as Watched[];
|
||||
for (const v of jsonData) {
|
||||
if (!v.content || !v.content.title) {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Item in export has no content or a missing title! Look in console for more details."
|
||||
});
|
||||
console.error(
|
||||
"Can't add export item to import table! It has no content or a missing content.title! Item:",
|
||||
v
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const t: ImportedList = {
|
||||
tmdbId: v.content.tmdbId,
|
||||
name: v.content.title,
|
||||
year: new Date(v.content.release_date)?.getFullYear()?.toString(),
|
||||
type: v.content.type,
|
||||
rating: v.rating,
|
||||
status: v.status,
|
||||
thoughts: v.thoughts,
|
||||
// datesWatched: [new Date(v.createdAt)], // Shouldn't need this, all activity will be imported, including ADDED_WATCHED activity
|
||||
activity: v.activity,
|
||||
watchedEpisodes: v.watchedEpisodes,
|
||||
watchedSeasons: v.watchedSeasons
|
||||
};
|
||||
toImport.push(t);
|
||||
}
|
||||
console.log("toImport:", toImport);
|
||||
importedList.set({
|
||||
data: JSON.stringify(toImport),
|
||||
type: "watcharr"
|
||||
});
|
||||
goto("/import/process");
|
||||
} catch (err) {
|
||||
isLoading = false;
|
||||
notify({ type: "error", text: "Failed to read file!" });
|
||||
console.error("import: Failed to read file!", err);
|
||||
}
|
||||
}
|
||||
|
||||
function processFilesMyAnimeList(files?: FileList | null) {
|
||||
try {
|
||||
console.log("processFilesMyAnimeList", files);
|
||||
if (!files || files?.length <= 0) {
|
||||
console.error("processFilesMyAnimeList", "No files to process!");
|
||||
notify({
|
||||
type: "error",
|
||||
text: "File not found in dropped items. Please try again or refresh.",
|
||||
time: 6000
|
||||
});
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
if (files.length > 1) {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Only one file at a time is supported. Continuing with the first.",
|
||||
time: 6000
|
||||
});
|
||||
}
|
||||
// Currently only support for importing one file at a time
|
||||
const file = files[0];
|
||||
if (file.type !== "text/xml") {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Your MyAnimeList export should be a xml file."
|
||||
});
|
||||
isLoading = false;
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
const r = new FileReader();
|
||||
r.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
if (r.result) {
|
||||
importedList.set({
|
||||
data: r.result.toString(),
|
||||
type: "myanimelist"
|
||||
});
|
||||
goto("/import/process");
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
r.readAsText(file);
|
||||
} catch (err) {
|
||||
isLoading = false;
|
||||
notify({ type: "error", text: "Failed to read file!" });
|
||||
console.error("import: Failed to read file!", err);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!localStorage.getItem("token")) {
|
||||
goto("/login");
|
||||
@@ -265,6 +400,13 @@
|
||||
filesSelected={(f) => processFilesMovary(f)}
|
||||
allowSelectMultipleFiles
|
||||
/>
|
||||
|
||||
<DropFileButton text="Watcharr Export" filesSelected={(f) => processWatcharrFile(f)} />
|
||||
|
||||
<DropFileButton
|
||||
text="MyAnimeList Export"
|
||||
filesSelected={(f) => processFilesMyAnimeList(f)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,7 +417,7 @@
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 30px 0 30px;
|
||||
padding: 0 30px 30px 30px;
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
@@ -283,6 +425,10 @@
|
||||
min-width: 400px;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.big-btns {
|
||||
|
||||
@@ -129,6 +129,130 @@
|
||||
text: "Processing failed!. Please report this issue if it persists."
|
||||
});
|
||||
}
|
||||
} else if (list?.type === "watcharr") {
|
||||
importText = "Watcharr";
|
||||
try {
|
||||
const s = JSON.parse(list.data);
|
||||
// Builds imported list in previous step for ease.
|
||||
rList = s;
|
||||
} catch (err) {
|
||||
console.error("Watcharr import processing failed!", err);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Processing failed!. Please report this issue if it persists."
|
||||
});
|
||||
}
|
||||
} else if (list?.type === "myanimelist") {
|
||||
importText = "MyAnimeList";
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(list.data.trim(), "application/xml");
|
||||
const errorNode = doc.querySelector("parsererror");
|
||||
if (errorNode) {
|
||||
console.error("MyAnimeList parse error:", errorNode);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "An error occurred while parsing your MyAnimeList export!"
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log(doc.documentElement.querySelectorAll("anime"));
|
||||
const animeNodes = doc.documentElement.querySelectorAll("anime");
|
||||
if (animeNodes?.length <= 0) {
|
||||
console.error("MyAnimeList: Found no anime nodes:", animeNodes);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "We found no Anime entries in your export file!"
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < animeNodes.length; i++) {
|
||||
const animeNode = animeNodes[i];
|
||||
const titleNode = animeNode.querySelector("series_title");
|
||||
console.debug("Processing anime:", titleNode?.textContent);
|
||||
if (!titleNode?.textContent) {
|
||||
console.error("No title found for an anime!", animeNode, titleNode);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "An anime failed to import, a title was not found! Check console for more details."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const l: ImportedList = { name: titleNode.textContent };
|
||||
const scoreNode = animeNode.querySelector("my_score");
|
||||
if (scoreNode?.textContent) {
|
||||
l.rating = Number(scoreNode.textContent);
|
||||
}
|
||||
const statusNode = animeNode.querySelector("my_status");
|
||||
if (statusNode?.textContent) {
|
||||
let malStatus = statusNode.textContent?.toLowerCase();
|
||||
if (malStatus === "on-hold") {
|
||||
l.status = "HOLD";
|
||||
} else if (malStatus === "dropped") {
|
||||
l.status = "DROPPED";
|
||||
} else if (malStatus === "plan to watch") {
|
||||
l.status = "PLANNED";
|
||||
} else if (malStatus === "watching") {
|
||||
l.status = "WATCHING";
|
||||
} else if (malStatus === "completed") {
|
||||
l.status = "FINISHED";
|
||||
} else {
|
||||
console.warn(
|
||||
"Anime has no status or an unrecognized status:",
|
||||
malStatus,
|
||||
"anime_title:",
|
||||
titleNode.textContent
|
||||
);
|
||||
}
|
||||
}
|
||||
const typeNode = animeNode.querySelector("series_type");
|
||||
if (typeNode?.textContent) {
|
||||
const malSeriesType = typeNode.textContent?.toLowerCase();
|
||||
if (malSeriesType === "tv" || malSeriesType === "movie") {
|
||||
l.type = malSeriesType;
|
||||
} else {
|
||||
console.warn(
|
||||
"Anime has no type or an unrecognized type:",
|
||||
malSeriesType,
|
||||
"anime_title:",
|
||||
titleNode.textContent
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const startDateNode = animeNode.querySelector("my_start_date");
|
||||
const finishDateNode = animeNode.querySelector("my_finish_date");
|
||||
if (startDateNode?.textContent) {
|
||||
// For start date, we can simply add the activity manually.
|
||||
l.activity = [
|
||||
// We don't need all the data when importing activity.
|
||||
// customDate must be a date object.
|
||||
{
|
||||
type: "STATUS_CHANGED",
|
||||
data: "WATCHING",
|
||||
customDate: new Date(startDateNode.textContent)
|
||||
}
|
||||
] as any[];
|
||||
}
|
||||
if (finishDateNode?.textContent) {
|
||||
l.datesWatched = [new Date(finishDateNode.textContent)];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Processing start/finish times for anime failed!", err);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed to process start/finish times for an anime! Check console for more details."
|
||||
});
|
||||
}
|
||||
rList.push(l);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("MyAnimeList import failed!", err);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed to process import data!"
|
||||
});
|
||||
}
|
||||
}
|
||||
// TODO: remove duplicate names in list
|
||||
return list;
|
||||
@@ -410,87 +534,15 @@
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: 410px) {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
border-spacing: 0px;
|
||||
border: 1px solid $accent-color;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
|
||||
th {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
transition: padding 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
border-top-left-radius: 10px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
|
||||
&.loading-col {
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
th {
|
||||
background-color: $accent-color;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
td:first-of-type {
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
td:last-of-type {
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(odd) td {
|
||||
background-color: $accent-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-importing {
|
||||
th {
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding-left: 3px;
|
||||
|
||||
input {
|
||||
padding: 7px 0;
|
||||
|
||||
&:focus {
|
||||
padding: 7px 5px;
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 5px;
|
||||
|
||||
&.icon-cell {
|
||||
padding-right: 3px;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.year {
|
||||
width: 70px;
|
||||
}
|
||||
@@ -498,36 +550,23 @@
|
||||
&.type {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.is-importing {
|
||||
th {
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding-left: 3px;
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
color: $text-color;
|
||||
border: 0;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
padding: 7px 10px;
|
||||
transition: padding 100ms ease;
|
||||
padding: 7px 0;
|
||||
|
||||
&[type="number"] {
|
||||
appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
&.delete {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: $placeholder-color;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
}
|
||||
&:focus {
|
||||
padding: 7px 5px;
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { getOrdinalSuffix, monthsShort, userHasPermission } from "@/lib/util/helpers";
|
||||
import { UserPermission, type ManagedUser } from "@/types";
|
||||
import axios from "axios";
|
||||
import EditUserModal from "./modals/EditUserModal.svelte";
|
||||
|
||||
const currentYear = new Date(Date.now()).getFullYear();
|
||||
|
||||
let allUsers: ManagedUser[];
|
||||
let editingUser: ManagedUser | undefined;
|
||||
|
||||
async function getUsers() {
|
||||
allUsers = (await axios.get(`/server/users`)).data as ManagedUser[];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<h2>User Management</h2>
|
||||
<h5 class="norm">Take control and manage all the users using your server.</h5>
|
||||
|
||||
{#await getUsers()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Private</th>
|
||||
<th>Joined</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{#each allUsers as u}
|
||||
{@const joinDate = new Date(u.createdAt)}
|
||||
<tr>
|
||||
<td class="username">
|
||||
<div class={`type-${u.type}`}>
|
||||
{#if u.type == 1}
|
||||
<Icon i="jellyfin" wh={20} />
|
||||
{:else if u.type == 2}
|
||||
<Icon i="plex" wh={20} />
|
||||
{:else}
|
||||
<span
|
||||
style="font-family: 'Rampart One'; font-weight: bold; font-size: 21px; line-height: 20px; user-select: none;"
|
||||
>
|
||||
W
|
||||
</span>
|
||||
{/if}
|
||||
{u.username}
|
||||
{#if userHasPermission(u.permissions, UserPermission.PERM_ADMIN)}
|
||||
<span class="tag">Admin</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>{u.private === true ? "Yes" : "No"}</td>
|
||||
<td>
|
||||
{joinDate.getDate()}{getOrdinalSuffix(joinDate.getDate())}
|
||||
{monthsShort[joinDate.getMonth()]}
|
||||
{joinDate.getFullYear() === currentYear
|
||||
? ""
|
||||
: `'${String(joinDate.getFullYear()).substring(2, 4)}`}</td
|
||||
>
|
||||
<td>
|
||||
<button class="plain" on:click={() => (editingUser = u)}>
|
||||
<Icon i="chevron" facing="right" wh={24} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</table>
|
||||
|
||||
{#if editingUser}
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
onClose={() => {
|
||||
editingUser = undefined;
|
||||
getUsers(); // lazyness, but also scientifically doesn't matter
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<PageError error={err} pretty="Failed to fetch users!" />
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
table {
|
||||
td {
|
||||
padding: 12px 15px;
|
||||
word-wrap: anywhere;
|
||||
|
||||
&.username > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
gap: 10px;
|
||||
|
||||
&.type-1 :global(svg) {
|
||||
fill: $text-color;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
background-color: $accent-color;
|
||||
outline: 1px solid $bg-color;
|
||||
border-radius: 5px;
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
flex-flow: column;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(button) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 15px;
|
||||
|
||||
&:hover :global(svg) {
|
||||
transform: rotate(180deg) translateX(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 30px 30px 30px;
|
||||
|
||||
.inner {
|
||||
min-width: 700px;
|
||||
max-width: 700px;
|
||||
overflow: hidden;
|
||||
|
||||
h2 {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& > div:not(:first-of-type) {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 740px) {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import Checkbox from "@/lib/Checkbox.svelte";
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import Setting from "@/lib/settings/Setting.svelte";
|
||||
import SettingsList from "@/lib/settings/SettingsList.svelte";
|
||||
import { userHasPermission } from "@/lib/util/helpers";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import { UserPermission, type ManagedUser } from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
interface UpdateUserRequest {
|
||||
permissions?: number;
|
||||
}
|
||||
|
||||
export let user: ManagedUser;
|
||||
export let onClose: () => void;
|
||||
|
||||
let error: string;
|
||||
let formDisabled = false;
|
||||
|
||||
// Things we have changed
|
||||
let changedPerms = false;
|
||||
|
||||
async function save() {
|
||||
// If nothing changed.. error
|
||||
if (!changedPerms) {
|
||||
error = "Nothing has been changed";
|
||||
return;
|
||||
}
|
||||
if (!error) {
|
||||
try {
|
||||
const toUpdate: UpdateUserRequest = {};
|
||||
if (changedPerms) {
|
||||
toUpdate["permissions"] = user.permissions;
|
||||
}
|
||||
const res = await axios.post(`/server/users/${user.id}`, toUpdate);
|
||||
if (res.status === 200) {
|
||||
notify({
|
||||
type: "success",
|
||||
text: "Changes saved!"
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to save user!", err);
|
||||
error = `Failed to save`;
|
||||
if (err?.response?.data?.error) {
|
||||
error = err.response.data.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function userTogglePermission(perm: UserPermission) {
|
||||
user.permissions ^= perm;
|
||||
changedPerms = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title={`Edit User`} desc={`Configuring ${user.username}`} maxWidth="500px" {onClose}>
|
||||
{#if error}
|
||||
<span class="error">{error}!</span>
|
||||
{/if}
|
||||
|
||||
<h3 class="norm">Permissions</h3>
|
||||
|
||||
<SettingsList>
|
||||
<Setting title="Admin" desc="Give user admin, overrides all other permissions." row>
|
||||
<Checkbox
|
||||
name="USER_PERM_ADMIN"
|
||||
value={userHasPermission(user.permissions, UserPermission.PERM_ADMIN)}
|
||||
toggled={() => {
|
||||
userTogglePermission(UserPermission.PERM_ADMIN);
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<Setting title="Request Content" desc="Give user permission to request content." row>
|
||||
<Checkbox
|
||||
name="USER_PERM_REQUEST_CONTENT"
|
||||
value={userHasPermission(user.permissions, UserPermission.PERM_REQUEST_CONTENT)}
|
||||
toggled={() => {
|
||||
userTogglePermission(UserPermission.PERM_REQUEST_CONTENT);
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<Setting title="Auto Approve Content Request" desc="Auto approve user's content requests." row>
|
||||
<Checkbox
|
||||
name="PERM_REQUEST_CONTENT_AUTO_APPROVE"
|
||||
value={userHasPermission(
|
||||
user.permissions,
|
||||
UserPermission.PERM_REQUEST_CONTENT_AUTO_APPROVE
|
||||
)}
|
||||
toggled={() => {
|
||||
userTogglePermission(UserPermission.PERM_REQUEST_CONTENT_AUTO_APPROVE);
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<div class="btns">
|
||||
<button on:click={() => save()}>Save</button>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
|
||||
:first-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: rgb(221, 48, 48);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -5,9 +5,10 @@
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import Status from "@/lib/Status.svelte";
|
||||
import HorizontalList from "@/lib/HorizontalList.svelte";
|
||||
import { contentExistsOnJellyfin, updateWatched } from "@/lib/util/api";
|
||||
import { serverFeatures, watchedList } from "@/store";
|
||||
import { contentExistsOnJellyfin, removeWatched, updateWatched } from "@/lib/util/api";
|
||||
import { serverFeatures, userSettings, watchedList } from "@/store";
|
||||
import type {
|
||||
ArrDetailsResponse,
|
||||
TMDBContentCredits,
|
||||
TMDBContentCreditsCrew,
|
||||
TMDBMovieDetails,
|
||||
@@ -26,6 +27,11 @@
|
||||
import RequestMovie from "@/lib/request/RequestMovie.svelte";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import FollowedThoughts from "@/lib/content/FollowedThoughts.svelte";
|
||||
import ArrRequestButton from "@/lib/request/ArrRequestButton.svelte";
|
||||
import tooltip from "@/lib/actions/tooltip.js";
|
||||
import MyThoughts from "@/lib/content/MyThoughts.svelte";
|
||||
|
||||
$: settings = $userSettings;
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -33,6 +39,7 @@
|
||||
let requestModalShown = false;
|
||||
let trailerShown = false;
|
||||
let jellyfinUrl: string | undefined;
|
||||
let arrRequestButtonComp: ArrRequestButton;
|
||||
|
||||
$: wListItem = $watchedList.find(
|
||||
(w) => w.content?.type === "movie" && w.content?.tmdbId === data.movieId
|
||||
@@ -41,6 +48,7 @@
|
||||
let movieId: number | undefined;
|
||||
let movie: TMDBMovieDetails | undefined;
|
||||
let pageError: Error | undefined;
|
||||
let arrStatus: ArrDetailsResponse | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = page.subscribe((value) => {
|
||||
@@ -62,7 +70,9 @@
|
||||
if (!movieId) {
|
||||
return;
|
||||
}
|
||||
const data = (await axios.get(`/content/movie/${movieId}`)).data as TMDBMovieDetails;
|
||||
const data = (
|
||||
await axios.get(`/content/movie/${movieId}`, { params: { region: settings?.country } })
|
||||
).data as TMDBMovieDetails;
|
||||
if (data.videos?.results?.length > 0) {
|
||||
const t = data.videos.results.find((v) => v.type?.toLowerCase() === "trailer");
|
||||
if (t?.key) {
|
||||
@@ -93,8 +103,17 @@
|
||||
return credits;
|
||||
}
|
||||
|
||||
function contentChanged(newStatus?: WatchedStatus, newRating?: number, newThoughts?: string) {
|
||||
updateWatched(data.movieId, "movie", newStatus, newRating, newThoughts);
|
||||
async function contentChanged(
|
||||
newStatus?: WatchedStatus,
|
||||
newRating?: number,
|
||||
newThoughts?: string,
|
||||
pinned?: boolean
|
||||
): Promise<boolean> {
|
||||
if (!data.movieId) {
|
||||
console.error("contentChanged: no movieId");
|
||||
return false;
|
||||
}
|
||||
return await updateWatched(data.movieId, "movie", newStatus, newRating, newThoughts, pinned);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -155,8 +174,42 @@
|
||||
<Icon i="jellyfin" wh={14} />Play On Jellyfin
|
||||
</a>
|
||||
{/if}
|
||||
{#if $serverFeatures.radarr}
|
||||
<button on:click={() => (requestModalShown = !requestModalShown)}>Request</button>
|
||||
{#if $serverFeatures.radarr && data.movieId}
|
||||
<ArrRequestButton
|
||||
type="movie"
|
||||
tmdbId={data.movieId}
|
||||
openRequestModal={() => (requestModalShown = !requestModalShown)}
|
||||
bind:this={arrRequestButtonComp}
|
||||
/>
|
||||
{/if}
|
||||
{#if wListItem}
|
||||
<div class="other-side">
|
||||
<button
|
||||
on:click={() => {
|
||||
if (wListItem?.pinned) {
|
||||
contentChanged(undefined, undefined, undefined, false);
|
||||
} else {
|
||||
contentChanged(undefined, undefined, undefined, true);
|
||||
}
|
||||
}}
|
||||
use:tooltip={{
|
||||
text: `${wListItem?.pinned ? "Unpin from" : "Pin to"} top of list`,
|
||||
pos: "bot"
|
||||
}}
|
||||
>
|
||||
<Icon i={wListItem?.pinned ? "unpin" : "pin"} wh={19} />
|
||||
</button>
|
||||
<button
|
||||
class="delete-btn"
|
||||
on:click={() =>
|
||||
wListItem
|
||||
? removeWatched(wListItem.id)
|
||||
: console.error("no wlistItem.. can't delete")}
|
||||
use:tooltip={{ text: "Delete", pos: "bot" }}
|
||||
>
|
||||
<Icon i="trash" wh={19} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -166,7 +219,15 @@
|
||||
</div>
|
||||
|
||||
{#if requestModalShown}
|
||||
<RequestMovie content={movie} onClose={() => (requestModalShown = false)} />
|
||||
<RequestMovie
|
||||
content={movie}
|
||||
onClose={(reqResp) => {
|
||||
requestModalShown = false;
|
||||
if (reqResp) {
|
||||
arrRequestButtonComp.setExistingRequest(reqResp);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="page">
|
||||
@@ -175,17 +236,11 @@
|
||||
<Rating rating={wListItem?.rating} onChange={(n) => contentChanged(undefined, n)} />
|
||||
<Status status={wListItem?.status} onChange={(n) => contentChanged(n)} />
|
||||
{#if wListItem}
|
||||
<textarea
|
||||
name="Thoughts"
|
||||
rows="3"
|
||||
placeholder={`My thoughts on ${movie.title}`}
|
||||
value={wListItem?.thoughts}
|
||||
on:blur={(e) => {
|
||||
if (wListItem?.thoughts === e.currentTarget.value) {
|
||||
// thoughts didn't change
|
||||
return;
|
||||
}
|
||||
contentChanged(undefined, undefined, e.currentTarget?.value);
|
||||
<MyThoughts
|
||||
contentTitle={movie.title}
|
||||
thoughts={wListItem?.thoughts}
|
||||
onChange={(newThoughts) => {
|
||||
return contentChanged(undefined, undefined, newThoughts);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -295,13 +350,15 @@
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
margin-top: auto;
|
||||
|
||||
a.btn,
|
||||
button {
|
||||
@@ -322,6 +379,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.other-side {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
|
||||
@media screen and (min-width: 900px) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
&:hover {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +429,10 @@
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
max-width: 380px;
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
max-width: 340px;
|
||||
}
|
||||
}
|
||||
|
||||
.creators {
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
onRatingChanged={(r) => updateWatched(c.id, c.media_type, undefined, r)}
|
||||
onDeleteClicked={() => removeWatched(c.id)}
|
||||
{...getWatchedDependedProps(c.id, c.media_type, wList)}
|
||||
fluidSize
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import UserAvatar from "@/lib/img/UserAvatar.svelte";
|
||||
import PwChangeModal from "@/routes/(app)/profile/modals/PwChangeModal.svelte";
|
||||
import SyncModal from "./modals/SyncModal.svelte";
|
||||
import RegionDropDown from "@/lib/RegionDropDown.svelte";
|
||||
|
||||
$: user = $userInfo;
|
||||
$: settings = $userSettings;
|
||||
@@ -24,6 +25,7 @@
|
||||
let privateThoughtsDisabled = false;
|
||||
let exportDisabled = false;
|
||||
let hideSpoilersDisabled = false;
|
||||
let countryDisabled = false;
|
||||
let includePreviouslyWatchedDisabled = false;
|
||||
let pwChangeModalOpen = false;
|
||||
let getProfilePromise = getProfile();
|
||||
@@ -194,6 +196,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Setting
|
||||
title="Country"
|
||||
desc="What country would you like to see available streaming providers for?"
|
||||
>
|
||||
<RegionDropDown
|
||||
selectedCountry={settings?.country}
|
||||
disabled={countryDisabled}
|
||||
onChange={(c) => {
|
||||
countryDisabled = true;
|
||||
updateUserSetting("country", c, () => {
|
||||
countryDisabled = false;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<Setting title="Private" desc="Hide your profile from others?" row>
|
||||
<Checkbox
|
||||
name="private"
|
||||
@@ -261,6 +279,7 @@
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<div class="row btns">
|
||||
<button on:click={() => goto("/import")}>Import</button>
|
||||
<button on:click={() => downloadWatchedList()} disabled={exportDisabled}>Export</button>
|
||||
@@ -273,7 +292,7 @@
|
||||
{/if}
|
||||
{#if user?.type === UserType?.Jellyfin}
|
||||
<button on:click={() => (jellyfinSyncModalOpen = true)} disabled={exportDisabled}>
|
||||
Sync With Jellyfin
|
||||
Sync With {localStorage.getItem("useEmby") ? "Emby" : "Jellyfin"}
|
||||
</button>
|
||||
{/if}
|
||||
{#if user?.type === UserType?.Plex}
|
||||
|
||||
@@ -113,7 +113,11 @@
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title="{type === 'jellyfin' ? 'Jellyfin' : 'Plex'} Sync"
|
||||
title="{type === 'jellyfin'
|
||||
? localStorage.getItem('useEmby')
|
||||
? 'Emby'
|
||||
: 'Jellyfin'
|
||||
: 'Plex'} Sync"
|
||||
maxWidth="700px"
|
||||
onClose={modalClose}
|
||||
>
|
||||
|
||||
@@ -22,11 +22,8 @@
|
||||
import GamePoster from "@/lib/poster/GamePoster.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { notify } from "@/lib/util/notify.js";
|
||||
|
||||
export let data;
|
||||
|
||||
$: searchQ = $searchQuery;
|
||||
$: wList = $watchedList;
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import { onNavigate } from "$app/navigation";
|
||||
|
||||
type GameWithMediaType = GameSearch & { media_type: "game" };
|
||||
type CombinedResult =
|
||||
@@ -34,12 +31,25 @@
|
||||
| ContentSearchTv
|
||||
| ContentSearchPerson
|
||||
| GameWithMediaType;
|
||||
type SearchFilterTypes = MediaType | "game";
|
||||
|
||||
export let data;
|
||||
|
||||
let allSearchResults: CombinedResult[];
|
||||
let searchResults: CombinedResult[];
|
||||
let activeSearchFilter: SearchFilterTypes | undefined;
|
||||
|
||||
$: searchQ = $searchQuery;
|
||||
$: wList = $watchedList;
|
||||
$: (allSearchResults, activeSearchFilter), filterResults();
|
||||
|
||||
async function search(query: string) {
|
||||
const f = get(serverFeatures);
|
||||
if (!f.games) {
|
||||
console.log("Search: Only for movies/tv");
|
||||
return (await axios.get<ContentSearch>(`/content/${query}`)).data.results;
|
||||
allSearchResults = (await axios.get<ContentSearch>(`/content/${query}`)).data.results;
|
||||
searchResults = allSearchResults;
|
||||
return;
|
||||
}
|
||||
console.log("Search: For movies/tv and games");
|
||||
// To get around promise.all rejecting both promises when one fails,
|
||||
@@ -61,38 +71,30 @@
|
||||
...g,
|
||||
media_type: "game"
|
||||
}));
|
||||
const d = new Array<CombinedResult>().concat
|
||||
.apply([], [r[0].data.results, games])
|
||||
?.sort((a, b) => {
|
||||
let name = "";
|
||||
if (a.media_type === "game" || a.media_type === "tv" || a.media_type === "person") {
|
||||
name = a.name ?? "";
|
||||
} else if (a.media_type === "movie") {
|
||||
name = a.title ?? "";
|
||||
}
|
||||
|
||||
let name2 = "";
|
||||
if (b.media_type === "game" || b.media_type === "tv" || b.media_type === "person") {
|
||||
name2 = b.name ?? "";
|
||||
} else if (b.media_type === "movie") {
|
||||
name2 = b.title ?? "";
|
||||
}
|
||||
|
||||
if (name < name2) {
|
||||
return 1;
|
||||
}
|
||||
if (name > name2) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return d;
|
||||
allSearchResults = new Array<CombinedResult>().concat.apply([], [r[0].data.results, games]);
|
||||
searchResults = allSearchResults;
|
||||
}
|
||||
|
||||
async function searchUsers(query: string) {
|
||||
return (await axios.get(`/user/search/${query}`)).data as PublicUser[];
|
||||
}
|
||||
|
||||
function setActiveSearchFilter(to: SearchFilterTypes) {
|
||||
if (activeSearchFilter === to) {
|
||||
activeSearchFilter = undefined;
|
||||
return;
|
||||
}
|
||||
activeSearchFilter = to;
|
||||
}
|
||||
|
||||
function filterResults() {
|
||||
if (!activeSearchFilter) {
|
||||
searchResults = allSearchResults;
|
||||
return;
|
||||
}
|
||||
searchResults = allSearchResults.filter((s) => s.media_type === activeSearchFilter);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!searchQ && data.slug) {
|
||||
searchQuery.set(data.slug);
|
||||
@@ -105,7 +107,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Content Search</title>
|
||||
<title>Search Results{data?.slug ? ` for '${data?.slug}'` : ""}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="content">
|
||||
@@ -121,11 +123,53 @@
|
||||
|
||||
{#await search(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<h2>Results</h2>
|
||||
{:then}
|
||||
<div class="results-filters-header">
|
||||
<h2>Results</h2>
|
||||
<div>
|
||||
{#if allSearchResults?.length > 0}
|
||||
{#if allSearchResults.find((s) => s.media_type === "movie") || activeSearchFilter === "movie"}
|
||||
<button
|
||||
class="plain"
|
||||
data-active={activeSearchFilter === "movie"}
|
||||
on:click={() => setActiveSearchFilter("movie")}
|
||||
>
|
||||
<Icon i="film" wh={20} /> Movies
|
||||
</button>
|
||||
{/if}
|
||||
{#if allSearchResults.find((s) => s.media_type === "tv") || activeSearchFilter === "tv"}
|
||||
<button
|
||||
class="plain"
|
||||
data-active={activeSearchFilter === "tv"}
|
||||
on:click={() => setActiveSearchFilter("tv")}
|
||||
>
|
||||
<Icon i="tv" wh={20} /> TV Shows
|
||||
</button>
|
||||
{/if}
|
||||
{#if allSearchResults.find((s) => s.media_type === "game") || activeSearchFilter === "game"}
|
||||
<button
|
||||
class="plain"
|
||||
data-active={activeSearchFilter === "game"}
|
||||
on:click={() => setActiveSearchFilter("game")}
|
||||
>
|
||||
<Icon i="gamepad" wh={20} /> Games
|
||||
</button>
|
||||
{/if}
|
||||
{#if allSearchResults.find((s) => s.media_type === "person") || activeSearchFilter === "person"}
|
||||
<button
|
||||
class="plain"
|
||||
data-active={activeSearchFilter === "person"}
|
||||
on:click={() => setActiveSearchFilter("person")}
|
||||
>
|
||||
<Icon i="people-nocircle" wh={20} /> People
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<PosterList>
|
||||
{#if results?.length > 0}
|
||||
{#each results as w (w.id)}
|
||||
{#if searchResults?.length > 0}
|
||||
{#each searchResults as w (w.id)}
|
||||
{#if w.media_type === "person"}
|
||||
<PersonPoster id={w.id} name={w.name} path={w.profile_path} />
|
||||
{:else if w.media_type === "game"}
|
||||
@@ -138,9 +182,14 @@
|
||||
firstReleaseDate: w.first_release_date
|
||||
}}
|
||||
{...getPlayedDependedProps(w.id, wList)}
|
||||
fluidSize
|
||||
/>
|
||||
{:else}
|
||||
<Poster media={w} {...getWatchedDependedProps(w.id, w.media_type, wList)} />
|
||||
<Poster
|
||||
media={w}
|
||||
{...getWatchedDependedProps(w.id, w.media_type, wList)}
|
||||
fluidSize
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
@@ -150,44 +199,6 @@
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load results!" error={err} />
|
||||
{/await}
|
||||
|
||||
<!-- {#await searchGames(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<h2>Results</h2>
|
||||
<PosterList>
|
||||
{#if results?.length > 0}
|
||||
{#each results as w (w.id)}
|
||||
<GamePoster media={w} />
|
||||
{/each}
|
||||
{:else}
|
||||
No Search Results!
|
||||
{/if}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load results!" error={err} />
|
||||
{/await} -->
|
||||
|
||||
<!-- {#await search(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<h2>Results</h2>
|
||||
<PosterList>
|
||||
{#if results?.results?.length > 0}
|
||||
{#each results.results as w (w.id)}
|
||||
{#if w.media_type === "person"}
|
||||
<PersonPoster id={w.id} name={w.name} path={w.profile_path} />
|
||||
{:else}
|
||||
<Poster media={w} {...getWatchedDependedProps(w.id, w.media_type, wList)} />
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
No Search Results!
|
||||
{/if}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load results!" error={err} />
|
||||
{/await} -->
|
||||
{:else}
|
||||
<h2>No Search Query!</h2>
|
||||
{/if}
|
||||
@@ -195,6 +206,60 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.results-filters-header {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 0 15px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
height: fit-content;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: $text-color;
|
||||
fill: $text-color;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease,
|
||||
outline 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&[data-active="true"] {
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
}
|
||||
|
||||
&[data-active="true"] {
|
||||
outline: 3px solid $accent-color;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
flex-flow: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script lang="ts">
|
||||
import Checkbox from "@/lib/Checkbox.svelte";
|
||||
import DropDown from "@/lib/DropDown.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import type { Content, RadarrSettings, ServerConfig, SonarrSettings } from "@/types";
|
||||
import type {
|
||||
Content,
|
||||
RadarrSettings,
|
||||
ServerConfig,
|
||||
SonarrSettings,
|
||||
DropDownItem
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import SonarrModal from "./modals/SonarrModal.svelte";
|
||||
import SettingsList from "@/lib/settings/SettingsList.svelte";
|
||||
@@ -15,6 +22,7 @@
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import Stat from "@/lib/stats/Stat.svelte";
|
||||
import TwitchModal from "./modals/TwitchModal.svelte";
|
||||
import RegionDropDown from "@/lib/RegionDropDown.svelte";
|
||||
|
||||
let serverConfig: ServerConfig;
|
||||
let sonarrModalOpen = false;
|
||||
@@ -30,6 +38,11 @@
|
||||
let jfDisabled = false;
|
||||
let tmdbkDisabled = false;
|
||||
let plexHostDisabled = false;
|
||||
let countryDisabled = false;
|
||||
let useEmbyDisabled = false;
|
||||
let selectedCountry: string;
|
||||
let countries: any;
|
||||
let countriesDropdown: DropDownItem[] = [];
|
||||
|
||||
async function getServerConfig() {
|
||||
serverConfig = (await axios.get(`/server/config`)).data as ServerConfig;
|
||||
@@ -80,6 +93,11 @@
|
||||
async function getServerStats() {
|
||||
return (await axios.get("/server/stats")).data as ServerStats;
|
||||
}
|
||||
|
||||
let jellyfinOrEmby = "Jellyfin";
|
||||
$: {
|
||||
jellyfinOrEmby = serverConfig?.USE_EMBY ? "Emby" : "Jellyfin";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
@@ -91,7 +109,7 @@
|
||||
{#await getServerStats()}
|
||||
<Spinner />
|
||||
{:then stats}
|
||||
<Stat name="Users" value={stats.users} large />
|
||||
<Stat name="Users" value={stats.users} href="/manage_users" large />
|
||||
<Stat name="Private Users" value={stats.privateUsers} large />
|
||||
<Stat name="Watched Movies" value={stats.watchedMovies} large />
|
||||
<Stat name="Watched Shows" value={stats.watchedShows} large />
|
||||
@@ -121,13 +139,28 @@
|
||||
{:then}
|
||||
<h3>General</h3>
|
||||
<Setting
|
||||
title="Jellyfin Host"
|
||||
desc="Point to your Jellyfin server to enable related features. Don't change server after
|
||||
title="Default Country"
|
||||
desc="Default country for new users. This can be changed per user and won't affect existing users."
|
||||
>
|
||||
<RegionDropDown
|
||||
selectedCountry={serverConfig.DEFAULT_COUNTRY}
|
||||
disabled={countryDisabled}
|
||||
onChange={(c) => {
|
||||
countryDisabled = true;
|
||||
updateServerConfig("DEFAULT_COUNTRY", c, () => {
|
||||
countryDisabled = false;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting
|
||||
title="{jellyfinOrEmby} Host"
|
||||
desc="Point to your {jellyfinOrEmby} server to enable related features. Don't change server after
|
||||
already using another."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://jellyfin.example.com"
|
||||
placeholder="https://{jellyfinOrEmby.toLowerCase()}.example.com"
|
||||
bind:value={serverConfig.JELLYFIN_HOST}
|
||||
on:blur={() => {
|
||||
jfDisabled = true;
|
||||
@@ -138,6 +171,23 @@
|
||||
disabled={jfDisabled}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting
|
||||
title="Use Emby"
|
||||
desc="Do you want to pretend you're using Emby instead of Jellyfin?"
|
||||
row
|
||||
>
|
||||
<Checkbox
|
||||
name="USE_EMBY"
|
||||
disabled={useEmbyDisabled}
|
||||
value={serverConfig.USE_EMBY}
|
||||
toggled={(on) => {
|
||||
useEmbyDisabled = true;
|
||||
updateServerConfig("USE_EMBY", on, () => {
|
||||
useEmbyDisabled = false;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting
|
||||
title="Plex Host"
|
||||
desc="Point to your Plex server to enable related features. Don't change server after
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
<SettingsList>
|
||||
<a href="https://watcharr.app/docs/server_config/game-support-igdb" target="_blank">
|
||||
Learn how to configure this options at Watcharr Docs.
|
||||
Learn how to configure this option at Watcharr Docs.
|
||||
</a>
|
||||
|
||||
<Setting title="Client ID" desc="Twitch application Client ID.">
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
import SimilarContent from "@/lib/content/SimilarContent.svelte";
|
||||
import Title from "@/lib/content/Title.svelte";
|
||||
import VideoEmbedModal from "@/lib/content/VideoEmbedModal.svelte";
|
||||
import { contentExistsOnJellyfin, updateWatched } from "@/lib/util/api";
|
||||
import { contentExistsOnJellyfin, removeWatched, updateWatched } from "@/lib/util/api";
|
||||
import { getTopCrew } from "@/lib/util/helpers.js";
|
||||
import { serverFeatures, watchedList } from "@/store";
|
||||
import { serverFeatures, userSettings, watchedList } from "@/store";
|
||||
import type {
|
||||
TMDBContentCredits,
|
||||
TMDBContentCreditsCrew,
|
||||
@@ -27,6 +27,11 @@
|
||||
import { onMount } from "svelte";
|
||||
import RequestShow from "@/lib/request/RequestShow.svelte";
|
||||
import FollowedThoughts from "@/lib/content/FollowedThoughts.svelte";
|
||||
import ArrRequestButton from "@/lib/request/ArrRequestButton.svelte";
|
||||
import tooltip from "@/lib/actions/tooltip.js";
|
||||
import MyThoughts from "@/lib/content/MyThoughts.svelte";
|
||||
|
||||
$: settings = $userSettings;
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -34,6 +39,7 @@
|
||||
let trailerShown = false;
|
||||
let requestModalShown = false;
|
||||
let jellyfinUrl: string | undefined;
|
||||
let arrRequestButtonComp: ArrRequestButton;
|
||||
|
||||
$: wListItem = $watchedList.find(
|
||||
(w) => w.content?.type === "tv" && w.content?.tmdbId === data.tvId
|
||||
@@ -62,7 +68,9 @@
|
||||
if (!showId) {
|
||||
return;
|
||||
}
|
||||
const data = (await axios.get(`/content/tv/${showId}`)).data as TMDBShowDetails;
|
||||
const data = (
|
||||
await axios.get(`/content/tv/${showId}`, { params: { region: settings?.country } })
|
||||
).data as TMDBShowDetails;
|
||||
if (data.videos?.results?.length > 0) {
|
||||
const t = data.videos.results.find((v) => v.type?.toLowerCase() === "trailer");
|
||||
if (t?.key) {
|
||||
@@ -93,8 +101,17 @@
|
||||
return credits;
|
||||
}
|
||||
|
||||
function contentChanged(newStatus?: WatchedStatus, newRating?: number, newThoughts?: string) {
|
||||
updateWatched(data.tvId, "tv", newStatus, newRating, newThoughts);
|
||||
async function contentChanged(
|
||||
newStatus?: WatchedStatus,
|
||||
newRating?: number,
|
||||
newThoughts?: string,
|
||||
pinned?: boolean
|
||||
): Promise<boolean> {
|
||||
if (!data.tvId) {
|
||||
console.error("contentChanged: no tvId");
|
||||
return false;
|
||||
}
|
||||
return await updateWatched(data.tvId, "tv", newStatus, newRating, newThoughts, pinned);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -150,11 +167,49 @@
|
||||
{/if}
|
||||
{#if jellyfinUrl}
|
||||
<a class="btn" href={jellyfinUrl} target="_blank">
|
||||
<Icon i="jellyfin" wh={14} />Play On Jellyfin
|
||||
{#if localStorage.getItem("useEmby")}
|
||||
<Icon i="emby" wh={14} />Play On Emby
|
||||
{:else}
|
||||
<Icon i="jellyfin" wh={14} />Play On Jellyfin
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{#if $serverFeatures.sonarr}
|
||||
<button on:click={() => (requestModalShown = !requestModalShown)}>Request</button>
|
||||
{#if $serverFeatures.sonarr && data.tvId}
|
||||
<ArrRequestButton
|
||||
type="tv"
|
||||
tmdbId={data.tvId}
|
||||
openRequestModal={() => (requestModalShown = !requestModalShown)}
|
||||
bind:this={arrRequestButtonComp}
|
||||
/>
|
||||
{/if}
|
||||
{#if wListItem}
|
||||
<div class="other-side">
|
||||
<button
|
||||
on:click={() => {
|
||||
if (wListItem?.pinned) {
|
||||
contentChanged(undefined, undefined, undefined, false);
|
||||
} else {
|
||||
contentChanged(undefined, undefined, undefined, true);
|
||||
}
|
||||
}}
|
||||
use:tooltip={{
|
||||
text: `${wListItem?.pinned ? "Unpin from" : "Pin to"} top of list`,
|
||||
pos: "bot"
|
||||
}}
|
||||
>
|
||||
<Icon i={wListItem?.pinned ? "unpin" : "pin"} wh={19} />
|
||||
</button>
|
||||
<button
|
||||
class="delete-btn"
|
||||
on:click={() =>
|
||||
wListItem
|
||||
? removeWatched(wListItem.id)
|
||||
: console.error("no wlistItem.. can't delete")}
|
||||
use:tooltip={{ text: "Delete", pos: "bot" }}
|
||||
>
|
||||
<Icon i="trash" wh={19} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -164,7 +219,15 @@
|
||||
</div>
|
||||
|
||||
{#if requestModalShown}
|
||||
<RequestShow content={show} onClose={() => (requestModalShown = false)} />
|
||||
<RequestShow
|
||||
content={show}
|
||||
onClose={(reqResp) => {
|
||||
requestModalShown = false;
|
||||
if (reqResp) {
|
||||
arrRequestButtonComp.setExistingRequest(reqResp);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="page">
|
||||
@@ -173,17 +236,11 @@
|
||||
<Rating rating={wListItem?.rating} onChange={(n) => contentChanged(undefined, n)} />
|
||||
<Status status={wListItem?.status} onChange={(n) => contentChanged(n)} />
|
||||
{#if wListItem}
|
||||
<textarea
|
||||
name="Thoughts"
|
||||
rows="3"
|
||||
placeholder={`My thoughts on ${show.name}`}
|
||||
value={wListItem?.thoughts}
|
||||
on:blur={(e) => {
|
||||
if (wListItem?.thoughts === e.currentTarget.value) {
|
||||
// thoughts didn't change
|
||||
return;
|
||||
}
|
||||
contentChanged(undefined, undefined, e.currentTarget?.value);
|
||||
<MyThoughts
|
||||
contentTitle={show.name}
|
||||
thoughts={wListItem?.thoughts}
|
||||
onChange={(newThoughts) => {
|
||||
return contentChanged(undefined, undefined, newThoughts);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -301,13 +358,15 @@
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
margin-top: auto;
|
||||
|
||||
a.btn,
|
||||
button {
|
||||
@@ -328,6 +387,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.other-side {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
|
||||
@media screen and (min-width: 900px) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
&:hover {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +437,10 @@
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
max-width: 380px;
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
max-width: 340px;
|
||||
}
|
||||
}
|
||||
|
||||
.creators {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
let login = true;
|
||||
let availableProviders: string[] = [];
|
||||
let signupEnabled = true;
|
||||
let useEmby = false;
|
||||
|
||||
onMount(() => {
|
||||
if (localStorage.getItem("token")) {
|
||||
@@ -25,6 +26,7 @@
|
||||
}
|
||||
availableProviders = r.data.available;
|
||||
signupEnabled = r.data.signupEnabled;
|
||||
useEmby = r.data.useEmby;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -60,6 +62,11 @@
|
||||
if (resp.data?.token) {
|
||||
console.log("Received token... logging in.");
|
||||
localStorage.setItem("token", resp.data.token);
|
||||
if (useEmby) {
|
||||
localStorage.setItem("useEmby", "1");
|
||||
} else {
|
||||
localStorage.removeItem("useEmby");
|
||||
}
|
||||
goto("/");
|
||||
notify({ id: nid, text: `Welcome ${user}!`, type: "success" });
|
||||
}
|
||||
@@ -142,9 +149,19 @@
|
||||
<div class="login-btns">
|
||||
<button type="submit"><span class="watcharr">W</span>Watcharr</button>
|
||||
{#if availableProviders?.length > 0}
|
||||
{#each availableProviders.filter((ap) => ap !== "plex") as p}
|
||||
<button type="submit" name={p} class="other"><Icon i={p} wh={18} />{p}</button>
|
||||
{/each}
|
||||
{#if availableProviders.find((ap) => ap === "jellyfin")}
|
||||
{#if useEmby}
|
||||
<button type="submit" name="jellyfin" class="other">
|
||||
<Icon i="emby" wh={18} />
|
||||
emby
|
||||
</button>
|
||||
{:else}
|
||||
<button type="submit" name="jellyfin" class="other">
|
||||
<Icon i="jellyfin" wh={18} />
|
||||
jellyfin
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if availableProviders?.findIndex((provider) => provider == "plex") > -1}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const activeSort = writable<string[]>(defaultSort);
|
||||
export const activeFilters = writable<Filters>({ type: [], status: [] });
|
||||
export const appTheme = writable<Theme>();
|
||||
export const importedList = writable<
|
||||
{ data: string; type: "text-list" | "tmdb" | "movary" } | undefined
|
||||
{ data: string; type: "text-list" | "tmdb" | "movary" | "watcharr" | "myanimelist" } | undefined
|
||||
>();
|
||||
export const parsedImportedList = writable<ImportedList[] | undefined>();
|
||||
export const searchQuery = writable<string>("");
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Icon =
|
||||
| "play"
|
||||
| "pause"
|
||||
| "jellyfin"
|
||||
| "emby"
|
||||
| "plex"
|
||||
| "trash"
|
||||
| "close"
|
||||
@@ -24,6 +25,7 @@ export type Icon =
|
||||
| "search"
|
||||
| "sort"
|
||||
| "eye-closed"
|
||||
| "people-nocircle"
|
||||
| "people"
|
||||
| "person"
|
||||
| "person-add"
|
||||
@@ -31,7 +33,13 @@ export type Icon =
|
||||
| "pencil"
|
||||
| "eye"
|
||||
| "star"
|
||||
| "movary";
|
||||
| "movary"
|
||||
| "refresh"
|
||||
| "gamepad"
|
||||
| "film"
|
||||
| "tv"
|
||||
| "pin"
|
||||
| "unpin";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
@@ -95,6 +103,7 @@ export interface Watched extends dbModel {
|
||||
activity: Activity[];
|
||||
status: WatchedStatus;
|
||||
thoughts: string;
|
||||
pinned: boolean;
|
||||
watchedSeasons?: WatchedSeason[];
|
||||
watchedEpisodes?: WatchedEpisode[];
|
||||
}
|
||||
@@ -117,6 +126,7 @@ export interface WatchedUpdateRequest {
|
||||
status?: WatchedStatus;
|
||||
thoughts?: string;
|
||||
removeThoughts?: boolean;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface WatchedUpdateResponse {
|
||||
@@ -150,6 +160,7 @@ export interface UserSettings {
|
||||
privateThoughts: boolean;
|
||||
hideSpoilers: boolean;
|
||||
includePreviouslyWatched: boolean;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordForm {
|
||||
@@ -178,7 +189,8 @@ export interface PrivateUser {
|
||||
export enum UserPermission {
|
||||
PERM_NONE = 1,
|
||||
PERM_ADMIN = 2,
|
||||
PERM_REQUEST_CONTENT = 4
|
||||
PERM_REQUEST_CONTENT = 4,
|
||||
PERM_REQUEST_CONTENT_AUTO_APPROVE = 8
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
@@ -196,7 +208,7 @@ export interface AvailableAuthProviders {
|
||||
available: string[];
|
||||
signupEnabled: boolean;
|
||||
isInSetup: boolean;
|
||||
plexOauthId: string;
|
||||
useEmby: boolean;
|
||||
}
|
||||
|
||||
export interface TokenClaims {
|
||||
@@ -658,6 +670,14 @@ export interface TMDBKeywords {
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TMDBRegions {
|
||||
results: {
|
||||
iso_3166_1: string;
|
||||
english_name: string;
|
||||
native_name: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ContentSearch {
|
||||
page: number;
|
||||
results: (ContentSearchMovie | ContentSearchTv | ContentSearchPerson)[];
|
||||
@@ -748,6 +768,9 @@ export interface ImportedList {
|
||||
status?: WatchedStatus;
|
||||
thoughts?: string;
|
||||
datesWatched?: Date[];
|
||||
activity?: Activity[];
|
||||
watchedEpisodes?: WatchedEpisode[];
|
||||
watchedSeasons?: WatchedSeason[];
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
@@ -755,8 +778,19 @@ export interface Filters {
|
||||
status: string[];
|
||||
}
|
||||
|
||||
export interface ManagedUser {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
username: string;
|
||||
type: UserType;
|
||||
permissions: number;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
DEFAULT_COUNTRY: string;
|
||||
JELLYFIN_HOST: string;
|
||||
USE_EMBY: boolean;
|
||||
SIGNUP_ENABLED: boolean;
|
||||
TMDB_KEY: string;
|
||||
PLEX_HOST: string;
|
||||
@@ -792,8 +826,9 @@ export interface TwitchSettings {
|
||||
}
|
||||
|
||||
export interface DropDownItem {
|
||||
id: number;
|
||||
id: number | string;
|
||||
value: string;
|
||||
icon: Icon;
|
||||
}
|
||||
|
||||
export interface ListBoxItem {
|
||||
@@ -857,6 +892,34 @@ export interface RadarrTestResponse {
|
||||
rootFolders: RootFolder[];
|
||||
}
|
||||
|
||||
export type ArrRequestStatus = "PENDING" | "APPROVED" | "AUTO_APPROVED" | "DENIED" | "FOUND";
|
||||
|
||||
export interface ArrRequestResponse {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
serverName: string;
|
||||
arrId: number;
|
||||
content: Content;
|
||||
status: ArrRequestStatus;
|
||||
requestJson: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface ArrDetailsResponse {
|
||||
progress: number;
|
||||
estimatedCompletionTime: string;
|
||||
status: string;
|
||||
trackedDownloadStatus: string;
|
||||
trackedDownloadState: string;
|
||||
}
|
||||
|
||||
export interface ArrInfoResponse {
|
||||
hasFile: boolean;
|
||||
isAvailable: boolean;
|
||||
added: string;
|
||||
}
|
||||
|
||||
export interface ServerFeatures {
|
||||
sonarr: boolean;
|
||||
radarr: boolean;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
--rating-color: black;
|
||||
--placeholder-color: #8e8e8e;
|
||||
--poster-rating-color: gold;
|
||||
--img-blend-multiply-bg-col: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
:root.theme-dark {
|
||||
@@ -22,6 +23,7 @@
|
||||
--rating-color: gold;
|
||||
--placeholder-color: #8e8e8e;
|
||||
--poster-rating-color: black;
|
||||
--img-blend-multiply-bg-col: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
$bg-color: var(--bg-color);
|
||||
@@ -36,9 +38,11 @@ $nav-color: var(--nav-color);
|
||||
$nav-height: 71px; // How tall the nav is naturally, usefull in some places.
|
||||
$poster-rating-color: var(--poster-rating-color);
|
||||
$poster-extra-detail-bg-color: rgba(46, 46, 46, 0.5);
|
||||
// Bg col of elements with image behind with mix-blend-mode: multiply where we want a little of image to come through.
|
||||
$img-blend-multiply-bg-col: var(--img-blend-multiply-bg-col);
|
||||
$error: #f3555a;
|
||||
$success: #28a745;
|
||||
$plex: #e5a00d;
|
||||
$success-hover: #1e7e34;
|
||||
|
||||
// For ratings that are on bg-color.
|
||||
$rating-color: var(--rating-color);
|
||||
|
||||