mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
Fix Include Previously Watched regression and update profile stats to not use the setting
Index also added to Fixes https://github.com/sbondCo/Watcharr/issues/1027
This commit is contained in:
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
These changes are awaiting release:
|
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.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- `Include Previously Watched` regression (fixes https://github.com/sbondCo/Watcharr/issues/1027).
|
||||||
|
|
||||||
# [4.0.1] - 2026-07-16T10:12:00Z
|
# [4.0.1] - 2026-07-16T10:12:00Z
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ type Activity struct {
|
|||||||
// secured (users can only view their own activities).
|
// secured (users can only view their own activities).
|
||||||
UserID uint `json:"-" gorm:"not null"`
|
UserID uint `json:"-" gorm:"not null"`
|
||||||
// ID of watched list item this activity is linked to.
|
// 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 of activity.
|
||||||
Type ActivityType `json:"type" gorm:"not null"`
|
Type ActivityType `json:"type" gorm:"not null"`
|
||||||
// Holds custom data (ex, if rating changed, this can
|
// Holds custom data (ex, if rating changed, this can
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import (
|
|||||||
|
|
||||||
// Auth middleware
|
// Auth middleware
|
||||||
// If db is passed, extra user info from the database will be fetched.
|
// 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 {
|
func AuthRequired(db *gorm.DB, cfg *config.ServerConfig) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
slog.Debug("AuthRequired middleware hit")
|
slog.Debug("AuthRequired middleware hit")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package profile
|
package profile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"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 {
|
func (s *Service) hasBeenPreviouslyWatched(a *[]entity.Activity) bool {
|
||||||
wp := false
|
|
||||||
var relatedActivity []entity.Activity
|
|
||||||
for _, v := range *a {
|
for _, v := range *a {
|
||||||
if v.Type == entity.ADDED_WATCHED ||
|
if v.CountAsPlay {
|
||||||
v.Type == entity.IMPORTED_ADDED_WATCHED ||
|
return true
|
||||||
v.Type == entity.IMPORTED_WATCHED ||
|
|
||||||
v.Type == entity.STATUS_CHANGED {
|
|
||||||
relatedActivity = append(relatedActivity, v)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(relatedActivity) <= 0 {
|
return false
|
||||||
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
|
// Gets any data required for profile page
|
||||||
func (s *Service) getProfile(userId uint) (Profile, error) {
|
func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||||
|
// Get user.
|
||||||
user := new(entity.User)
|
user := new(entity.User)
|
||||||
res := s.db.Model(&entity.User{}).Where("id = ?", userId).Take(&user)
|
res := s.db.Model(&entity.User{}).Where("id = ?", userId).Take(&user)
|
||||||
if res.Error != nil {
|
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")
|
return Profile{}, errors.New("failed to get profile")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process stats.
|
||||||
watched := new([]entity.Watched)
|
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 {
|
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")
|
return Profile{}, errors.New("failed to get watched for processing")
|
||||||
}
|
}
|
||||||
var (
|
var (
|
||||||
@@ -95,11 +69,12 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
|||||||
)
|
)
|
||||||
for _, w := range *watched {
|
for _, w := range *watched {
|
||||||
isFinished := false
|
isFinished := false
|
||||||
if w.Status == entity.FINISHED {
|
// Note: Deliberately always checking `hasBeenPreviouslyWatched` for any
|
||||||
isFinished = true
|
// items without status set to FINISHED without checking users
|
||||||
} else if *user.IncludePreviouslyWatched && s.hasBeenPreviouslyWatched(&w.Activity) {
|
// `IncludePreviouslyWatched` setting, because that setting is useful
|
||||||
// If status is not finished and user has IncludePreviouslyWatched enabled,
|
// for filters, BUT not for these stats. I think it is always expected
|
||||||
// then we can also check if content hasBeenPreviouslyWatched.
|
// that all previously watched stuff is included in finished stats.
|
||||||
|
if w.Status == entity.FINISHED || s.hasBeenPreviouslyWatched(&w.Activity) {
|
||||||
isFinished = true
|
isFinished = true
|
||||||
}
|
}
|
||||||
if isFinished {
|
if isFinished {
|
||||||
@@ -107,7 +82,8 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c := *w.Content
|
c := *w.Content
|
||||||
if c.Type == entity.SHOW {
|
switch c.Type {
|
||||||
|
case entity.SHOW:
|
||||||
showsWatched++
|
showsWatched++
|
||||||
// This aint a science, just a very inaccurate guesstimate.
|
// This aint a science, just a very inaccurate guesstimate.
|
||||||
if c.NumberOfEpisodes != 0 {
|
if c.NumberOfEpisodes != 0 {
|
||||||
@@ -116,9 +92,11 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
|||||||
showRuntime = c.Runtime
|
showRuntime = c.Runtime
|
||||||
}
|
}
|
||||||
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
|
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++
|
moviesWatched++
|
||||||
moviesWatchedRuntime += c.Runtime
|
moviesWatchedRuntime += c.Runtime
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (s *Service) UserUpdate(userId uint, ur entity.UserSettings) (entity.UserSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UserGetSettings(userId uint) (entity.UserSettings, error) {
|
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)
|
user := new(entity.User)
|
||||||
res := s.db.Where("id = ?", userId).Take(&user)
|
res := s.db.Where("id = ?", userId).Take(&user)
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
|
|||||||
@@ -22,11 +22,16 @@ type GameProvider interface {
|
|||||||
GetOrCache(igdbID int) (entity.Game, error)
|
GetOrCache(igdbID int) (entity.Game, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserProvider interface {
|
||||||
|
UserGetSettings(userId uint) (entity.UserSettings, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
cp ContentProvider
|
cp ContentProvider
|
||||||
gameProvider GameProvider
|
gameProvider GameProvider
|
||||||
activityProvider domain.ActivityAddProvider
|
activityProvider domain.ActivityAddProvider
|
||||||
|
userProvider UserProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
@@ -34,12 +39,14 @@ func NewService(
|
|||||||
cp ContentProvider,
|
cp ContentProvider,
|
||||||
gameProvider GameProvider,
|
gameProvider GameProvider,
|
||||||
activityProvider domain.ActivityAddProvider,
|
activityProvider domain.ActivityAddProvider,
|
||||||
|
userProvider UserProvider,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db,
|
db,
|
||||||
cp,
|
cp,
|
||||||
gameProvider,
|
gameProvider,
|
||||||
activityProvider,
|
activityProvider,
|
||||||
|
userProvider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,8 +81,16 @@ func (s *Service) GetWatchedPage(
|
|||||||
"user_id", userId,
|
"user_id", userId,
|
||||||
"pagination_params", pp,
|
"pagination_params", pp,
|
||||||
"wr", wr)
|
"wr", wr)
|
||||||
watched := new([]entity.Watched)
|
|
||||||
pRes := &util.PaginationResponse[entity.Watched, util.None]{}
|
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.
|
res := s.db.
|
||||||
Model(&entity.Watched{}).
|
Model(&entity.Watched{}).
|
||||||
Where(&entity.Watched{UserID: userId})
|
Where(&entity.Watched{UserID: userId})
|
||||||
@@ -101,7 +116,7 @@ func (s *Service) GetWatchedPage(
|
|||||||
Preload("WatchedSeasons").
|
Preload("WatchedSeasons").
|
||||||
Preload("WatchedEpisodes").
|
Preload("WatchedEpisodes").
|
||||||
// Apply filters first.
|
// Apply filters first.
|
||||||
Scopes(watchedRefineFilter(wr)).
|
Scopes(watchedRefineFilter(wr, &userSettings)).
|
||||||
// Then count results (after filter);
|
// Then count results (after filter);
|
||||||
Count(&pRes.TotalResults).
|
Count(&pRes.TotalResults).
|
||||||
// Now calculate pagination properties with a TotalResults
|
// Now calculate pagination properties with a TotalResults
|
||||||
@@ -164,7 +179,7 @@ func (s *Service) getPublicWatched(
|
|||||||
Preload("WatchedSeasons").
|
Preload("WatchedSeasons").
|
||||||
Preload("WatchedEpisodes").
|
Preload("WatchedEpisodes").
|
||||||
// Apply filters first.
|
// Apply filters first.
|
||||||
Scopes(watchedRefineFilter(wr)).
|
Scopes(watchedRefineFilter(wr, nil)).
|
||||||
// Then count results (after filter);
|
// Then count results (after filter);
|
||||||
Count(&pRes.TotalResults).
|
Count(&pRes.TotalResults).
|
||||||
// Now calculate pagination properties with a TotalResults
|
// Now calculate pagination properties with a TotalResults
|
||||||
|
|||||||
@@ -38,15 +38,41 @@ func refineFilterType(db *gorm.DB, ft []util.SupportedMedia) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Applies 'Status' filter.
|
// 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 {
|
if len(f) <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Process the input data.
|
||||||
|
fIncludesFinished := false
|
||||||
for i := range f {
|
for i := range f {
|
||||||
// Ensure string **case** is valid WatchedStatus by converting to uppercase.
|
// Ensure string **case** is valid WatchedStatus by converting to uppercase.
|
||||||
f[i] = entity.WatchedStatus(strings.ToUpper(string(f[i])))
|
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.
|
||||||
|
// The WHERE here is wrapped in parenthesis so that the `OR` doesn't
|
||||||
|
// intefere with the main query confusing its AND/ORs.
|
||||||
|
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.
|
// Applies sorts to list.
|
||||||
@@ -125,11 +151,14 @@ func refineSortPinned(db *gorm.DB) {
|
|||||||
|
|
||||||
// list data.
|
// list data.
|
||||||
// gorm scope for applying filters to watched
|
// 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 {
|
return func(db *gorm.DB) *gorm.DB {
|
||||||
// Apply filters
|
// Apply filters
|
||||||
refineFilterType(db, wr.FilterType)
|
refineFilterType(db, wr.FilterType)
|
||||||
refineFilterStatus(db, wr.FilterStatus)
|
refineFilterStatus(db, wr.FilterStatus, userSettings)
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -206,7 +206,12 @@ func main() {
|
|||||||
userService := user.NewService(db)
|
userService := user.NewService(db)
|
||||||
userManageService := user.NewManageService(db)
|
userManageService := user.NewManageService(db)
|
||||||
gameService := game.NewService(db, &br.Cfg.TWITCH, activityService)
|
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)
|
watchedSeasonService := season.NewService(db, activityService)
|
||||||
watchedEpisodeService := episode.NewService(
|
watchedEpisodeService := episode.NewService(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -297,7 +297,7 @@
|
|||||||
|
|
||||||
<Setting
|
<Setting
|
||||||
title="Include Previously Watched"
|
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
|
row
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -308,8 +308,6 @@
|
|||||||
includePreviouslyWatchedDisabled = true;
|
includePreviouslyWatchedDisabled = true;
|
||||||
updateUserSetting("includePreviouslyWatched", on, () => {
|
updateUserSetting("includePreviouslyWatched", on, () => {
|
||||||
includePreviouslyWatchedDisabled = false;
|
includePreviouslyWatchedDisabled = false;
|
||||||
// Get profile stats again
|
|
||||||
getProfilePromise = getProfile();
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user