mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 15:25:29 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc8b74893 | |||
| 9d5e489675 | |||
| 4e6e71687b | |||
| 8c8d604bc6 | |||
| 39b82335eb | |||
| 217be719e4 | |||
| 0da32164a3 | |||
| db72d05d79 | |||
| 88a7542064 | |||
| 19c773976d | |||
| a16c769273 | |||
| f022dc702c | |||
| c2f72dbd9c | |||
| d777630e84 | |||
| f5bfa29877 | |||
| 9b64932af1 | |||
| bf0594c1e8 | |||
| fee82df59d | |||
| 61382de4d2 | |||
| e65ba9d1d9 | |||
| 6654d85448 | |||
| eabdb47cfa | |||
| 643ca0fd38 | |||
| a08b19675a | |||
| 38fba0ac10 | |||
| f23552c170 | |||
| 448fdd9df3 | |||
| ca428327e2 | |||
| f7cca74a41 | |||
| 86286b967f | |||
| 8243838bfb | |||
| 5bebb754ed | |||
| c8920c911d | |||
| dd0bae137f | |||
| d5baee0df7 | |||
| 764a25be6c | |||
| c019162994 | |||
| 05f9813be8 | |||
| f285d1a8f4 | |||
| 4cc9bd3edb | |||
| 34f7f54e72 | |||
| 3fe2d2b155 |
@@ -1,5 +1,7 @@
|
||||
<!-- Make sure your code is formatted by running `npm run format` or using prettier manually. -->
|
||||
|
||||
<!-- AI Disclosure: <If you used AI to write the code, please disclose it by uncommenting this line and describing the usage. If AI wrote any of the code, do you fully understand it?> -->
|
||||
|
||||
### Changes made
|
||||
|
||||
<!-- Describe changes made here. If changes are visual a screenshot could be useful! -->
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Web UI
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "ui"
|
||||
|
||||
# Server
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/server"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "server"
|
||||
|
||||
# Workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # / will look in /.github/workflows directory by default
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "workflow"
|
||||
@@ -11,13 +11,20 @@ on:
|
||||
- "server/**"
|
||||
|
||||
jobs:
|
||||
format_check_go:
|
||||
test_and_format_check_go:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
|
||||
- name: go test all packages
|
||||
run: go test ./...
|
||||
|
||||
- name: gofmt test
|
||||
run: |
|
||||
outp=$(gofmt -l .)
|
||||
|
||||
+83
-4
@@ -2,9 +2,70 @@
|
||||
|
||||
These changes are awaiting release:
|
||||
|
||||
## Fixed
|
||||
|
||||
- Plex Login: Request JSON from plex api so that it doesn't return XML.
|
||||
- GHSA-6x53-2w54-v5rj (thanks to [@tonghuaroot] for reporting and patching!)
|
||||
|
||||
# [4.1.0] - 2026-07-19T14:18:00Z
|
||||
|
||||
## Changed
|
||||
|
||||
- Profile: Stats no longer care about the `Include Previously Watched` setting. All previously watched items will be counted in the stats now.
|
||||
- Activity: Added `index` to `WatchedID` column to speed up queries.
|
||||
- SeasonsListEpisode when `Hide Spoilers` is on (thanks [@goestav]!):
|
||||
- Show episode spoilers if its status is `FINISHED`;
|
||||
- Allow changing status without showing spoilers (useful for when setting an episode to PLANNED, etc).
|
||||
|
||||
## Fixed
|
||||
|
||||
- `Include Previously Watched` regression (fixes https://github.com/sbondCo/Watcharr/issues/1027).
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Remove `dependabot.yml`.
|
||||
|
||||
## Etc
|
||||
|
||||
- **Package**: [GitHub CR](https://github.com/orgs/sbondCo/packages/container/watcharr/1045300036?tag=v4.1.0) or [Docker Hub](https://hub.docker.com/layers/sbondco/watcharr/v4.1.0/images/sha256-8148b4bdd81e7fc2b412a4dd0a0a2bafa478bd42ab6d9824f9be10d1763719f4).
|
||||
|
||||
# [4.0.1] - 2026-07-16T10:12:00Z
|
||||
|
||||
## Changed
|
||||
|
||||
- Move notifications to its own component.
|
||||
- Use an `svg` for the favicon and change its `fill` to `white` for dark themed browsers.
|
||||
|
||||
## Fixed
|
||||
|
||||
- SpinnerTiny: Use svg instead of the css border trick to create the spinner fixing it looking more like a horseshoe than a circle on different browser scales (also added animation on the svg circle).
|
||||
- SpinnerTiny: Fix color being wrong on dark theme.
|
||||
- Watched: Also log the `error` on the "failed to restore existing watched entry" log.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- workflows/test-pr-server: Add test step
|
||||
|
||||
## Etc
|
||||
|
||||
- **Package**: https://github.com/sbondCo/Watcharr/pkgs/container/watcharr/1036344762?tag=v4.0.1 or on [docker hub](https://hub.docker.com/layers/sbondco/watcharr/v4.0.1/images/sha256-3546329532facf6f55c779bb229ce997258ff1a89c4e5f9d66a92cc65ae80396).
|
||||
|
||||
# [4.0.0] - 2026-07-11T02:34:00Z
|
||||
|
||||
> [!CAUTION]
|
||||
> It is always highly recommended that you perform a backup before updating (https://watcharr.app/docs/server_config/backup)!
|
||||
|
||||
> [!NOTE]
|
||||
> v4 is finally here! Don't worry about the major version bump, all migrations will be handled automatically when you start the upgraded version.
|
||||
>
|
||||
> Have a read of the Data Migrations section below to get an understanding of the migrations we are applying in v4. You'll also notice that on the first start there will be a delay while migrations are applied.
|
||||
|
||||
> [!NOTE]
|
||||
> This release contains security fixes. If your server is exposed to the public and you have signing up enabled, you should update sooner rather than later!
|
||||
|
||||
## Data migrations
|
||||
|
||||
This is the first time migrations are being taken place when updating Watcharr, please be mindful of that and **ensure you have your existing database backed up** incase of any errors.
|
||||
This is the first time migrations are taking place when updating Watcharr, please be mindful of that and **ensure you have your existing database backed up** incase of any errors.
|
||||
|
||||
### Backfilling `plays` data from users Activity.
|
||||
|
||||
@@ -39,6 +100,7 @@ If you backup your database by copying the .db file (while your server is stoppe
|
||||
- Moved db to WAL journal_mode.
|
||||
- WatchedUpdateRequest: Manually validate instead of using complex struct tags.
|
||||
- Now properly validating WatchedStatus.
|
||||
- ViewTrailerButton: Use youtube-nocookie.com (thanks [@GreatGatsby102])
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -49,6 +111,8 @@ If you backup your database by copying the .db file (while your server is stoppe
|
||||
- import: myanimelist: Don't import start/finish dates when they are empty.
|
||||
- Star and Play icons color.
|
||||
- Activity: Fixed automation tooltip going out of bounds by moving it to top.
|
||||
- Make `image` package a lot more robust (thanks [@4qu4r1um]).
|
||||
- GHSA-5q73-v9hv-4cf3
|
||||
|
||||
## Removed
|
||||
|
||||
@@ -59,6 +123,14 @@ If you backup your database by copying the .db file (while your server is stoppe
|
||||
|
||||
- Backup: Also note that `watcharr.db-wal` should be backed up along with the .db file since v3.
|
||||
|
||||
## New Contributors
|
||||
|
||||
- [@GreatGatsby102] made their first contribution in https://github.com/sbondCo/Watcharr/pull/1040
|
||||
|
||||
## Etc
|
||||
|
||||
- **Package**: https://github.com/sbondCo/Watcharr/pkgs/container/watcharr/1020169511?tag=v4.0.0 or on [docker hub](https://hub.docker.com/layers/sbondco/watcharr/v4.0.0/images/sha256-cd39b4fc0578ca374b2c7d65b410e29dc2859918a322e35e6b01f8e55d250f10).
|
||||
|
||||
# [3.0.1] - 2026-03-09
|
||||
|
||||
Hi All, delivered straight to your inbox today; you have bug fixes, some even improving general quality of life!!
|
||||
@@ -189,7 +261,7 @@ Many thanks to everyone who has worked on this release!
|
||||
## New
|
||||
|
||||
- About modal (accessible via face menu) by [@Clusters] in https://github.com/sbondCo/Watcharr/pull/811
|
||||
- System app theme (automatically swaps between light/dark themes depending on system config) by @antoniosarro in https://github.com/sbondCo/Watcharr/pull/822
|
||||
- System app theme (automatically swaps between light/dark themes depending on system config) by [@antoniosarro] in https://github.com/sbondCo/Watcharr/pull/822
|
||||
- Search shortcut (`Ctrl+S`) by [@IRHM] in https://github.com/sbondCo/Watcharr/pull/886
|
||||
|
||||
## Fixed
|
||||
@@ -1631,7 +1703,10 @@ Welcome to Watcharr :popcorn:, hope it is enjoyed and improves anyone's experien
|
||||
|
||||
<!-- Version Changelog References (newest first) -->
|
||||
|
||||
[Unreleased]: https://github.com/sbondCo/Watcharr/compare/v3.0.1...HEAD
|
||||
[Unreleased]: https://github.com/sbondCo/Watcharr/compare/v4.0.0...HEAD
|
||||
[4.1.0]: https://github.com/sbondCo/Watcharr/compare/v4.0.1...v4.1.0
|
||||
[4.0.1]: https://github.com/sbondCo/Watcharr/compare/v4.0.0...v4.0.1
|
||||
[4.0.0]: https://github.com/sbondCo/Watcharr/compare/v3.0.1...v4.0.0
|
||||
[3.0.1]: https://github.com/sbondCo/Watcharr/compare/v3.0.0...v3.0.1
|
||||
[3.0.0]: https://github.com/sbondCo/Watcharr/compare/v2.1.1...v3.0.0
|
||||
[2.1.1]: https://github.com/sbondCo/Watcharr/compare/v2.1.0...v2.1.1
|
||||
@@ -1729,4 +1804,8 @@ Welcome to Watcharr :popcorn:, hope it is enjoyed and improves anyone's experien
|
||||
[@jigglycrumb]: https://github.com/jigglycrumb
|
||||
[@IvanBeke]: https://github.com/IvanBeke
|
||||
[@ParksideParade]: https://github.com/ParksideParade
|
||||
[Dredsen]: https://github.com/Dredsen
|
||||
[@Dredsen]: https://github.com/Dredsen
|
||||
[@GreatGatsby102]: https://github.com/GreatGatsby102
|
||||
[@4qu4r1um]: https://github.com/4qu4r1um
|
||||
[@goestav]: https://github.com/goestav
|
||||
[@tonghuaroot]: https://github.com/tonghuaroot
|
||||
|
||||
+7
-1
@@ -4,8 +4,14 @@ First of all, thank you for looking into contributing! 🫡
|
||||
|
||||
Feel free to contribute in any way: 🐞 bug reports, 💡 ideas, 🥖 new features etc - everything is welcome!
|
||||
|
||||
For big PRs (lots of changes/big change in the way something works), if you'd like, it can be discussed first in an issue (dont want to waste anyones time!).
|
||||
Big pull requests (anything more than a few lines) should be discussed first in an issue (dont want to waste anyones time!).
|
||||
|
||||
## Project Board
|
||||
|
||||
We have a [board to organize new features and bug fixes](https://github.com/orgs/sbondCo/projects/9/views/2) to be worked on. If you see something you'd like to work on, just ask in the issue and it can be assigned to you. Even if it isn't set for the next milestone already, it can be.
|
||||
|
||||
## AI
|
||||
|
||||
If you use AI, please disclose the usage in your pull request.
|
||||
|
||||
Fully understanding any code that is submitted is a must because someone's gotta understand it!
|
||||
|
||||
@@ -16,9 +16,17 @@ With [some extra configuration](https://watcharr.app/docs/server_config/game-sup
|
||||
|
||||
I am built with Go and Svelte(Kit).
|
||||
|
||||
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://beta.watcharr.app/](https://beta.watcharr.app/)
|
||||
### Demo
|
||||
|
||||
[Track progress for the next version](https://github.com/orgs/sbondCo/projects/9/views/3).
|
||||
Feel free to abuse this demo instance (nicely). It runs on the latest `dev` build so there may be bugs, as new features are tested on here too. The demo is a worst-case scenario for speed (which is why I like it as a testing ground), if you host it yourself the app will be snappy.
|
||||
|
||||
Demo: [https://beta.watcharr.app/](https://beta.watcharr.app/)
|
||||
|
||||
**NOTE:** There is no demo account, just type in a random username/password (smashing hand into keyboard is supported), then click `Not a user?` at the bottom and a `Sign Up` button will appear.
|
||||
|
||||
### Track new features
|
||||
|
||||
Most patches are tracked through [our project board](https://github.com/orgs/sbondCo/projects/9/views/3), though I am very unorganised so expect surprise updates (or don't, if you like surprises)!
|
||||
|
||||
### Contents
|
||||
|
||||
@@ -26,6 +34,7 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
|
||||
- [Set Up](#set-up)
|
||||
- [Community Made Tools](#community-made-tools)
|
||||
- [Getting Help](#getting-help)
|
||||
- [License](#license)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
# Screenshots
|
||||
@@ -72,6 +81,8 @@ If something isn't working for you or you are stuck, [creating an issue](https:/
|
||||
|
||||
You can also [join our space on Matrix](https://matrix.to/#/#watcharr:matrix.org) for support.
|
||||
|
||||
I'll do my best to reply!
|
||||
|
||||
# License
|
||||
|
||||
This project is licensed under the GPLv3 license. You should see the [LICENSE](LICENSE) file located in the root folder of this project for the full license text, if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "3.0.2-dev1",
|
||||
"version": "4.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "watcharr",
|
||||
"version": "3.0.2-dev1",
|
||||
"version": "4.1.1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"license": "GPL-3.0-only",
|
||||
"version": "3.0.2-dev1",
|
||||
"version": "4.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.0.2-dev1
|
||||
4.1.1
|
||||
+30
-12
@@ -16,6 +16,10 @@ import (
|
||||
// Also runs migrations, etc, before returning connection.
|
||||
// Any error returned from this func should always make our app Exit (caller
|
||||
// handled).
|
||||
//
|
||||
// NOTE: Our mock db used in tests mimics this function, so if this func is
|
||||
// changed, you should look at the mock db to make it match if it makes
|
||||
// sense, so our tests stay accurate to prod.
|
||||
func New() (*gorm.DB, error) {
|
||||
slog.Info("New: Opening new database connection")
|
||||
// Open the database.
|
||||
@@ -27,13 +31,27 @@ func New() (*gorm.DB, error) {
|
||||
slog.Error("New: Opening database failed.")
|
||||
return nil, err
|
||||
}
|
||||
if err := configure(db); err != nil {
|
||||
slog.Error("New: Configuring connection failed!", "error", err)
|
||||
// Setup the db (migrations, etc)
|
||||
if err := Setup(db); err != nil {
|
||||
slog.Error("New: Setting up connection failed!", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Setup configures our db connection and applies migrations.
|
||||
//
|
||||
// NOTE: This exists as a separate function so it can be reused by our testutil
|
||||
// package that we want to have configured in the same way as the main db so
|
||||
// that tests reflect real life.
|
||||
func Setup(db *gorm.DB) error {
|
||||
if err := configure(db); err != nil {
|
||||
slog.Error("Setup: Configuring connection failed!", "error", err)
|
||||
return err
|
||||
}
|
||||
// Perform auto migration.
|
||||
slog.Info("New: AutoMigrating")
|
||||
err = db.AutoMigrate(
|
||||
slog.Info("Setup: AutoMigrating")
|
||||
err := db.AutoMigrate(
|
||||
&migrate.MigrationRecord{},
|
||||
&entity.User{},
|
||||
&entity.UserServices{},
|
||||
@@ -50,21 +68,21 @@ func New() (*gorm.DB, error) {
|
||||
&entity.Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("New: Auto migration failed.")
|
||||
return nil, err
|
||||
slog.Error("Setup: Auto migration failed.")
|
||||
return err
|
||||
}
|
||||
slog.Info("New: AutoMigrated")
|
||||
slog.Info("Setup: AutoMigrated")
|
||||
// Perform our manual migrations.
|
||||
if err := migrate.Now(db); err != nil {
|
||||
slog.Error("New: Manual migrations failed.", "error", err)
|
||||
return nil, err
|
||||
slog.Error("Setup: Manual migrations failed.", "error", err)
|
||||
return err
|
||||
}
|
||||
// Optimize database.
|
||||
if err := optimize(db); err != nil {
|
||||
slog.Error("New: Optimizing database failed.", "error", err)
|
||||
return nil, err
|
||||
slog.Error("Setup: Optimizing database failed.", "error", err)
|
||||
return err
|
||||
}
|
||||
return db, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Configure our SQLite database connection.
|
||||
|
||||
@@ -46,7 +46,7 @@ type Activity struct {
|
||||
// secured (users can only view their own activities).
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// ID of watched list item this activity is linked to.
|
||||
WatchedID uint `json:"watchedId" gorm:"not null"`
|
||||
WatchedID uint `json:"watchedId" gorm:"not null;index"`
|
||||
// Type of activity.
|
||||
Type ActivityType `json:"type" gorm:"not null"`
|
||||
// Holds custom data (ex, if rating changed, this can
|
||||
|
||||
@@ -54,6 +54,9 @@ func Now(db *gorm.DB) error {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("Migration has NOT been applied before.. applying.",
|
||||
"id", mig.ID)
|
||||
|
||||
// Timing the migration.
|
||||
timeBeforeMig := time.Now()
|
||||
|
||||
@@ -89,7 +92,7 @@ func Now(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("Migration applied successfully.",
|
||||
slog.Info("Migration applied successfully.",
|
||||
"id", mig.ID,
|
||||
"duration", time.Since(timeBeforeMig))
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ var migrations = []Migration{
|
||||
Model(&entity.Activity{}).
|
||||
Where("type IN ?", []entity.ActivityType{
|
||||
entity.IMPORTED_ADDED_WATCHED,
|
||||
// TODO: Should these be here?:
|
||||
entity.IMPORTED_ADDED_WATCHED_JF,
|
||||
entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
}).
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
|
||||
// Auth middleware
|
||||
// If db is passed, extra user info from the database will be fetched.
|
||||
//
|
||||
// **NOTE:** Instead of providing the `db` parameter, it is probably better to
|
||||
// fetch what you need in the handler directly! We might follow that pattern
|
||||
// from now on and potentially remove `db` from this func in the future.
|
||||
func AuthRequired(db *gorm.DB, cfg *config.ServerConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
slog.Debug("AuthRequired middleware hit")
|
||||
|
||||
@@ -35,7 +35,19 @@ func (s *Service) saveGame(c *entity.Game, onlyUpdate bool) error {
|
||||
return errors.New("game missing id or title")
|
||||
}
|
||||
if c.CoverID != "" {
|
||||
p, err := image.DownloadAndInsertImage(s.db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
|
||||
p, err := image.
|
||||
NewSaver(
|
||||
s.db,
|
||||
"games",
|
||||
image.ValidateOptions{
|
||||
// To avoid losing quality, we want to keep png format
|
||||
// for our game posters.
|
||||
ToFormat: image.ValidateAllowedFormatPNG,
|
||||
},
|
||||
).
|
||||
DownloadAndInsertFromUrl(
|
||||
"https://images.igdb.com/igdb/image/upload/t_cover_big/" +
|
||||
c.CoverID + ".png")
|
||||
if err != nil {
|
||||
slog.Error("saveGame: Failed to cache game cover.", "error", err)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// This router simply serves the images stored in the server data folder
|
||||
// under the `img` folder.
|
||||
// Note: The `img` folder contains user uploaded content (eg profile pictures).
|
||||
|
||||
package img
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{
|
||||
br,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
img := r.br.Router.Group("/img").
|
||||
Use(func(c *gin.Context) {
|
||||
// The two following headers are preventative since this group
|
||||
// (the static route below) hosts user uploaded content, which can
|
||||
// potentially include malicious data. We are trying to protect
|
||||
// against XSS attacks here by telling the browser to:
|
||||
// - Not sniff content; and
|
||||
// - Not execute JS; and
|
||||
// - treat the content as if it was a separate domain (so if eg
|
||||
// somehow js runs, it won't be in same context as our tokens).
|
||||
// RESOURCE: web.dev/articles/securely-hosting-user-data
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Serve up img folder.
|
||||
img.Static("/", path.Join(config.DataPath, "img"))
|
||||
}
|
||||
@@ -334,6 +334,27 @@ type PlexClientResources []struct {
|
||||
} `json:"connections"`
|
||||
}
|
||||
|
||||
// plexHTTPClient is the shared client for all Plex outbound calls. Its
|
||||
// CheckRedirect policy strips the custom X-Plex-Token header when a redirect
|
||||
// crosses to a different host. net/http already strips the standard sensitive
|
||||
// headers (Authorization, Cookie, WWW-Authenticate) on a cross-host redirect,
|
||||
// but it does NOT strip custom-named headers, so without this the Plex token
|
||||
// would be forwarded to any host the configured PLEX_HOST redirects to.
|
||||
var plexHTTPClient = &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del("X-Plex-Token")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *config.ServerConfig
|
||||
}
|
||||
@@ -345,7 +366,7 @@ func NewService(cfg *config.ServerConfig) *Service {
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexIdentity(host string) (PlexIdentity, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", host+"/identity", nil)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
@@ -369,7 +390,7 @@ func (s *Service) GetPlexIdentity(host string) (PlexIdentity, error) {
|
||||
}
|
||||
|
||||
func (s *Service) FetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/users/account.json", nil)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
@@ -419,7 +440,7 @@ func (s *Service) UpdateConfigPlexHost(cfg *config.ServerConfig, v string) (Plex
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/sections", nil)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
@@ -444,7 +465,7 @@ func (s *Service) GetPlexLibraries(plexAuth string) (PlexLibrariesResponse, erro
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
@@ -469,7 +490,7 @@ func (s *Service) GetPlexLibraryItems(plexAuth string, libraryKey string) (PlexL
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
@@ -494,7 +515,7 @@ func (s *Service) GetPlexLibraryItemSeasons(plexAuth string, ratingKey string) (
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
@@ -522,7 +543,7 @@ func (s *Service) GetPlexLibraryItemEpisodes(plexAuth string, ratingKey string)
|
||||
// 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 (s *Service) GetPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package plex
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
)
|
||||
|
||||
// TestGetPlexLibraries_StripsTokenOnCrossHostRedirect verifies the Plex client
|
||||
// does not forward X-Plex-Token across a cross-host redirect but preserves it
|
||||
// on a same-host redirect.
|
||||
func TestGetPlexLibraries_StripsTokenOnCrossHostRedirect(t *testing.T) {
|
||||
const token = "secret-plex-token"
|
||||
|
||||
t.Run("cross-host strips token", func(t *testing.T) {
|
||||
var finalToken string
|
||||
final := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
finalToken = r.Header.Get("X-Plex-Token")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"MediaContainer":{}}`))
|
||||
}))
|
||||
defer final.Close()
|
||||
redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, final.URL+r.URL.Path, http.StatusFound)
|
||||
}))
|
||||
defer redir.Close()
|
||||
|
||||
svc := NewService(&config.ServerConfig{PLEX_HOST: redir.URL})
|
||||
_, _ = svc.GetPlexLibraries(token)
|
||||
if finalToken != "" {
|
||||
t.Fatalf("X-Plex-Token forwarded cross-host = %q, want empty", finalToken)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same-host keeps token", func(t *testing.T) {
|
||||
var finalToken string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("r") == "1" {
|
||||
finalToken = r.Header.Get("X-Plex-Token")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"MediaContainer":{}}`))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, r.URL.Path+"?r=1", http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewService(&config.ServerConfig{PLEX_HOST: srv.URL})
|
||||
_, _ = svc.GetPlexLibraries(token)
|
||||
if finalToken != token {
|
||||
t.Fatalf("X-Plex-Token on same host = %q, want %q", finalToken, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -28,63 +27,38 @@ func NewService(db *gorm.DB) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if content has been previsouly watched by looking for related activity.
|
||||
// Checks if item has been previously watched by scanning for any activity
|
||||
// that counts as a play.
|
||||
func (s *Service) hasBeenPreviouslyWatched(a *[]entity.Activity) bool {
|
||||
wp := false
|
||||
var relatedActivity []entity.Activity
|
||||
for _, v := range *a {
|
||||
if v.Type == entity.ADDED_WATCHED ||
|
||||
v.Type == entity.IMPORTED_ADDED_WATCHED ||
|
||||
v.Type == entity.IMPORTED_WATCHED ||
|
||||
v.Type == entity.STATUS_CHANGED {
|
||||
relatedActivity = append(relatedActivity, v)
|
||||
if v.CountAsPlay {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(relatedActivity) <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, ra := range relatedActivity {
|
||||
if ra.Type == entity.IMPORTED_ADDED_WATCHED {
|
||||
wp = true
|
||||
break
|
||||
} else if ra.Type == entity.ADDED_WATCHED || ra.Type == entity.IMPORTED_WATCHED {
|
||||
if ra.Data == "" {
|
||||
continue
|
||||
}
|
||||
var v map[string]any
|
||||
err := json.Unmarshal([]byte(ra.Data), &v)
|
||||
if err != nil {
|
||||
slog.Error("Checking ADDED_WATCHED or IMPORTED_WATCHED.. failed to parse json data", "error", err)
|
||||
continue
|
||||
}
|
||||
if status, ok := v["status"]; ok {
|
||||
if status == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if ra.Type == entity.STATUS_CHANGED {
|
||||
if ra.Data == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return wp
|
||||
return false
|
||||
}
|
||||
|
||||
// Gets any data required for profile page
|
||||
func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
// Get user.
|
||||
user := new(entity.User)
|
||||
res := s.db.Model(&entity.User{}).Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to get profile:", "error", res.Error.Error())
|
||||
slog.Error("Failed to get profile:",
|
||||
"error", res.Error)
|
||||
return Profile{}, errors.New("failed to get profile")
|
||||
}
|
||||
|
||||
// Process stats.
|
||||
watched := new([]entity.Watched)
|
||||
res = s.db.Model(&entity.Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
res = s.db.Model(&entity.Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Activity").
|
||||
Where("user_id = ?", userId).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
|
||||
slog.Error("Profile: Failed to get watched for processing:",
|
||||
"error", res.Error)
|
||||
return Profile{}, errors.New("failed to get watched for processing")
|
||||
}
|
||||
var (
|
||||
@@ -95,11 +69,12 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
)
|
||||
for _, w := range *watched {
|
||||
isFinished := false
|
||||
if w.Status == entity.FINISHED {
|
||||
isFinished = true
|
||||
} else if *user.IncludePreviouslyWatched && s.hasBeenPreviouslyWatched(&w.Activity) {
|
||||
// If status is not finished and user has IncludePreviouslyWatched enabled,
|
||||
// then we can also check if content hasBeenPreviouslyWatched.
|
||||
// Note: Deliberately always checking `hasBeenPreviouslyWatched` for any
|
||||
// items without status set to FINISHED without checking users
|
||||
// `IncludePreviouslyWatched` setting, because that setting is useful
|
||||
// for filters, BUT not for these stats. I think it is always expected
|
||||
// that all previously watched stuff is included in finished stats.
|
||||
if w.Status == entity.FINISHED || s.hasBeenPreviouslyWatched(&w.Activity) {
|
||||
isFinished = true
|
||||
}
|
||||
if isFinished {
|
||||
@@ -107,7 +82,8 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
continue
|
||||
}
|
||||
c := *w.Content
|
||||
if c.Type == entity.SHOW {
|
||||
switch c.Type {
|
||||
case entity.SHOW:
|
||||
showsWatched++
|
||||
// This aint a science, just a very inaccurate guesstimate.
|
||||
if c.NumberOfEpisodes != 0 {
|
||||
@@ -116,9 +92,11 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
showRuntime = c.Runtime
|
||||
}
|
||||
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
|
||||
slog.Debug("calcualted", "show", c.Title, "runti", showRuntime*c.NumberOfEpisodes)
|
||||
slog.Debug("profile stat calculated",
|
||||
"show", c.Title,
|
||||
"runti", showRuntime*c.NumberOfEpisodes)
|
||||
}
|
||||
} else if c.Type == entity.MOVIE {
|
||||
case entity.MOVIE:
|
||||
moviesWatched++
|
||||
moviesWatchedRuntime += c.Runtime
|
||||
}
|
||||
|
||||
@@ -136,7 +136,9 @@ func (r *Router) UpdateAvatar(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := r.service.UploadUserAvatar(c, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
|
||||
+26
-47
@@ -1,16 +1,10 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/image"
|
||||
"gorm.io/gorm"
|
||||
@@ -71,7 +65,7 @@ func (s *Service) UserUpdate(userId uint, ur entity.UserSettings) (entity.UserSe
|
||||
}
|
||||
|
||||
func (s *Service) UserGetSettings(userId uint) (entity.UserSettings, error) {
|
||||
slog.Debug("user update request running", "user_id", userId)
|
||||
slog.Debug("UserGetSettings: Request running.", "user_id", userId)
|
||||
user := new(entity.User)
|
||||
res := s.db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
@@ -133,58 +127,43 @@ func (s *Service) UserUpdateBio(userId uint, newBio string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) UploadUserAvatar(c *gin.Context, userId uint) (entity.Image, error) {
|
||||
func (s *Service) UploadUserAvatar(
|
||||
c *gin.Context,
|
||||
userId uint,
|
||||
) (entity.Image, error) {
|
||||
file, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
slog.Error("failed to get file", "error", err)
|
||||
return entity.Image{}, errors.New("no file found")
|
||||
}
|
||||
|
||||
slog.Debug("an avatar is being uploaded", "name", file.Filename)
|
||||
slog.Debug("UploadUserAvatar: An avatar is being uploaded",
|
||||
"name", file.Filename)
|
||||
|
||||
f, _ := file.Open()
|
||||
if err := image.IsValidImageType(f); err != nil {
|
||||
return entity.Image{}, errors.New("invalid image type")
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
slog.Error("uploadUserAvatar: Copy failed!", "error", err)
|
||||
return entity.Image{}, errors.New("copy failed")
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
defer f.Close()
|
||||
|
||||
slog.Debug("image hash calculated", "hash", hs, "first_letter", hs[0:1])
|
||||
|
||||
// Upload the file to specific dst.
|
||||
outp := path.Join("img/up/", hs[0:1], hs+filepath.Ext(file.Filename))
|
||||
c.SaveUploadedFile(file, path.Join(config.DataPath, outp))
|
||||
|
||||
_, err = f.Seek(0, 0)
|
||||
img, err := image.
|
||||
NewSaver(s.db, "up", image.ValidateOptions{}).
|
||||
DownloadAndInsert(f)
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar seeking back to start of image failed", "error", err)
|
||||
slog.Error("UploadUserAvatar: DownloadAndInsert failed!",
|
||||
"error", err)
|
||||
return entity.Image{}, errors.New("processing image failed")
|
||||
}
|
||||
|
||||
// No need to remove old image, the daily cleanup task will handle removing unused ones.
|
||||
var img entity.Image
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Insert avatar into db
|
||||
img, err = image.InsertImage(s.db, hs, outp, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if img.ID == 0 {
|
||||
return errors.New("image has no id")
|
||||
}
|
||||
// Update users avatar to newly inserted
|
||||
if err := tx.Where("id = ?", userId).Updates(&entity.User{AvatarID: img.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar failed!", "error", err)
|
||||
return entity.Image{}, errors.New("uploadUserAvatar transaction failed")
|
||||
// No need to remove old image, the daily cleanup task will handle removing
|
||||
// unused ones.
|
||||
|
||||
// Update users avatar to newly inserted
|
||||
res := s.db.
|
||||
Where("id = ?", userId).
|
||||
Updates(&entity.User{AvatarID: img.ID})
|
||||
if res.Error != nil {
|
||||
slog.Error("UploadUserAvatar: Updating the users avatar in db failed!",
|
||||
"error", err)
|
||||
return entity.Image{}, errors.New("updating user failed")
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
@@ -22,11 +22,16 @@ type GameProvider interface {
|
||||
GetOrCache(igdbID int) (entity.Game, error)
|
||||
}
|
||||
|
||||
type UserProvider interface {
|
||||
UserGetSettings(userId uint) (entity.UserSettings, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
cp ContentProvider
|
||||
gameProvider GameProvider
|
||||
activityProvider domain.ActivityAddProvider
|
||||
userProvider UserProvider
|
||||
}
|
||||
|
||||
func NewService(
|
||||
@@ -34,12 +39,14 @@ func NewService(
|
||||
cp ContentProvider,
|
||||
gameProvider GameProvider,
|
||||
activityProvider domain.ActivityAddProvider,
|
||||
userProvider UserProvider,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db,
|
||||
cp,
|
||||
gameProvider,
|
||||
activityProvider,
|
||||
userProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +81,16 @@ func (s *Service) GetWatchedPage(
|
||||
"user_id", userId,
|
||||
"pagination_params", pp,
|
||||
"wr", wr)
|
||||
watched := new([]entity.Watched)
|
||||
|
||||
pRes := &util.PaginationResponse[entity.Watched, util.None]{}
|
||||
|
||||
// Get user settings.
|
||||
userSettings, err := s.userProvider.UserGetSettings(userId)
|
||||
if err != nil {
|
||||
return *pRes, errors.New("failed to get user settings")
|
||||
}
|
||||
|
||||
watched := new([]entity.Watched)
|
||||
res := s.db.
|
||||
Model(&entity.Watched{}).
|
||||
Where(&entity.Watched{UserID: userId})
|
||||
@@ -101,7 +116,7 @@ func (s *Service) GetWatchedPage(
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
// Apply filters first.
|
||||
Scopes(watchedRefineFilter(wr)).
|
||||
Scopes(watchedRefineFilter(wr, &userSettings)).
|
||||
// Then count results (after filter);
|
||||
Count(&pRes.TotalResults).
|
||||
// Now calculate pagination properties with a TotalResults
|
||||
@@ -164,7 +179,7 @@ func (s *Service) getPublicWatched(
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
// Apply filters first.
|
||||
Scopes(watchedRefineFilter(wr)).
|
||||
Scopes(watchedRefineFilter(wr, nil)).
|
||||
// Then count results (after filter);
|
||||
Count(&pRes.TotalResults).
|
||||
// Now calculate pagination properties with a TotalResults
|
||||
@@ -443,7 +458,8 @@ func (s *Service) AddWatched(
|
||||
&watched,
|
||||
); err != nil {
|
||||
// Try to restore the entry if unique contraint hit.
|
||||
slog.Error("AddWatched: Failed to restore existing watched entry.")
|
||||
slog.Error("AddWatched: Failed to restore existing watched entry.",
|
||||
"error", err)
|
||||
// Returns watched too because handlers of certain errors
|
||||
// may need it (and it's ID since we could have fetched it here)
|
||||
return watched, err
|
||||
|
||||
@@ -38,15 +38,44 @@ func refineFilterType(db *gorm.DB, ft []util.SupportedMedia) {
|
||||
}
|
||||
|
||||
// Applies 'Status' filter.
|
||||
func refineFilterStatus(db *gorm.DB, f []entity.WatchedStatus) {
|
||||
func refineFilterStatus(
|
||||
db *gorm.DB,
|
||||
f []entity.WatchedStatus,
|
||||
userSettings *entity.UserSettings,
|
||||
) {
|
||||
if len(f) <= 0 {
|
||||
return
|
||||
}
|
||||
// Process the input data.
|
||||
fIncludesFinished := false
|
||||
for i := range f {
|
||||
// Ensure string **case** is valid WatchedStatus by converting to uppercase.
|
||||
f[i] = entity.WatchedStatus(strings.ToUpper(string(f[i])))
|
||||
if f[i] == entity.FINISHED {
|
||||
fIncludesFinished = true
|
||||
slog.Debug("refineFilterStatus: f includes FINISHED")
|
||||
}
|
||||
}
|
||||
// Apply the query.
|
||||
if fIncludesFinished &&
|
||||
userSettings != nil && util.Deref(userSettings.IncludePreviouslyWatched, false) {
|
||||
slog.Debug("refineFilterStatus: Performing query that includes previously watched.")
|
||||
db.
|
||||
// If status IN `f` OR any activity counts as a play for this
|
||||
// watched item.
|
||||
// NOTE: GORM adds parenthesis around this WHERE so that the OR
|
||||
// doesn't confuse the whole WHERE on the main query, so we don't
|
||||
// need to do that.
|
||||
Where(`watcheds.status IN ? OR EXISTS (
|
||||
SELECT 1
|
||||
FROM activities
|
||||
WHERE activities.watched_id = watcheds.id
|
||||
AND activities.count_as_play = 1
|
||||
)`, f)
|
||||
} else {
|
||||
slog.Debug("refineFilterStatus: Performing standard query.")
|
||||
db.Where("watcheds.status IN ?", f)
|
||||
}
|
||||
db.Where("watcheds.status IN ?", f)
|
||||
}
|
||||
|
||||
// Applies sorts to list.
|
||||
@@ -125,11 +154,14 @@ func refineSortPinned(db *gorm.DB) {
|
||||
|
||||
// list data.
|
||||
// gorm scope for applying filters to watched
|
||||
func watchedRefineFilter(wr domain.WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
|
||||
func watchedRefineFilter(
|
||||
wr domain.WatchedGetPageRequest,
|
||||
userSettings *entity.UserSettings,
|
||||
) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
// Apply filters
|
||||
refineFilterType(db, wr.FilterType)
|
||||
refineFilterStatus(db, wr.FilterStatus)
|
||||
refineFilterStatus(db, wr.FilterStatus, userSettings)
|
||||
return db
|
||||
}
|
||||
}
|
||||
|
||||
+14
-108
@@ -2,33 +2,35 @@ package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TODO now that this file is in the image package it no longer needs to have "image(s)"
|
||||
// in the name of all the functions..
|
||||
const (
|
||||
defMaxSize int64 = 10 * util.Mebibyte
|
||||
defMaxWidthHeight int64 = 7680
|
||||
defMaxPixels int64 = 10_000_000
|
||||
)
|
||||
|
||||
// Insert an image into database
|
||||
func InsertImage(db *gorm.DB, hash string, path string, f io.Reader) (entity.Image, error) {
|
||||
bh, _ := GetBlurHash(f)
|
||||
func Insert(
|
||||
db *gorm.DB,
|
||||
hash string,
|
||||
path string,
|
||||
b []byte,
|
||||
) (entity.Image, error) {
|
||||
br := bytes.NewReader(b)
|
||||
bh, _ := GetBlurHash(br)
|
||||
img := entity.Image{
|
||||
Hash: hash,
|
||||
Path: path,
|
||||
@@ -95,99 +97,3 @@ WHERE NOT EXISTS (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidImageType(f multipart.File) error {
|
||||
// Read first 512 bytes, since that is all `DetectContentType` will evaluate on.
|
||||
// Reading whole file is a waste.
|
||||
buff := make([]byte, 512)
|
||||
if _, err := f.Read(buff); err != nil {
|
||||
slog.Error("isValidImageType: failed to read file into buffer", "error", err)
|
||||
return errors.New("failed to verify if image is valid")
|
||||
}
|
||||
t := http.DetectContentType(buff)
|
||||
slog.Debug("isValidImageType", "type", t)
|
||||
if t != "image/png" && t != "image/jpeg" && t != "image/webp" && t != "image/gif" {
|
||||
slog.Debug("isValidImageType: rejecting file as not valid (supported) image type")
|
||||
return errors.New("invalid file type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DownloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (entity.Image, error) {
|
||||
slog.Debug("Attempting to download image", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return entity.Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Read body into byte array, then create new reader
|
||||
// So we have the ability to seek.
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage failed to read response into byte array", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, br); err != nil {
|
||||
slog.Error("DownloadAndInsertImage: Copy failed!", "error", err)
|
||||
return entity.Image{}, errors.New("copy failed")
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Seek back for file
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
outp := path.Join("img/", imgSubPath, hs[0:1], hs+filepath.Ext(resp.Request.URL.Path))
|
||||
dataOutP := path.Join(config.DataPath, outp)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(dataOutP)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(dataOutP), 0764)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(dataOutP)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
} else {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Seek back for insertImage
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
img, err := InsertImage(db, hs, outp, br)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/internal/testutil"
|
||||
)
|
||||
|
||||
func TestDownloadAndInsertFromUrl(t *testing.T) {
|
||||
testutil.SetupLogging()
|
||||
db := testutil.SetupDB(t)
|
||||
|
||||
i, err := NewSaver(db, "test", ValidateOptions{}).
|
||||
DownloadAndInsertFromUrl(
|
||||
"https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png")
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadAndInsert call failed: %v", err)
|
||||
}
|
||||
|
||||
if i.ID == 0 || i.Path == "" || i.BlurHash == "" {
|
||||
t.Fatal("returned entity.Image doesn't have certain fields!",
|
||||
"id", i.ID, "path", i.Path, "blurhash", i.BlurHash)
|
||||
}
|
||||
|
||||
fullImgDataPath := path.Join(config.DataPath, i.Path)
|
||||
|
||||
// Verify file exists and looks right.
|
||||
if fi, err := os.Stat(fullImgDataPath); err != nil {
|
||||
t.Fatalf("os.stat failed %v", err)
|
||||
} else if fi.Size() <= 1 {
|
||||
t.Fatalf("image file size doesn't seem right: %v", fi.Size())
|
||||
} else if filepath.Ext(fi.Name()) != ".jpg" {
|
||||
t.Fatalf("image file name doesn't have .jpg ext: %s", fi.Name())
|
||||
}
|
||||
|
||||
if err := os.Remove(fullImgDataPath); err != nil {
|
||||
// Not a fatal error because this is extra logic only for testing,
|
||||
// but failing test because something in the real logic might possibly
|
||||
// have something to do with it failing and we should probably know.
|
||||
t.Errorf("removing the image file errored: %v", err)
|
||||
}
|
||||
|
||||
// Verify image is in db
|
||||
var c int64
|
||||
if res := db.
|
||||
Model(&entity.Image{}).
|
||||
Where(&entity.Image{ID: i.ID}).
|
||||
Count(&c); res.Error != nil {
|
||||
t.Fatalf("verification query failed: %v", res.Error)
|
||||
}
|
||||
if c != 1 {
|
||||
t.Fatalf("count of images in db doesn't look right: %v", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewSaver(db *gorm.DB, saveSubPath string, vo ValidateOptions) *Saver {
|
||||
return &Saver{
|
||||
db: db,
|
||||
SaveSubPath: saveSubPath,
|
||||
ValidateOptions: vo,
|
||||
}
|
||||
}
|
||||
|
||||
type Saver struct {
|
||||
// Database
|
||||
db *gorm.DB
|
||||
// Image save sub path.
|
||||
// Eg: `img/<subPath>/`
|
||||
SaveSubPath string
|
||||
// Validate options.
|
||||
ValidateOptions ValidateOptions
|
||||
}
|
||||
|
||||
// Download an image from `url` and insert it.
|
||||
func (s *Saver) DownloadAndInsertFromUrl(url string) (entity.Image, error) {
|
||||
slog.Debug("DownloadAndInsertFromUrl: Running.", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return entity.Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
return s.DownloadAndInsert(resp.Body)
|
||||
}
|
||||
|
||||
// Download an image from a provided Reader and insert it.
|
||||
func (s *Saver) DownloadAndInsert(r io.Reader) (entity.Image, error) {
|
||||
slog.Debug("DownloadAndInsert: Running.")
|
||||
|
||||
// Read all into memory.
|
||||
b, err := util.LimitedReadAll(r, defMaxSize)
|
||||
if err != nil {
|
||||
slog.Error("DownloadAndInsert: Failed to read response!", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Save the file.
|
||||
imgHash, imgPath, err := s.save(b)
|
||||
if err != nil {
|
||||
slog.Error("DownloadAndInsert: Failed to save file!", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Insert image into db.
|
||||
imge, err := Insert(s.db, imgHash, imgPath, b)
|
||||
if err != nil {
|
||||
slog.Error("DownloadAndInsert: Insert into db failed!", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
return imge, nil
|
||||
}
|
||||
|
||||
// Creates the image file on disk.
|
||||
// Returns image hash, image path and error.
|
||||
func (s *Saver) save(b []byte) (string, string, error) {
|
||||
br := bytes.NewReader(b)
|
||||
// First we get a hash of the files contents.
|
||||
// Using the hash for filename has the benefit of us not storing duplicate
|
||||
// files just because their filename provided to us is different.
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, br); err != nil {
|
||||
slog.Error("save: Copy failed!", "error", err)
|
||||
return "", "", errors.New("copy failed")
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
slog.Debug("save: image hash calculated",
|
||||
"hash", hs,
|
||||
"first_letter", hs[0:1])
|
||||
|
||||
// Validate the file.
|
||||
// We always validate, even from "trusted sources!"
|
||||
b, ext, err := s.validate(b)
|
||||
if err != nil {
|
||||
slog.Error("save: Validate failed!", "error", err)
|
||||
return "", "", err
|
||||
}
|
||||
br = bytes.NewReader(b)
|
||||
|
||||
// Create paths for file.
|
||||
imgPath := path.Join(
|
||||
// Always outputs to `img/` dir.
|
||||
"img/",
|
||||
// Any sub path for separating images.
|
||||
s.SaveSubPath,
|
||||
// Sub-separate images by the starting character of their hash.
|
||||
hs[0:1],
|
||||
// File name is whole hash then the file extension.
|
||||
hs+ext)
|
||||
fullOutPath := path.Join(config.DataPath, imgPath)
|
||||
slog.Debug("save: Built path", "path", imgPath)
|
||||
|
||||
// Save file
|
||||
err = os.MkdirAll(path.Dir(fullOutPath), 0764)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
out, err := os.Create(fullOutPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return hs, imgPath, nil
|
||||
}
|
||||
|
||||
type ValidateAllowedFormat string
|
||||
|
||||
const (
|
||||
ValidateAllowedFormatJPEG ValidateAllowedFormat = "jpeg"
|
||||
ValidateAllowedFormatPNG ValidateAllowedFormat = "png"
|
||||
)
|
||||
|
||||
type ValidateOptions struct {
|
||||
ToFormat ValidateAllowedFormat
|
||||
}
|
||||
|
||||
// Validate the image safely.
|
||||
// Re-encodes the image in our own desired format, which helps verify the file
|
||||
// is a valid image and any undesirable data (eg think xss; appended html,
|
||||
// exit, etc) is not kept in the final image file we store.
|
||||
// We currently prefer jpg for the format we re-encode to, which also helps us
|
||||
// save storage space (adds compression, which the image we are validating
|
||||
// could lack or have a higher quality setting, etc).
|
||||
// Returns the new re-encoded image data, file extension, and error.
|
||||
// NOTE: Never use a user-set file extension (always validate we allow it).
|
||||
func (s *Saver) validate(b []byte) ([]byte, string, error) {
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
// Check image header for config/format.
|
||||
cfg, format, err := image.DecodeConfig(br)
|
||||
if err != nil {
|
||||
slog.Error("Validate: Failed to DecodeConfig", "error", err)
|
||||
return []byte{}, "", errors.New("invalid or bad image")
|
||||
}
|
||||
slog.Debug("Validate",
|
||||
"cfg.Width", cfg.Width,
|
||||
"cfg.Height", cfg.Height,
|
||||
"format", format)
|
||||
if int64(cfg.Width) > defMaxWidthHeight ||
|
||||
int64(cfg.Height) > defMaxWidthHeight {
|
||||
return []byte{}, "", errors.New("dimensions too large")
|
||||
}
|
||||
// Protect against images that max out the allowed width/height.
|
||||
// If we assume each pixel is 4 bytes, someone maxing out 8000x8000 would
|
||||
// mean ~235mb of data we need to decode into memory (i think!), but instead
|
||||
// of limiting the max width/height values too much, we can limit the max
|
||||
// amount of pixels to restrict the max size of the img pixels we'd allow.
|
||||
// Then weird aspect ratios are still allowed.
|
||||
// I'm definitely over-engineering this feature for a self-hosted movie list app lol.
|
||||
if int64(cfg.Width)*int64(cfg.Height) > defMaxPixels {
|
||||
return []byte{}, "", errors.New("i can't handle all those pixels")
|
||||
}
|
||||
|
||||
// Seek back, we are reading again below for decode.
|
||||
if _, err = br.Seek(0, 0); err != nil {
|
||||
slog.Error("Validate: Seeking reader to start failed", "error", err)
|
||||
return []byte{}, "", err
|
||||
}
|
||||
|
||||
// Decode image.
|
||||
// This should catch any malformed image files.
|
||||
var img image.Image
|
||||
switch format {
|
||||
case "png":
|
||||
img, err = png.Decode(br)
|
||||
case "jpeg":
|
||||
img, err = jpeg.Decode(br)
|
||||
case "gif":
|
||||
img, err = gif.Decode(br)
|
||||
default:
|
||||
return []byte{}, "", errors.New("unsupported image type")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("full image decode failed", "error", err, "format", format)
|
||||
return []byte{}, "", errors.New("invalid or corrupt image")
|
||||
}
|
||||
|
||||
// Re-encode the image from our decoded data (any extra included, possibly
|
||||
// malicious data not part of the image should be gone now).
|
||||
// exif data, etc should also be gone now too which is good.
|
||||
// We just re-encode as jpeg right now, but if we wanted to, in the future
|
||||
// it's possible to encode different formats based on if we want to perserve
|
||||
// transparency from png, etc. OR maybe using webp will be easier and we
|
||||
// can just use that format since it supports transparency and animations.
|
||||
outfmt := ValidateAllowedFormatJPEG
|
||||
if s.ValidateOptions.ToFormat != "" {
|
||||
outfmt = s.ValidateOptions.ToFormat
|
||||
}
|
||||
|
||||
switch outfmt {
|
||||
case ValidateAllowedFormatJPEG:
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
|
||||
slog.Error("Validate: Failed to encode jpeg", "error", err)
|
||||
return []byte{}, "", errors.New("failed to encode image")
|
||||
}
|
||||
return buf.Bytes(), ".jpg", nil
|
||||
case ValidateAllowedFormatPNG:
|
||||
// NOTE: PNG->PNG re-encode can still result in output file being a bit
|
||||
// bigger, I think this is acceptable. I haven't seen any case where the
|
||||
// difference is big enough to care (sometimes can be smaller output too).
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
slog.Error("Validate: Failed to encode png", "error", err)
|
||||
return []byte{}, "", errors.New("failed to encode image")
|
||||
}
|
||||
return buf.Bytes(), ".png", nil
|
||||
default:
|
||||
return []byte{}, "", errors.New("invalid outfmt described")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
We don't really use the `internal` folder because none of our code is anything we'd ever expect anyone to import and rely on in their own codebase.
|
||||
|
||||
However, for some packages, it might make sense to put it here just as a signal to us (while developing) that this package is not code that goes into a prod build, etc (eg: `testutil`). This feels nicer, avoiding a scenario where our root folder has a bunch of real packages and ones that should never see prod mixed together (which is probably confusing).
|
||||
@@ -0,0 +1,55 @@
|
||||
// testutil is for testing code that we want to reuse for tests.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SetupLogging will configure the default slog logger.
|
||||
// Since our main app uses `slog`, this is useful for getting
|
||||
// debug logs to show, or hiding all, etc.
|
||||
// Controlled by env var `WTEST_LOG_LEVEL` (accepts: `debug` or `error`), if not
|
||||
// set, default Info log level is used.
|
||||
func SetupLogging() {
|
||||
level := slog.LevelInfo
|
||||
switch os.Getenv("WTEST_LOG_LEVEL") {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(
|
||||
os.Stdout, &slog.HandlerOptions{Level: level})))
|
||||
}
|
||||
|
||||
// Setup a fresh database for testing.
|
||||
// Exits test by using t.Fatalf if something fails.
|
||||
func SetupDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
// Open our test db.
|
||||
// Note: Could have used inmemory db, but it breaks our WAL migration and
|
||||
// errors out, and I don't wanna mess with prod code simply so I can use
|
||||
// an inmem db for testing, so we make a temporary file db.
|
||||
db, err := gorm.Open(
|
||||
sqlite.Open(filepath.Join(t.TempDir(), "test-watcharr.db")),
|
||||
&gorm.Config{TranslateError: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test db: %v", err)
|
||||
}
|
||||
|
||||
// Setup the db same as we do for prod.
|
||||
if err := database.Setup(db); err != nil {
|
||||
t.Fatalf("failed to migrate test db: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
func LimitedReadAll(r io.Reader, maxSize int64) ([]byte, error) {
|
||||
// LimitReader: Allow reading one extra byte so our maxSize check later works.
|
||||
b, err := io.ReadAll(io.LimitReader(r, maxSize+1))
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
if int64(len(b)) > maxSize {
|
||||
return b, errors.New("file is too big")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package util
|
||||
|
||||
const (
|
||||
Byte int64 = 1
|
||||
Kibibyte int64 = 1024
|
||||
Mebibyte int64 = 1024 * Kibibyte
|
||||
)
|
||||
+8
-3
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/sbondCo/Watcharr/feature/feature"
|
||||
"github.com/sbondCo/Watcharr/feature/follow"
|
||||
"github.com/sbondCo/Watcharr/feature/game"
|
||||
"github.com/sbondCo/Watcharr/feature/img"
|
||||
"github.com/sbondCo/Watcharr/feature/imprt"
|
||||
"github.com/sbondCo/Watcharr/feature/jellyfin"
|
||||
"github.com/sbondCo/Watcharr/feature/job"
|
||||
@@ -205,7 +206,12 @@ func main() {
|
||||
userService := user.NewService(db)
|
||||
userManageService := user.NewManageService(db)
|
||||
gameService := game.NewService(db, &br.Cfg.TWITCH, activityService)
|
||||
watchedService := watched.NewService(db, contentService, gameService, activityService)
|
||||
watchedService := watched.NewService(
|
||||
db,
|
||||
contentService,
|
||||
gameService,
|
||||
activityService,
|
||||
userService)
|
||||
watchedSeasonService := season.NewService(db, activityService)
|
||||
watchedEpisodeService := episode.NewService(
|
||||
db,
|
||||
@@ -266,6 +272,7 @@ func main() {
|
||||
game.NewRouter(br, gameService, watchedService).AddRoutes()
|
||||
search.NewRouter(br, searchService, watchedService).AddRoutes()
|
||||
discover.NewRouter(br, discoverService, watchedService).AddRoutes()
|
||||
img.NewRouter(br).AddRoutes()
|
||||
|
||||
// Only add setup routes if there are no users found in db.
|
||||
var userCount int64
|
||||
@@ -281,8 +288,6 @@ func main() {
|
||||
"error", uresp.Error)
|
||||
}
|
||||
|
||||
api.Static("/img", path.Join(config.DataPath, "img"))
|
||||
|
||||
go taskl.SetupTasks(cfg, db)
|
||||
|
||||
gine.Run("0.0.0.0:3080")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
|
||||
+40
-16
@@ -1,23 +1,32 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
style?: string;
|
||||
}
|
||||
|
||||
let { style = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="loader" {style}></div>
|
||||
<span class="loader">
|
||||
<svg viewBox="0 0 50 50">
|
||||
<circle
|
||||
cx="25"
|
||||
cy="25"
|
||||
r="20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
$animDur: 1.4s;
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border: 2px solid $text-color;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
height: 16px;
|
||||
animation: rotation $animDur linear infinite;
|
||||
|
||||
svg {
|
||||
circle {
|
||||
stroke-dasharray: 80px 200px;
|
||||
stroke-dashoffset: 0px;
|
||||
animation: circle-size $animDur ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
@@ -28,4 +37,19 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes circle-size {
|
||||
0% {
|
||||
stroke-dasharray: 1px 200px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 100px 200px;
|
||||
stroke-dashoffset: -15px;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1px 200px;
|
||||
stroke-dashoffset: -124px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
return a.best ? -1 : 1;
|
||||
});
|
||||
if (t[0]?.id) {
|
||||
return `https://www.youtube.com/embed/${t[0]?.id}`;
|
||||
return `https://www.youtube-nocookie.com/embed/${t[0]?.id}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
bind:this={avatarInput}
|
||||
type="file"
|
||||
title=""
|
||||
accept=".jpg,.png,.gif,.webp"
|
||||
accept=".jpg,.png,.gif"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { store } from "@/store.svelte";
|
||||
import SpinnerTiny from "./SpinnerTiny.svelte";
|
||||
import { unNotify } from "./util/notify";
|
||||
import Icon from "./Icon.svelte";
|
||||
</script>
|
||||
|
||||
<div id="notifications">
|
||||
{#each store.notifications as n}
|
||||
<div class={`${n.type} notif`}>
|
||||
{#if n.type === "loading"}
|
||||
<SpinnerTiny />
|
||||
{/if}
|
||||
<!-- only comes from our strings (which may have html) -->
|
||||
<!-- eslint-disable-next-line -->
|
||||
<span>{@html n.text}</span>
|
||||
<button
|
||||
class="plain"
|
||||
onclick={() => {
|
||||
unNotify(n.id);
|
||||
}}
|
||||
>
|
||||
<Icon i="close" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
#notifications {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
z-index: 99999;
|
||||
|
||||
.notif {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
color: black;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba($color: #000000, $alpha: 0.2);
|
||||
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
|
||||
animation: comein 250ms ease forwards;
|
||||
position: relative;
|
||||
|
||||
&.loading {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes comein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: white;
|
||||
background-color: $error;
|
||||
border: 1px solid $error;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: white;
|
||||
background-color: $success;
|
||||
border: 1px solid $success;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid rgba($color: black, $alpha: 0.2);
|
||||
padding: 10px 12px;
|
||||
padding-left: 9px;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@
|
||||
import { notify } from "../util/notify";
|
||||
import { store } from "@/store.svelte";
|
||||
import { removeWatchedEpisode, updateWatchedEpisode } from "./api";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
ep: TMDBSeasonDetailsEpisode;
|
||||
@@ -18,32 +19,65 @@
|
||||
|
||||
let { ep, watchedItem }: Props = $props();
|
||||
|
||||
const we = $derived(
|
||||
watchedItem?.watchedEpisodes?.find(
|
||||
(s) =>
|
||||
s.seasonNumber === ep.season_number &&
|
||||
s.episodeNumber === ep.episode_number,
|
||||
),
|
||||
);
|
||||
|
||||
let isHidden: boolean = $state(!!store?.userSettings?.hideSpoilers);
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
/**
|
||||
* Re-sets `isHidden` state.
|
||||
*/
|
||||
function reSetIsHidden() {
|
||||
// If the episode status is "FINISHED", ensure `isHidden` is set to
|
||||
// `false` (so finished episodes aren't blurred when hideSpoilers is on).
|
||||
if (we?.status == "FINISHED") {
|
||||
isHidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
reSetIsHidden();
|
||||
});
|
||||
|
||||
async function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (!watchedItem) {
|
||||
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
|
||||
return;
|
||||
}
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedEpisodes?.find(
|
||||
(s) =>
|
||||
s.seasonNumber === ep.season_number &&
|
||||
s.episodeNumber === ep.episode_number,
|
||||
);
|
||||
if (!ws) {
|
||||
if (!we || !we.id) {
|
||||
notify({
|
||||
text: "Failed to find watched episode id. Please try refreshing.",
|
||||
type: "error",
|
||||
});
|
||||
console.error(
|
||||
"handleStatusClick(DELETE): `we` doesn't exist or have an id",
|
||||
we,
|
||||
);
|
||||
return;
|
||||
}
|
||||
removeWatchedEpisode(watchedItem, ws.id);
|
||||
removeWatchedEpisode(watchedItem, we.id);
|
||||
// NOTE: Similar to below where we `reSetIsHidden` to unhide spoilers
|
||||
// automatically if status is set to FINISHED, we WONT do the opposite
|
||||
// here and re-hide the spoilers (if unhidden) after removing an episode
|
||||
// because that would probably be annoying to users (eg: click to
|
||||
// show spoilers, then delete episode, spoilers re-hidden automatically).
|
||||
return;
|
||||
}
|
||||
updateWatchedEpisode(watchedItem, ep.season_number, ep.episode_number, {
|
||||
status: type,
|
||||
});
|
||||
await updateWatchedEpisode(
|
||||
watchedItem,
|
||||
ep.season_number,
|
||||
ep.episode_number,
|
||||
{
|
||||
status: type,
|
||||
},
|
||||
);
|
||||
reSetIsHidden();
|
||||
}
|
||||
|
||||
function handleStarClick(rating: number) {
|
||||
@@ -90,11 +124,6 @@
|
||||
<span class="overview">{ep.overview}</span>
|
||||
</div>
|
||||
{#if watchedItem}
|
||||
{@const we = watchedItem.watchedEpisodes?.find(
|
||||
(s) =>
|
||||
s.seasonNumber === ep.season_number &&
|
||||
s.episodeNumber === ep.episode_number,
|
||||
)}
|
||||
<div class="status-rating-ctr">
|
||||
<div class="rating" style={"width: 45px"}>
|
||||
<PosterRating
|
||||
@@ -221,6 +250,19 @@
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
overflow: visible;
|
||||
|
||||
/* z-index of 2 so the button is higher than .spoiler-text
|
||||
which makes it clickable while whole ep is still hidden.
|
||||
Which is useful if you don't want spoilers while setting
|
||||
the episode to WATCHING, etc. */
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
/* On hover, the z-index is higher than all other status
|
||||
buttons on the page to avoid the active one being put
|
||||
below others (making it unuseable). */
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ export function preparePlexAuth(): Plex {
|
||||
// Don't really want to give all the possible headers,
|
||||
// trying to minimize it to what gets it working.
|
||||
headers: {
|
||||
// So plex api returns json.
|
||||
Accept: "application/json",
|
||||
// Plex product specific headers:
|
||||
"X-Plex-Product": "Watcharr",
|
||||
"X-Plex-Client-Identifier": clientId,
|
||||
"X-Plex-Version": "Plex OAuth",
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
<td class="icon-cell">
|
||||
<div>
|
||||
{#if !l.state}
|
||||
<SpinnerTiny style="width: 13px;" />
|
||||
<SpinnerTiny />
|
||||
{:else if l.state === ImportResponseType.IMPORT_SUCCESS}
|
||||
<Icon i="check" wh={22} />
|
||||
{:else if l.state === ImportResponseType.IMPORT_NOTFOUND}
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
|
||||
<Setting
|
||||
title="Include Previously Watched"
|
||||
desc="Deprecated: This setting is due to be removed because I think TRUE is the only useful value (the removal will go through soon, please give feedback if you have any opinions!)."
|
||||
desc="Should previously finished items be included in the 'Finished' status filter?"
|
||||
row
|
||||
>
|
||||
<Checkbox
|
||||
@@ -308,8 +308,6 @@
|
||||
includePreviouslyWatchedDisabled = true;
|
||||
updateUserSetting("includePreviouslyWatched", on, () => {
|
||||
includePreviouslyWatchedDisabled = false;
|
||||
// Get profile stats again
|
||||
getProfilePromise = getProfile();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import SpinnerTiny from "@/lib/SpinnerTiny.svelte";
|
||||
import { unNotify } from "@/lib/util/notify";
|
||||
import { store } from "@/store.svelte";
|
||||
import Notifications from "@/lib/notifications.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { pwaInfo } from "virtual:pwa-info";
|
||||
|
||||
@@ -37,26 +34,8 @@
|
||||
</svelte:head>
|
||||
|
||||
<div id="tooltip"></div>
|
||||
<div id="notifications">
|
||||
{#each store.notifications as n}
|
||||
<div class={`${n.type} notif`}>
|
||||
{#if n.type === "loading"}
|
||||
<SpinnerTiny />
|
||||
{/if}
|
||||
<!-- only comes from our strings (which may have html) -->
|
||||
<!-- eslint-disable-next-line -->
|
||||
<span>{@html n.text}</span>
|
||||
<button
|
||||
class="plain"
|
||||
onclick={() => {
|
||||
unNotify(n.id);
|
||||
}}
|
||||
>
|
||||
<Icon i="close" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Notifications />
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
|
||||
@@ -235,89 +235,6 @@
|
||||
}
|
||||
|
||||
:global {
|
||||
#notifications {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
z-index: 99999;
|
||||
|
||||
.notif {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
color: black;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba($color: #000000, $alpha: 0.2);
|
||||
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
|
||||
animation: comein 250ms ease forwards;
|
||||
position: relative;
|
||||
|
||||
&.loading {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes comein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: white;
|
||||
background-color: $error;
|
||||
border: 1px solid $error;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: white;
|
||||
background-color: $success;
|
||||
border: 1px solid $success;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid rgba($color: black, $alpha: 0.2);
|
||||
padding: 10px 12px;
|
||||
padding-left: 9px;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.small-scrollbar {
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none" viewBox="0 0 512 512">
|
||||
<style>
|
||||
/* For dark themed browsers, we make the favicon a more visible color. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fav-fill {
|
||||
fill: #fff !important;
|
||||
}
|
||||
|
||||
.fav-outline {
|
||||
stroke: #fff !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path class="fav-fill"
|
||||
d="M272.375 449.62 257.1 374.883l-21.23 99.733L147.329 482l-53.075-26.552L10.5 59.107l95.922-7.901 53.074 26.552 11.65 62.56 19.159-91.703 84.27-6.994 50.874 26.552 16.44 80.175 20.712-109.836L448.426 31 501.5 57.552 419.818 468.79l-94.369 7.9zm94.498-405.797-41.424 224.852q-.517 4.793-1.294 12.305-.777 7.383-1.424 13.47l-.518 6.088h-1.553l-.518-5.829q-.518-5.827-1.553-13.47-1.035-7.771-2.201-12.046L269.786 47.45l-75.339 6.347-41.294 207.496q-1.036 6.347-2.719 16.45-1.553 10.103-2.33 18.392l-.776 8.16h-1.683l-.518-7.642q-.518-7.77-1.812-18.133t-2.848-18.263L101.762 57.034l-84.919 7.383 81.682 385.202 79.482-6.346 45.695-212.16q1.036-5.31 2.33-16.19a549 549 0 0 0 2.072-20.465l.906-9.584 1.553-.518.777 9.325q.777 9.197 2.071 20.076 1.425 10.88 2.46 16.19l40.776 214.88 85.436-7.383 79.482-399.968z"
|
||||
style="fill:#000;fill-opacity:1" />
|
||||
<path class="fav-outline"
|
||||
d="M272.375 449.62 257.1 374.883l-21.23 99.733L147.329 482l-53.075-26.552L10.5 59.107l95.922-7.901 53.074 26.552 11.65 62.56 19.159-91.703 84.27-6.994 50.874 26.552 16.44 80.175 20.712-109.836L448.426 31 501.5 57.552 419.818 468.79l-94.369 7.9zm94.498-405.797-41.424 224.852q-.517 4.793-1.294 12.305-.777 7.383-1.424 13.47l-.518 6.088h-1.553l-.518-5.829q-.518-5.827-1.553-13.47-1.035-7.771-2.201-12.046L269.786 47.45l-75.339 6.347-41.294 207.496q-1.036 6.347-2.719 16.45-1.553 10.103-2.33 18.392l-.776 8.16h-1.683l-.518-7.642q-.518-7.77-1.812-18.133t-2.848-18.263L101.762 57.034l-84.919 7.383 81.682 385.202 79.482-6.346 45.695-212.16q1.036-5.31 2.33-16.19a549 549 0 0 0 2.072-20.465l.906-9.584 1.553-.518.777 9.325q.777 9.197 2.071 20.076 1.425 10.88 2.46 16.19l40.776 214.88 85.436-7.383 79.482-399.968z"
|
||||
style="fill:none;stroke-width:10;stroke:#000;stroke-opacity:1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
Reference in New Issue
Block a user