Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2bd61f4a |
@@ -0,0 +1,27 @@
|
||||
# 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,20 +11,13 @@ on:
|
||||
- "server/**"
|
||||
|
||||
jobs:
|
||||
test_and_format_check_go:
|
||||
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 .)
|
||||
|
||||
@@ -2,59 +2,9 @@
|
||||
|
||||
These changes are awaiting release:
|
||||
|
||||
## 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`.
|
||||
|
||||
# [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 taking 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 being taken 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.
|
||||
|
||||
@@ -89,7 +39,6 @@ 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
|
||||
|
||||
@@ -100,8 +49,6 @@ 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
|
||||
|
||||
@@ -112,14 +59,6 @@ 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!!
|
||||
@@ -250,7 +189,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
|
||||
@@ -1692,9 +1631,7 @@ 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/v4.0.0...HEAD
|
||||
[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
|
||||
[Unreleased]: https://github.com/sbondCo/Watcharr/compare/v3.0.1...HEAD
|
||||
[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
|
||||
@@ -1792,7 +1729,4 @@ 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
|
||||
[@GreatGatsby102]: https://github.com/GreatGatsby102
|
||||
[@4qu4r1um]: https://github.com/4qu4r1um
|
||||
[@goestav]: https://github.com/goestav
|
||||
[Dredsen]: https://github.com/Dredsen
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 878 KiB After Width: | Height: | Size: 711 KiB |
|
Before Width: | Height: | Size: 360 KiB After Width: | Height: | Size: 291 KiB |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "4.1.0",
|
||||
"version": "3.0.2-dev1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "watcharr",
|
||||
"version": "4.1.0",
|
||||
"version": "3.0.2-dev1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"license": "GPL-3.0-only",
|
||||
"version": "4.1.0",
|
||||
"version": "3.0.2-dev1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -1 +1 @@
|
||||
4.1.0
|
||||
3.0.2-dev1
|
||||
@@ -16,10 +16,6 @@ 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.
|
||||
@@ -31,27 +27,13 @@ func New() (*gorm.DB, error) {
|
||||
slog.Error("New: Opening database failed.")
|
||||
return nil, err
|
||||
}
|
||||
// Setup the db (migrations, etc)
|
||||
if err := Setup(db); err != nil {
|
||||
slog.Error("New: Setting up connection failed!", "error", err)
|
||||
if err := configure(db); err != nil {
|
||||
slog.Error("New: Configuring 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("Setup: AutoMigrating")
|
||||
err := db.AutoMigrate(
|
||||
slog.Info("New: AutoMigrating")
|
||||
err = db.AutoMigrate(
|
||||
&migrate.MigrationRecord{},
|
||||
&entity.User{},
|
||||
&entity.UserServices{},
|
||||
@@ -68,21 +50,21 @@ func Setup(db *gorm.DB) error {
|
||||
&entity.Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("Setup: Auto migration failed.")
|
||||
return err
|
||||
slog.Error("New: Auto migration failed.")
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("Setup: AutoMigrated")
|
||||
slog.Info("New: AutoMigrated")
|
||||
// Perform our manual migrations.
|
||||
if err := migrate.Now(db); err != nil {
|
||||
slog.Error("Setup: Manual migrations failed.", "error", err)
|
||||
return err
|
||||
slog.Error("New: Manual migrations failed.", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
// Optimize database.
|
||||
if err := optimize(db); err != nil {
|
||||
slog.Error("Setup: Optimizing database failed.", "error", err)
|
||||
return err
|
||||
slog.Error("New: Optimizing database failed.", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
return db, 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;index"`
|
||||
WatchedID uint `json:"watchedId" gorm:"not null"`
|
||||
// Type of activity.
|
||||
Type ActivityType `json:"type" gorm:"not null"`
|
||||
// Holds custom data (ex, if rating changed, this can
|
||||
|
||||
@@ -54,9 +54,6 @@ 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()
|
||||
|
||||
@@ -92,7 +89,7 @@ func Now(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Migration applied successfully.",
|
||||
slog.Debug("Migration applied successfully.",
|
||||
"id", mig.ID,
|
||||
"duration", time.Since(timeBeforeMig))
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ 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,10 +14,6 @@ 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,19 +35,7 @@ func (s *Service) saveGame(c *entity.Game, onlyUpdate bool) error {
|
||||
return errors.New("game missing id or title")
|
||||
}
|
||||
if c.CoverID != "" {
|
||||
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")
|
||||
p, err := image.DownloadAndInsertImage(s.db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
|
||||
if err != nil {
|
||||
slog.Error("saveGame: Failed to cache game cover.", "error", err)
|
||||
} else {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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"))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -27,38 +28,63 @@ func NewService(db *gorm.DB) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if item has been previously watched by scanning for any activity
|
||||
// that counts as a play.
|
||||
// Check if content has been previsouly watched by looking for related activity.
|
||||
func (s *Service) hasBeenPreviouslyWatched(a *[]entity.Activity) bool {
|
||||
wp := false
|
||||
var relatedActivity []entity.Activity
|
||||
for _, v := range *a {
|
||||
if v.CountAsPlay {
|
||||
return true
|
||||
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)
|
||||
}
|
||||
}
|
||||
return false
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
slog.Error("Failed to get profile:", "error", res.Error.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)
|
||||
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get watched for processing")
|
||||
}
|
||||
var (
|
||||
@@ -69,12 +95,11 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
)
|
||||
for _, w := range *watched {
|
||||
isFinished := false
|
||||
// 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) {
|
||||
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.
|
||||
isFinished = true
|
||||
}
|
||||
if isFinished {
|
||||
@@ -82,8 +107,7 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
continue
|
||||
}
|
||||
c := *w.Content
|
||||
switch c.Type {
|
||||
case entity.SHOW:
|
||||
if c.Type == entity.SHOW {
|
||||
showsWatched++
|
||||
// This aint a science, just a very inaccurate guesstimate.
|
||||
if c.NumberOfEpisodes != 0 {
|
||||
@@ -92,11 +116,9 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
showRuntime = c.Runtime
|
||||
}
|
||||
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
|
||||
slog.Debug("profile stat calculated",
|
||||
"show", c.Title,
|
||||
"runti", showRuntime*c.NumberOfEpisodes)
|
||||
slog.Debug("calcualted", "show", c.Title, "runti", showRuntime*c.NumberOfEpisodes)
|
||||
}
|
||||
case entity.MOVIE:
|
||||
} else if c.Type == entity.MOVIE {
|
||||
moviesWatched++
|
||||
moviesWatchedRuntime += c.Runtime
|
||||
}
|
||||
|
||||
@@ -136,9 +136,7 @@ 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)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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"
|
||||
@@ -65,7 +71,7 @@ func (s *Service) UserUpdate(userId uint, ur entity.UserSettings) (entity.UserSe
|
||||
}
|
||||
|
||||
func (s *Service) UserGetSettings(userId uint) (entity.UserSettings, error) {
|
||||
slog.Debug("UserGetSettings: Request running.", "user_id", userId)
|
||||
slog.Debug("user update request running", "user_id", userId)
|
||||
user := new(entity.User)
|
||||
res := s.db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
@@ -127,43 +133,58 @@ 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("UploadUserAvatar: An avatar is being uploaded",
|
||||
"name", file.Filename)
|
||||
slog.Debug("an avatar is being uploaded", "name", file.Filename)
|
||||
|
||||
f, _ := file.Open()
|
||||
defer f.Close()
|
||||
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))
|
||||
|
||||
img, err := image.
|
||||
NewSaver(s.db, "up", image.ValidateOptions{}).
|
||||
DownloadAndInsert(f)
|
||||
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)
|
||||
if err != nil {
|
||||
slog.Error("UploadUserAvatar: DownloadAndInsert failed!",
|
||||
"error", err)
|
||||
return entity.Image{}, errors.New("processing image failed")
|
||||
slog.Error("uploadUserAvatar seeking back to start of image failed", "error", err)
|
||||
}
|
||||
|
||||
// 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")
|
||||
// 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")
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
@@ -22,16 +22,11 @@ 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(
|
||||
@@ -39,14 +34,12 @@ func NewService(
|
||||
cp ContentProvider,
|
||||
gameProvider GameProvider,
|
||||
activityProvider domain.ActivityAddProvider,
|
||||
userProvider UserProvider,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db,
|
||||
cp,
|
||||
gameProvider,
|
||||
activityProvider,
|
||||
userProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,16 +74,8 @@ func (s *Service) GetWatchedPage(
|
||||
"user_id", userId,
|
||||
"pagination_params", pp,
|
||||
"wr", wr)
|
||||
|
||||
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)
|
||||
pRes := &util.PaginationResponse[entity.Watched, util.None]{}
|
||||
res := s.db.
|
||||
Model(&entity.Watched{}).
|
||||
Where(&entity.Watched{UserID: userId})
|
||||
@@ -116,7 +101,7 @@ func (s *Service) GetWatchedPage(
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
// Apply filters first.
|
||||
Scopes(watchedRefineFilter(wr, &userSettings)).
|
||||
Scopes(watchedRefineFilter(wr)).
|
||||
// Then count results (after filter);
|
||||
Count(&pRes.TotalResults).
|
||||
// Now calculate pagination properties with a TotalResults
|
||||
@@ -179,7 +164,7 @@ func (s *Service) getPublicWatched(
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
// Apply filters first.
|
||||
Scopes(watchedRefineFilter(wr, nil)).
|
||||
Scopes(watchedRefineFilter(wr)).
|
||||
// Then count results (after filter);
|
||||
Count(&pRes.TotalResults).
|
||||
// Now calculate pagination properties with a TotalResults
|
||||
@@ -458,8 +443,7 @@ 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.",
|
||||
"error", err)
|
||||
slog.Error("AddWatched: Failed to restore existing watched entry.")
|
||||
// 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,44 +38,15 @@ func refineFilterType(db *gorm.DB, ft []util.SupportedMedia) {
|
||||
}
|
||||
|
||||
// Applies 'Status' filter.
|
||||
func refineFilterStatus(
|
||||
db *gorm.DB,
|
||||
f []entity.WatchedStatus,
|
||||
userSettings *entity.UserSettings,
|
||||
) {
|
||||
func refineFilterStatus(db *gorm.DB, f []entity.WatchedStatus) {
|
||||
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.
|
||||
@@ -154,14 +125,11 @@ func refineSortPinned(db *gorm.DB) {
|
||||
|
||||
// list data.
|
||||
// gorm scope for applying filters to watched
|
||||
func watchedRefineFilter(
|
||||
wr domain.WatchedGetPageRequest,
|
||||
userSettings *entity.UserSettings,
|
||||
) func(db *gorm.DB) *gorm.DB {
|
||||
func watchedRefineFilter(wr domain.WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
// Apply filters
|
||||
refineFilterType(db, wr.FilterType)
|
||||
refineFilterStatus(db, wr.FilterStatus, userSettings)
|
||||
refineFilterStatus(db, wr.FilterStatus)
|
||||
return db
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,35 +2,33 @@ 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"
|
||||
)
|
||||
|
||||
const (
|
||||
defMaxSize int64 = 10 * util.Mebibyte
|
||||
defMaxWidthHeight int64 = 7680
|
||||
defMaxPixels int64 = 10_000_000
|
||||
)
|
||||
// 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..
|
||||
|
||||
// Insert an image into database
|
||||
func Insert(
|
||||
db *gorm.DB,
|
||||
hash string,
|
||||
path string,
|
||||
b []byte,
|
||||
) (entity.Image, error) {
|
||||
br := bytes.NewReader(b)
|
||||
bh, _ := GetBlurHash(br)
|
||||
func InsertImage(db *gorm.DB, hash string, path string, f io.Reader) (entity.Image, error) {
|
||||
bh, _ := GetBlurHash(f)
|
||||
img := entity.Image{
|
||||
Hash: hash,
|
||||
Path: path,
|
||||
@@ -97,3 +95,99 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
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).
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package util
|
||||
|
||||
const (
|
||||
Byte int64 = 1
|
||||
Kibibyte int64 = 1024
|
||||
Mebibyte int64 = 1024 * Kibibyte
|
||||
)
|
||||
@@ -33,7 +33,6 @@ 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"
|
||||
@@ -206,12 +205,7 @@ 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,
|
||||
userService)
|
||||
watchedService := watched.NewService(db, contentService, gameService, activityService)
|
||||
watchedSeasonService := season.NewService(db, activityService)
|
||||
watchedEpisodeService := episode.NewService(
|
||||
db,
|
||||
@@ -272,7 +266,6 @@ 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
|
||||
@@ -288,6 +281,8 @@ 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,7 +2,6 @@
|
||||
<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" />
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
<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>
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
style?: string;
|
||||
}
|
||||
|
||||
let { style = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="loader" {style}></div>
|
||||
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
$animDur: 1.4s;
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
animation: rotation $animDur linear infinite;
|
||||
|
||||
svg {
|
||||
circle {
|
||||
stroke-dasharray: 80px 200px;
|
||||
stroke-dashoffset: 0px;
|
||||
animation: circle-size $animDur ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
@@ -37,19 +28,4 @@
|
||||
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-nocookie.com/embed/${t[0]?.id}`;
|
||||
return `https://www.youtube.com/embed/${t[0]?.id}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
bind:this={avatarInput}
|
||||
type="file"
|
||||
title=""
|
||||
accept=".jpg,.png,.gif"
|
||||
accept=".jpg,.png,.gif,.webp"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<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,7 +10,6 @@
|
||||
import { notify } from "../util/notify";
|
||||
import { store } from "@/store.svelte";
|
||||
import { removeWatchedEpisode, updateWatchedEpisode } from "./api";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
ep: TMDBSeasonDetailsEpisode;
|
||||
@@ -19,65 +18,32 @@
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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") {
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (!watchedItem) {
|
||||
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
|
||||
return;
|
||||
}
|
||||
if (type === "DELETE") {
|
||||
if (!we || !we.id) {
|
||||
const ws = watchedItem.watchedEpisodes?.find(
|
||||
(s) =>
|
||||
s.seasonNumber === ep.season_number &&
|
||||
s.episodeNumber === ep.episode_number,
|
||||
);
|
||||
if (!ws) {
|
||||
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, 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).
|
||||
removeWatchedEpisode(watchedItem, ws.id);
|
||||
return;
|
||||
}
|
||||
await updateWatchedEpisode(
|
||||
watchedItem,
|
||||
ep.season_number,
|
||||
ep.episode_number,
|
||||
{
|
||||
status: type,
|
||||
},
|
||||
);
|
||||
reSetIsHidden();
|
||||
updateWatchedEpisode(watchedItem, ep.season_number, ep.episode_number, {
|
||||
status: type,
|
||||
});
|
||||
}
|
||||
|
||||
function handleStarClick(rating: number) {
|
||||
@@ -124,6 +90,11 @@
|
||||
<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
|
||||
@@ -250,19 +221,6 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
<td class="icon-cell">
|
||||
<div>
|
||||
{#if !l.state}
|
||||
<SpinnerTiny />
|
||||
<SpinnerTiny style="width: 13px;" />
|
||||
{: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="Should previously finished items be included in the 'Finished' status filter?"
|
||||
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!)."
|
||||
row
|
||||
>
|
||||
<Checkbox
|
||||
@@ -308,6 +308,8 @@
|
||||
includePreviouslyWatchedDisabled = true;
|
||||
updateUserSetting("includePreviouslyWatched", on, () => {
|
||||
includePreviouslyWatchedDisabled = false;
|
||||
// Get profile stats again
|
||||
getProfilePromise = getProfile();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Notifications from "@/lib/notifications.svelte";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import SpinnerTiny from "@/lib/SpinnerTiny.svelte";
|
||||
import { unNotify } from "@/lib/util/notify";
|
||||
import { store } from "@/store.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { pwaInfo } from "virtual:pwa-info";
|
||||
|
||||
@@ -34,8 +37,26 @@
|
||||
</svelte:head>
|
||||
|
||||
<div id="tooltip"></div>
|
||||
|
||||
<Notifications />
|
||||
<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>
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
|
||||
@@ -235,6 +235,89 @@
|
||||
}
|
||||
|
||||
: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 {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |