Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.35.2",
|
||||
"version": "1.37.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.">
|
||||
|
||||
@@ -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>("");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||