Compare commits

...

48 Commits

Author SHA1 Message Date
IRHM 60323ecd61 v1.37.0 2024-03-27 22:23:59 +00:00
IRHM 49b3c8e96a 1.37.0-dev 2024-03-27 21:43:53 +00:00
IRHM 2d39e29db0 PosterList: Responsive fix AGAIN ;( 2024-03-27 21:02:17 +00:00
IRHM 8c39ec881e manage_users: Change username cell flex-flow to col for mobile 2024-03-27 20:11:02 +00:00
IRHM 3ea7001986 import: Make pages use 100% width on smaller screens 2024-03-27 20:05:11 +00:00
Mr ec4b9c993f Import: support importing watched episodes/seasons from watcharr export 2024-03-27 00:50:08 +00:00
Mr 248935c4e7 manage_users: Add word-wrap: anywhere to table cells
So long usernames don't break the tables usage
2024-03-26 23:54:21 +00:00
Mr cfbc8def98 Merge pull request #440 from sbondCo/imgbot
[ImgBot] Optimize images
2024-03-26 12:30:59 +00:00
ImgBotApp 6453f7c274 [ImgBot] Optimize images
*Total -- 96.10kb -> 77.57kb (19.28%)

/doc/docs/installation/img/forking-repo.png -- 1.84kb -> 1.32kb (28.18%)
/doc/docs/server_config/img/watcharr-jellyfin-host-setting.png -- 12.35kb -> 9.61kb (22.16%)
/doc/docs/server_config/img/request-admin/admin-token-generated-log-example.png -- 19.12kb -> 15.14kb (20.8%)
/doc/docs/server_config/img/watcharr-plex-host-setting-filled.png -- 16.79kb -> 13.46kb (19.85%)
/doc/docs/importing/img/mal/export.png -- 10.68kb -> 8.64kb (19.09%)
/doc/docs/server_config/img/watcharr-plex-host-setting.png -- 11.36kb -> 9.20kb (19.04%)
/doc/docs/server_config/img/request-admin/request-admin-btn.png -- 6.97kb -> 5.76kb (17.36%)
/doc/docs/server_config/img/request-admin/redeeming-admin-token.png -- 17.00kb -> 14.44kb (15.02%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2024-03-26 05:25:08 +00:00
Mr 7261f2f454 Merge pull request #439 from sbondCo/import-myanimelist
Importing from MyAnimeList
2024-03-26 02:33:46 +00:00
IRHM 1e6e22942c myanimelist importing: import start and finish dates 2024-03-26 02:28:35 +00:00
IRHM 3b556ef5e7 Enable fluidSize on posters on search and people pages 2024-03-26 01:20:24 +00:00
IRHM dc05c7aaeb Basic MyAnimeList importing
also fixed poster sizing - added fluidSize property so we only have that on the watched list
2024-03-26 01:18:33 +00:00
stephaje ef0d119907 Import Watcharr Exports (#389)
* Issue #130

* Watcharr import: support activity import and fix ImportedList data

Also added padding to bottom of import page

---------

Co-authored-by: John Stephani <me@johnstephani.com>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2024-03-25 22:12:57 +00:00
Mr a12ac3c4a0 Merge pull request #438 from sbondCo/doc-restoring-admin
docs: restoring admin guide
2024-03-25 21:19:03 +00:00
Mr 564788a4c8 Merge pull request #437 from sbondCo/user-management
User management
2024-03-25 21:16:41 +00:00
IRHM 06ddda1137 EditUserModal: Remove unused style 2024-03-25 21:12:38 +00:00
IRHM 48a969b367 Link 'Users' stat to manage_users page 2024-03-25 21:06:08 +00:00
IRHM 5c08980e27 Support PERM_REQUEST_CONTENT on sonarr/radarr routes
Now content can be requested with only the PERM_REQUEST_CONTENT permission required.
2024-03-25 21:03:16 +00:00
Mr 96575c2c6d docs: restoring admin guide
Taken from v1.19.0 release (from before we had a doc site)
2024-03-25 16:32:02 +00:00
IRHM ea3a5d99f0 manage_users: Get all users again after edit user modal closes
fetches data again, because I'm too lazy to make it work locally. Also don't think it's that crucial just for an admin page to make it super duper deliciouso at the el momento
2024-03-25 02:16:14 +00:00
IRHM c47b9a07b8 manage_users: admin tag on table 2024-03-25 02:08:02 +00:00
IRHM c8743fadb6 manage_users: manage user perms 2024-03-25 01:56:00 +00:00
IRHM 42f2cde43f manage_users: Basic user management table
tldr: viewing all users in a table now works
2024-03-24 18:42:39 +00:00
Mr ac1ac76891 Merge pull request #432 from sbondCo/inst-doc
docs: installation for development
2024-03-24 18:08:05 +00:00
IRHM 7abed85a56 docs: installation for development
rough guide
2024-03-24 18:02:48 +00:00
Mr f20e52bbe6 Merge pull request #428 from sbondCo/mobile-responsiveness-fixes
Posters and Search mobile responsiveness fixes
2024-03-24 00:03:05 +00:00
IRHM bea8294f26 Move poster media queries to PosterList
So we can only apply then when the PosterList is in wrapping mode.

Added default min-width to posters of 170px.

This fixes posters on other pages, where they would be super small.
2024-03-23 23:59:59 +00:00
IRHM ca11301410 Nav: Searchbar even more centered better 2024-03-23 23:11:57 +00:00
IRHM b1f48309aa Nav: Make searchbox look a little more centered 2024-03-23 23:08:19 +00:00
IRHM d989f13621 Nav: Fix searchbar responsiveness on other pages
and add text-overflow
2024-03-23 23:04:08 +00:00
IRHM 85cd946619 Nav: Slowly decrease gap instead of always splitting nav below 390px screen size 2024-03-23 22:48:02 +00:00
IRHM 40420aa14b Nav: Keep main input on lower screens by replacing placeholder with a search icon 2024-03-23 22:41:24 +00:00
IRHM aec1014acf Nav: More responsive search bar
We now have two search bars in the markup to make this easily possible. When the size of the main input becomes too small to see the placeholder, we hide it and show another search bar below the main nav content.
2024-03-23 21:31:18 +00:00
IRHM fc49e9986f Poster: More responsive for smaller screens
Small range where we have a dynamic size to hopefully work better on a bigger range of devices. Can't go too big or too small otherwise the posters can become ugly and unusable.

Works well now with all the preset device sizes in my browser.
2024-03-20 22:43:22 +00:00
mordquist c9e23e1eba Configurable data location (#423)
* envvar to configure datadir if set

* config: Rename data environment variable

---------

Co-authored-by: mordquist <mordquist@proton.me>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2024-03-20 21:22:06 +00:00
IRHM f992841314 docs: jellyfin and plex auth docs, add description to all install/config docs 2024-03-18 02:56:51 +00:00
IRHM afb1fa6dfd v1.36.0 2024-03-18 02:30:39 +00:00
IRHM a58351c539 1.36.0-dev1 2024-03-18 01:41:51 +00:00
IRHM d643d349ac Plex Auth: Remove plexUserHasAccessToPlexHost and use getPlexHomeServerAuthToken instead
If we can get an auth token for our home server, then the user must have access to it.

plexUserHasAccessToPlexHost method wouldn't work if our plex server had zero allowed users (except ourselves), since the data would not appear for ourself.
2024-03-18 01:40:45 +00:00
IRHM 4fdb3e9f9d 1.36.0-dev 2024-03-17 18:48:57 +00:00
Mr c1be94738b Jobs: Use wildcard for get job route id param (#415)
Fixes 404 errors when there is a '/' in the job id.
2024-03-17 18:22:27 +00:00
IRHM ea66615f65 plex: Remove info log 2024-03-17 17:39:00 +00:00
IRHM 5b38f762ed Remove old plex remnants
- $plex scss variable
- plexOauthId property in AvailableAuthProviders interface
2024-03-17 17:30:36 +00:00
dependabot[bot] 2aa81f036e Bump follow-redirects from 1.15.5 to 1.15.6 in /doc (#414)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-17 17:18:07 +00:00
Mr cbf0120647 Merge pull request #412 from sbondCo/dependabot/npm_and_yarn/follow-redirects-1.15.6
ui: bump follow-redirects from 1.15.4 to 1.15.6
2024-03-17 17:17:49 +00:00
Mr 2865115b1f Merge pull request #413 from sbondCo/plex-sync
Plex sync
2024-03-17 17:13:19 +00:00
dependabot[bot] 5cbc817089 ui: bump follow-redirects from 1.15.4 to 1.15.6
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-16 22:36:17 +00:00
47 changed files with 1438 additions and 429 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"label": "Importing",
"position": 3,
"link": {
"type": "generated-index",
"description": "Guides for importing your data from other services."
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

+17
View File
@@ -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
![Hovering over poster](./img/mal/export.png)
2. Save the export anywhere and extract it to get your export `.xml` file.
3. Drag and drop your `.xml` file into Watcharr.
+1
View File
@@ -1,5 +1,6 @@
---
sidebar_position: 1
description: Install and setup with Docker Compose.
---
# Docker Compose
+77
View File
@@ -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.
![Fork button](./img/forking-repo.png)
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
View File
@@ -1,5 +1,6 @@
---
sidebar_position: 10
description: Building and installing from source.
---
# From Source
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+2 -3
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+28
View File
@@ -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.
![Navigating to server settings in Watcharr](./img/watcharr-settings-dropdown.png)
2. Scroll down to find the Jellyfin Host setting and enter the URL to your Jellyfin server.
![Entering Jellyfin server URL into input](./img/watcharr-jellyfin-host-setting.png)
3. Jellyfin authentication has now been enabled! Go back to the login screen and you will be able to login via Jellyfin.
+24
View File
@@ -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.
![Navigating to server settings in Watcharr](./img/watcharr-settings-dropdown.png)
2. Scroll down and find the Plex Host setting.
![Plex host setting input](./img/watcharr-plex-host-setting.png)
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.
![Entering home Plex server URL into input](./img/watcharr-plex-host-setting-filled.png)
+28
View File
@@ -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.
![requesting an admin token](./img/request-admin/request-admin-btn.png)
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).
![finding admin token in log](./img/request-admin/admin-token-generated-log-example.png)
4. Enter the code from the log (you should have been taken here after clicking the request button in step 2).
![redeeming admin token](./img/request-admin/redeeming-admin-token.png)
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.
+3 -3
View File
@@ -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",
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "watcharr",
"version": "1.35.2",
"version": "1.37.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watcharr",
"version": "1.35.2",
"version": "1.37.0",
"dependencies": {
"axios": "^1.6.7",
"blurhash": "^2.0.5",
@@ -4588,9 +4588,9 @@
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz",
"integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "watcharr",
"version": "1.35.2",
"version": "1.37.0",
"private": true,
"scripts": {
"dev": "vite dev",
+2
View File
@@ -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()
+22 -12
View File
@@ -254,6 +254,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.")
@@ -447,22 +462,21 @@ 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),
@@ -480,10 +494,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")
+18 -14
View File
@@ -6,13 +6,20 @@ 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
@@ -82,7 +89,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.")
@@ -126,7 +133,7 @@ func generateConfig() error {
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
@@ -156,7 +163,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 +180,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 -1
View File
@@ -85,7 +85,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())
}
+65 -8
View File
@@ -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 _, v := range ar.Activity {
activityDate := v.CustomDate
if activityDate == nil || activityDate.IsZero() {
activityDate = &v.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
}
+5 -95
View File
@@ -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
}
+57 -18
View File
@@ -961,6 +961,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 +1005,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 +1026,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 +1041,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 +1057,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 +1073,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,13 +1083,13 @@ 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 {
@@ -1079,10 +1113,12 @@ func (b *BaseRouter) addSonarrRoutes() {
}
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 +1134,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 +1148,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 +1163,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 +1178,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,12 +1187,12 @@ 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 {
@@ -1182,9 +1218,12 @@ func (b *BaseRouter) addRadarrRoutes() {
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
+58
View File
@@ -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
}
+3 -3
View File
@@ -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)
}
@@ -152,7 +152,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
+2
View File
@@ -216,6 +216,7 @@
dateAdded: w.createdAt,
dateModified: w.updatedAt
}}
fluidSize={true}
/>
{:else if w.content}
<Poster
@@ -237,6 +238,7 @@
dateModified: w.updatedAt,
lastWatched: getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes)
}}
fluidSize={true}
/>
{/if}
{/each}
+13 -2
View File
@@ -34,6 +34,7 @@
export let disableInteraction = false;
export let hideButtons = false;
export let extraDetails: ExtraDetailsGame | undefined = undefined;
export let fluidSize = false;
// When provided, default click handlers will instead run this callback.
export let onClick: (() => void) | undefined = undefined;
@@ -107,8 +108,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>
@@ -222,6 +228,11 @@
// aspect-ratio: 2/3;
transition: transform 150ms ease;
&.fluid-size {
height: 100%;
width: 100%;
}
img {
width: 100%;
height: 100%;
+15 -5
View File
@@ -36,6 +36,7 @@
export let disableInteraction = false;
export let hideButtons = false;
export let extraDetails: ExtraDetails | undefined = undefined;
export let fluidSize = false;
// When provided, default click handlers will instead run this callback.
export let onClick: (() => void) | undefined = undefined;
@@ -99,8 +100,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>
@@ -203,13 +209,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%;
+21
View File
@@ -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>
+93
View File
@@ -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;
}
}
}
}
}
+260 -109
View File
@@ -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,11 @@
subMenuShown = false;
}
function userManagement() {
goto("/manage_users");
subMenuShown = false;
}
function shareWatchedList() {
const nid = notify({ type: "loading", text: "Getting link" });
const ud = parseTokenPayload();
@@ -159,8 +170,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 +221,127 @@
</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 === "/"}
<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 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>
<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>
{#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}
<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 +355,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 +441,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 +630,5 @@
}
}
}
@media screen and (max-width: 666px) {
input {
width: 100%;
&:focus + .btns button:not(.face) {
display: none;
}
}
}
}
</style>
+149 -3
View File
@@ -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 {
+142 -103
View File
@@ -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%;
}
}
}
+164
View File
@@ -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,127 @@
<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>
<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>
@@ -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>
+6 -39
View File
@@ -138,9 +138,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 +155,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}
+1 -1
View File
@@ -91,7 +91,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 />
@@ -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.">
+1 -1
View File
@@ -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>("");
+12 -1
View File
@@ -196,7 +196,6 @@ export interface AvailableAuthProviders {
available: string[];
signupEnabled: boolean;
isInSetup: boolean;
plexOauthId: string;
}
export interface TokenClaims {
@@ -748,6 +747,9 @@ export interface ImportedList {
status?: WatchedStatus;
thoughts?: string;
datesWatched?: Date[];
activity?: Activity[];
watchedEpisodes?: WatchedEpisode[];
watchedSeasons?: WatchedSeason[];
}
export interface Filters {
@@ -755,6 +757,15 @@ export interface Filters {
status: string[];
}
export interface ManagedUser {
id: number;
createdAt: Date;
username: string;
type: UserType;
permissions: number;
private: boolean;
}
export interface ServerConfig {
JELLYFIN_HOST: string;
SIGNUP_ENABLED: boolean;
-1
View File
@@ -38,7 +38,6 @@ $poster-rating-color: var(--poster-rating-color);
$poster-extra-detail-bg-color: rgba(46, 46, 46, 0.5);
$error: #f3555a;
$success: #28a745;
$plex: #e5a00d;
// For ratings that are on bg-color.
$rating-color: var(--rating-color);