mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e6ca542ec | |||
| a60710e384 | |||
| fbf3002fa1 | |||
| fce668ef97 | |||
| 939ff6cdaf | |||
| 6a716f7507 | |||
| 8f80153d22 | |||
| d3d476cc3f | |||
| 2f78fdd0a5 | |||
| 79f1d1b242 | |||
| b946dfd3c9 | |||
| 69c656bc97 | |||
| 10bf5324b2 | |||
| fcdf752c6c | |||
| df425b9587 | |||
| 78eee0e27e | |||
| 0ea1fea88a | |||
| 18c0882650 | |||
| b70ae5d3c7 | |||
| 01e175ee9f | |||
| 409b2c9d41 | |||
| 902a444704 | |||
| 2c0983724a | |||
| bf0a1eaa14 |
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/robfig/go-cache"
|
||||
)
|
||||
|
||||
// Extension of the `.Get` method for `go-cache`.
|
||||
// This method will simplify our usage so we don't need
|
||||
// to type assert everywhere, this method will handle
|
||||
// everything related to getting the value from cache.
|
||||
// Returns `true` if `rv` was set to the cached value.
|
||||
// Returns `false` if we couldn't get anything from cache.
|
||||
func GetCache(c *cache.Cache, k string, rv any) bool {
|
||||
if val, found := c.Get(k); found {
|
||||
v := reflect.ValueOf(rv)
|
||||
if v.Type().Kind() == reflect.Ptr && v.Elem().CanSet() {
|
||||
v.Elem().Set(reflect.ValueOf(val))
|
||||
slog.Debug("cachefunc: Cache found.", "key", k)
|
||||
return true
|
||||
}
|
||||
slog.Error("cachefunc: Cache not set", "key", k)
|
||||
return false
|
||||
}
|
||||
slog.Debug("cachefunc: Cache not found", "key", k)
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a cache key for our in-mem cache.
|
||||
//
|
||||
// `name` should be the name of the function response we are caching.
|
||||
//
|
||||
// `...u` can be any amount of values that will make this key unique.
|
||||
// Currently supports types:
|
||||
// - `string`
|
||||
// - `map[string]string`
|
||||
// - `int`
|
||||
func CreateCacheKey(name string, u ...any) string {
|
||||
str := name
|
||||
appnd := func(s string) {
|
||||
str += "-" + s
|
||||
}
|
||||
for _, v := range u {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
appnd(vv)
|
||||
case map[string]string:
|
||||
for k, e := range vv {
|
||||
appnd(k + "_" + e)
|
||||
}
|
||||
case int:
|
||||
appnd(strconv.Itoa(vv))
|
||||
default:
|
||||
// This should never happen, but incase of unknown
|
||||
// value passed, hopefully this should make it easier
|
||||
// to catch in logs.
|
||||
str = str + "KEYTYPEERR"
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
+90
-19
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"github.com/robfig/go-cache"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -22,7 +22,8 @@ const (
|
||||
SHOW_EPISODE ContentType = "tv_episode"
|
||||
)
|
||||
|
||||
var ContentStore = persistence.NewInMemoryStore(time.Hour * 24)
|
||||
// inmemory content cache
|
||||
var ContentStore = cache.New(time.Hour*24, time.Minute)
|
||||
|
||||
// For storing cached content, so we can serve the basic local data for watched list to work
|
||||
type Content struct {
|
||||
@@ -250,11 +251,17 @@ func searchContent(query string, pageNum int) (TMDBSearchMultiResponse, error) {
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchContent", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchContent: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete multi search request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -263,6 +270,11 @@ func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchMovies", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/search/movie", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie search request!", "error", err.Error())
|
||||
@@ -271,6 +283,7 @@ func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "movie"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -279,6 +292,11 @@ func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchTv", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/search/tv", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv search request!", "error", err.Error())
|
||||
@@ -287,6 +305,7 @@ func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "tv"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -295,7 +314,10 @@ func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/person", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
err := tmdbRequest("/search/person", map[string]string{
|
||||
"query": query,
|
||||
"page": strconv.Itoa(pageNum),
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete people search request!", "error", err.Error())
|
||||
return TMDBSearchPeopleResponse{}, errors.New("failed to complete people search request")
|
||||
@@ -325,16 +347,23 @@ func searchByExternalId(id string, source string) (TMDBSearchMultiResponse, erro
|
||||
comb = append(comb, resp.TvSeasonResults...)
|
||||
comb = append(comb, resp.TvEpisodeResults...)
|
||||
return TMDBSearchMultiResponse{TMDBSearchResponse: TMDBSearchResponse[TMDBSearchMultiResults]{
|
||||
Results: comb,
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
Results: comb,
|
||||
TMDBPageFields: TMDBPageFields{
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
cacheKey := CreateCacheKey("movieDetails", id, country, rParams)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("movieDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie details request!", "error", err.Error())
|
||||
@@ -342,6 +371,7 @@ func movieDetails(db *gorm.DB, id string, country string, rParams map[string]str
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentMovie(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -355,8 +385,18 @@ func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
func tvDetails(
|
||||
db *gorm.DB,
|
||||
id string,
|
||||
country string,
|
||||
rParams map[string]string,
|
||||
) (TMDBShowDetails, error) {
|
||||
cacheKey := CreateCacheKey("tvDetails", id, country, rParams)
|
||||
resp := new(TMDBShowDetails)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("tvDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv details request!", "error", err.Error())
|
||||
@@ -364,6 +404,7 @@ func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentTv(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -379,13 +420,9 @@ func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
|
||||
// This method is manually cached, so it can be easily used in other places (on the server) with cache benefits
|
||||
func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error) {
|
||||
var cacheKey = "contentstore-seasondetails-" + tvId + "-" + seasonNumber
|
||||
cacheKey := CreateCacheKey("seasonDetails", tvId, seasonNumber)
|
||||
resp := new(TMDBSeasonDetails)
|
||||
if err := ContentStore.Get(cacheKey, &resp); err != nil {
|
||||
if err != persistence.ErrCacheMiss {
|
||||
slog.Error("seasonDetails: Cache failed for some reason", "error", err)
|
||||
}
|
||||
} else {
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("seasonDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
@@ -394,9 +431,7 @@ func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error)
|
||||
slog.Error("seasonDetails: Failed to complete season details request!", "error", err.Error())
|
||||
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
if err := ContentStore.Set(cacheKey, resp, time.Hour*24); err != nil {
|
||||
slog.Error("seasonDetails: Failed to set cache!", "error", err)
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -421,56 +456,92 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
}
|
||||
|
||||
func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
cacheKey := CreateCacheKey("discoverMovies")
|
||||
resp := new(TMDBDiscoverMovies)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("discoverMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/discover/movie", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover movies request!", "error", err.Error())
|
||||
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func discoverTv() (TMDBDiscoverShows, error) {
|
||||
cacheKey := CreateCacheKey("discoverTv")
|
||||
resp := new(TMDBDiscoverShows)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("discoverTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover tv request!", "error", err.Error())
|
||||
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func allTrending() (TMDBTrendingAll, error) {
|
||||
cacheKey := CreateCacheKey("allTrending")
|
||||
resp := new(TMDBTrendingAll)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("allTrending: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/trending/all/day", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete all trending request!", "error", err.Error())
|
||||
return TMDBTrendingAll{}, errors.New("failed to complete all trending request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func upcomingMovies() (TMDBUpcomingMovies, error) {
|
||||
cacheKey := CreateCacheKey("upcomingMovies")
|
||||
resp := new(TMDBUpcomingMovies)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("upcomingMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/movie/upcoming", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming movies request!", "error", err.Error())
|
||||
return TMDBUpcomingMovies{}, errors.New("failed to complete upcoming movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Theres no upcoming endpoint for tv ;( - using discover with future dates
|
||||
func upcomingTv() (TMDBUpcomingShows, error) {
|
||||
cacheKey := CreateCacheKey("upcomingTv")
|
||||
resp := new(TMDBUpcomingShows)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("upcomingTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
dFmt := "2006-01-02"
|
||||
mind := time.Now().Format(dFmt)
|
||||
maxd := time.Now().AddDate(0, 0, 15).Format(dFmt)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1", "first_air_date.gte": mind, "first_air_date.lte": maxd, "sort_by": "popularity.desc", "with_type": "2|3"}, &resp)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{
|
||||
"page": "1",
|
||||
"first_air_date.gte": mind,
|
||||
"first_air_date.lte": maxd,
|
||||
"sort_by": "popularity.desc",
|
||||
"with_type": "2|3",
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming tv request!", "error", err.Error())
|
||||
return TMDBUpcomingShows{}, errors.New("failed to complete upcoming tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// All the functions that help us turn a TMDB response struct
|
||||
// into one that will also include Watched data.
|
||||
// This process is very verbose. As far as I am aware, golangs
|
||||
// generics are not mature (powerful) enough to support us doing
|
||||
// this all with one function.
|
||||
//
|
||||
// TODO When possible look at turning all these funcs into one that
|
||||
// is reuable for any tmdb search response type.
|
||||
//
|
||||
// Each function will basically perform these simple steps:
|
||||
// 1. Repackage tmdb response so we can add Watched data to
|
||||
// 2. Get all watched data for the tmdb results
|
||||
// 4. Add any watched data to our new *WithWatched struct
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func searchContentAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchMultiResponse,
|
||||
) TMDBSearchMultiResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchMultiResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMultiResultsWithWatched{
|
||||
TMDBSearchMultiResults: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func searchMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchMoviesResponse,
|
||||
) TMDBSearchMoviesResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchMoviesResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMovieResultWithWatched{
|
||||
TMDBSearchMovieResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func searchTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchShowsResponse,
|
||||
) TMDBSearchShowsResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchShowsResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchShowsResultWithWatched{
|
||||
TMDBSearchShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func tvDetailsAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBShowDetails,
|
||||
) TMDBShowDetailsWithWatched {
|
||||
withWatchedResp := TMDBShowDetailsWithWatched{}
|
||||
withWatchedResp.TMDBShowDetailsBase = content.TMDBShowDetailsBase
|
||||
// Append watched list entry if exists
|
||||
if watchedEntry, err := getWatchedItemByTmdbId(db, userId, uint(content.ID), SHOW); err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
withWatchedResp.FailedToGetWatched = true
|
||||
}
|
||||
} else {
|
||||
withWatchedResp.Watched = &watchedEntry
|
||||
}
|
||||
// Add similar content with any watched entries
|
||||
similarContentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Similar.Results {
|
||||
withWatchedResp.Similar.Results = append(withWatchedResp.Similar.Results, TMDBShowSimilarResultWithWatched{
|
||||
TMDBShowSimilarResult: v,
|
||||
})
|
||||
similarContentIdAndTypePairs = append(similarContentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, similarContentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Similar.Results {
|
||||
if vv.ID == v.Content.TmdbID && string(SHOW) == string(v.Content.Type) {
|
||||
withWatchedResp.Similar.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func allTrendingAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBTrendingAll,
|
||||
) TMDBTrendingAllWithWatched {
|
||||
withWatchedResp := TMDBTrendingAllWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBTrendingAllResultWithWatched{
|
||||
TMDBTrendingAllResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func discoverTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBDiscoverShows,
|
||||
) TMDBDiscoverShowsWithWatched {
|
||||
withWatchedResp := TMDBDiscoverShowsWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverShowsResultWithWatched{
|
||||
TMDBDiscoverShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func upcomingTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBUpcomingShows,
|
||||
) TMDBUpcomingShowsWithWatched {
|
||||
withWatchedResp := TMDBUpcomingShowsWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingShowsResultWithWatched{
|
||||
TMDBUpcomingShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func discoverMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBDiscoverMovies,
|
||||
) TMDBDiscoverMoviesWithWatched {
|
||||
withWatchedResp := TMDBDiscoverMoviesWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverMoviesResultWithWatched{
|
||||
TMDBDiscoverMoviesResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
MOVIE,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func upcomingMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBUpcomingMovies,
|
||||
) TMDBUpcomingMoviesWithWatched {
|
||||
withWatchedResp := TMDBUpcomingMoviesWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingMoviesResultWithWatched{
|
||||
TMDBUpcomingMoviesResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
MOVIE,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -29,3 +31,51 @@ func WhereaboutsRequired() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination middleware
|
||||
// Reusable way to get pagination values.
|
||||
// If force=true then will default to page=1, otherwise
|
||||
// assume pagination is disabled when query params not present.
|
||||
func PaginatedRequest(force bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
pageStr := c.Query("p")
|
||||
page := 0
|
||||
if pageStr == "" && force {
|
||||
page = 1
|
||||
slog.Debug("PossiblyPaginated: Pagination is forced, but no page was provided. Using default.")
|
||||
} else if pageStr == "" {
|
||||
slog.Debug("PossiblyPaginated: Pagination is disabled. No parameter provided.")
|
||||
c.Set("paginationEnabled", false)
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
num, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
slog.Error("PossiblyPaginated: Query paramater 'p' was not parseable as an int", "err", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query param 'p' must be a number"})
|
||||
return
|
||||
}
|
||||
page = num
|
||||
}
|
||||
limitStr := c.Query("l")
|
||||
limit := 40
|
||||
if limitStr != "" {
|
||||
num, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
slog.Error("PossiblyPaginated: Query paramater 'l' was not parseable as an int", "err", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query param 'l' must be a number"})
|
||||
return
|
||||
}
|
||||
limit = num
|
||||
} else {
|
||||
slog.Debug("PossiblyPaginated: Using default limit.")
|
||||
}
|
||||
slog.Debug("PossiblyPaginated: middleware hit", "page", page, "page_limit", limit)
|
||||
c.Set("paginationEnabled", true)
|
||||
c.Set("paginationParams", PaginationParams{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
})
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Parameters that the paginator uses to
|
||||
// know what to return.
|
||||
type PaginationParams struct {
|
||||
// Page limit (max amount of items to get for each page).
|
||||
Limit int `json:"limit"`
|
||||
// Page number.
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
// Pagination response struct.
|
||||
type PaginationResponse[T interface{}] struct {
|
||||
PaginationParams
|
||||
// Max amount of pages we can produce from total_results
|
||||
TotalPages int `json:"totalPages"`
|
||||
TotalResults int64 `json:"totalResults"`
|
||||
Results []T `json:"results"`
|
||||
}
|
||||
|
||||
// Call when finished with PaginationResponse, before returning to user.
|
||||
// Performs final calculations.
|
||||
func (r *PaginationResponse[T]) Finished(p PaginationParams) {
|
||||
r.PaginationParams = p
|
||||
if r.TotalResults != 0 && r.Limit != 0 {
|
||||
r.TotalPages = int(math.Ceil(float64(r.TotalResults) / float64(r.Limit)))
|
||||
} else {
|
||||
slog.Warn(
|
||||
"PaginationResponse->Finished: TotalPages not calculated.",
|
||||
"total_results", r.TotalResults,
|
||||
"limit", r.Limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination gorm scope.
|
||||
// Pass in `PaginationParams` and the `PaginationResponse` will be filled out,
|
||||
// just fill out the `Results` manually.
|
||||
func Paginate[T interface{}](
|
||||
p PaginationParams,
|
||||
r *PaginationResponse[T],
|
||||
) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
offset := (p.Page - 1) * p.Limit
|
||||
return db.Offset(offset).Limit(p.Limit)
|
||||
}
|
||||
}
|
||||
+89
-79
@@ -80,98 +80,68 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
exp := time.Hour * 24
|
||||
|
||||
// Search for content
|
||||
content.GET("/search/multi", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/search/multi", PaginatedRequest(true), func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchContent(query, pageNum)
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
content, err := searchContent(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := searchContentAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Search for movies
|
||||
content.GET("/search/movie", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/search/movie", PaginatedRequest(true), func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchMovies(query, pageNum)
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
content, err := searchMovies(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := searchMoviesAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Search for shows
|
||||
content.GET("/search/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/search/tv", PaginatedRequest(true), func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchTv(query, pageNum)
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
content, err := searchTv(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := searchTvAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Search for people
|
||||
content.GET("/search/person", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/search/person", PaginatedRequest(true), cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchPeople(query, pageNum)
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
content, err := searchPeople(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -222,18 +192,24 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get tv details (for tv page)
|
||||
content.GET("/tv/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvDetails(b.db, c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
|
||||
content.GET("/tv/:id", WhereaboutsRequired(), func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
// 1. Get details
|
||||
content, err := tvDetails(
|
||||
b.db,
|
||||
c.Param("id"),
|
||||
c.MustGet("userCountry").(string),
|
||||
map[string]string{
|
||||
"append_to_response": "videos,watch/providers,similar,external_ids,keywords",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := tvDetailsAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Get tv cast
|
||||
content.GET("/tv/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
@@ -315,54 +291,64 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Discover movies
|
||||
content.GET("/discover/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/discover/movies", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := discoverMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := discoverMoviesAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Discover shows
|
||||
content.GET("/discover/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/discover/tv", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := discoverTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := discoverTvAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Get all trending (movies, tv, people)
|
||||
content.GET("/trending", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/trending", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := allTrending()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := allTrendingAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Upcoming Movies
|
||||
content.GET("/upcoming/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/upcoming/movies", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := upcomingMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := upcomingMoviesAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Upcoming Tv
|
||||
content.GET("/upcoming/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/upcoming/tv", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := upcomingTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := upcomingTvAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Available regions for watch providers
|
||||
content.GET("/regions", func(c *gin.Context) {
|
||||
@@ -482,9 +468,33 @@ func (b *BaseRouter) addGameRoutes() {
|
||||
func (b *BaseRouter) addWatchedRoutes() {
|
||||
watched := b.rg.Group("/watched").Use(AuthRequired(nil))
|
||||
|
||||
watched.GET("", func(c *gin.Context) {
|
||||
watched.GET("", PaginatedRequest(false), func(c *gin.Context) {
|
||||
isPaginated := c.MustGet("paginationEnabled").(bool)
|
||||
userId := c.MustGet("userId").(uint)
|
||||
c.JSON(http.StatusOK, getWatched(b.db, userId))
|
||||
if isPaginated {
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
wp := WatchedGetPageRequest{
|
||||
// Defaults..
|
||||
Sort: watchedSortDateAdded,
|
||||
SortDir: sortAscending,
|
||||
}
|
||||
if err := c.ShouldBind(&wp); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "failed to get request parameters"})
|
||||
return
|
||||
}
|
||||
if wp, err := getWatchedPage(b.db, userId, pp, wp); err == nil {
|
||||
c.JSON(http.StatusOK, wp)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "failed to get page"})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non paginated response (doesn't support sorting/filtering atm)
|
||||
if w, err := getWatched(b.db, userId); err == nil {
|
||||
c.JSON(http.StatusOK, w)
|
||||
} else {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "failed"})
|
||||
}
|
||||
})
|
||||
|
||||
watched.GET(":id/:username", func(c *gin.Context) {
|
||||
|
||||
+237
-129
@@ -10,24 +10,40 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type TMDBSearchResponse[R any] struct {
|
||||
// Separated from `TMDBSearchResponse` so we can embed it for
|
||||
// easily assigning all page fields in one.
|
||||
type TMDBPageFields struct {
|
||||
Page int `json:"page"`
|
||||
Results []R `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBSearchResponse[R any] struct {
|
||||
TMDBPageFields
|
||||
Results []R `json:"results"`
|
||||
}
|
||||
|
||||
// A common "base" type for search results.
|
||||
type TMDBSearchResult struct {
|
||||
// TMDB ID
|
||||
ID int `json:"id"`
|
||||
// Media Type (movie, show, person)
|
||||
// Some requests won't return this value
|
||||
// (namely any request other than a multi
|
||||
// type search), but we add it in manually.
|
||||
MediaType string `json:"media_type"`
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResults struct {
|
||||
TMDBSearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int64 `json:"genre_ids"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
@@ -49,15 +65,24 @@ type TMDBSearchMultiResults struct {
|
||||
StillPath string `json:"still_path,omitempty"`
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResultsWithWatched struct {
|
||||
TMDBSearchMultiResults
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchMultiResults]
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResponseWithWatched struct {
|
||||
TMDBSearchResponse[TMDBSearchMultiResultsWithWatched]
|
||||
}
|
||||
|
||||
type TMDBSearchMovieResult struct {
|
||||
TMDBSearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
@@ -68,18 +93,26 @@ type TMDBSearchMovieResult struct {
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
}
|
||||
|
||||
type TMDBSearchMovieResultWithWatched struct {
|
||||
TMDBSearchMovieResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBSearchMoviesResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchMovieResult]
|
||||
}
|
||||
|
||||
type TMDBSearchMoviesResponseWithWatched struct {
|
||||
TMDBSearchResponse[TMDBSearchMovieResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResult struct {
|
||||
TMDBSearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
@@ -90,23 +123,30 @@ type TMDBSearchShowsResult struct {
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResultWithWatched struct {
|
||||
TMDBSearchShowsResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchShowsResult]
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResponseWithWatched struct {
|
||||
TMDBSearchResponse[TMDBSearchShowsResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBSearchPeopleResult struct {
|
||||
TMDBSearchResult
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
KnownFor []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
@@ -195,7 +235,12 @@ type TMDBMovieDetails struct {
|
||||
ExternalIds TMDBExternalIdsMovie `json:"external_ids"`
|
||||
}
|
||||
|
||||
type TMDBShowDetails struct {
|
||||
type TMDBMovieDetailsWithWatched struct {
|
||||
WatchedAddedToContent
|
||||
*TMDBShowDetails
|
||||
}
|
||||
|
||||
type TMDBShowDetailsBase struct {
|
||||
TMDBContentDetails
|
||||
CreatedBy []struct {
|
||||
ID int `json:"id"`
|
||||
@@ -245,13 +290,24 @@ type TMDBShowDetails struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
// Extra items because we use `append_to_response` on the request
|
||||
// Similar SS `json:"similar"`
|
||||
Videos TMDBContentVideos `json:"videos"`
|
||||
WatchProviders interface{} `json:"watch/providers"`
|
||||
Similar TMDBShowSimilar `json:"similar"`
|
||||
ExternalIds TMDBExternalIdsShow `json:"external_ids"`
|
||||
Keywords TMDBKeywords `json:"keywords"`
|
||||
}
|
||||
|
||||
type TMDBShowDetails struct {
|
||||
TMDBShowDetailsBase
|
||||
Similar TMDBShowSimilar `json:"similar"`
|
||||
}
|
||||
|
||||
type TMDBShowDetailsWithWatched struct {
|
||||
WatchedAddedToContent
|
||||
TMDBShowDetailsBase
|
||||
Similar TMDBShowSimilarWithWatched `json:"similar"`
|
||||
}
|
||||
|
||||
type WatchProvider struct {
|
||||
ProviderID int `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
@@ -325,25 +381,33 @@ type TMDBSeasonDetails struct {
|
||||
}
|
||||
|
||||
type TMDBShowSimilar struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBShowSimilarResult]
|
||||
}
|
||||
|
||||
type TMDBShowSimilarResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBShowSimilarWithWatched struct {
|
||||
TMDBSearchResponse[TMDBShowSimilarResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBShowSimilarResultWithWatched struct {
|
||||
TMDBShowSimilarResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBMovieSimilar struct {
|
||||
@@ -448,73 +512,97 @@ type TMDBContentCredits struct {
|
||||
}
|
||||
|
||||
type TMDBDiscoverMovies struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBDiscoverMoviesResult]
|
||||
}
|
||||
|
||||
type TMDBDiscoverMoviesWithWatched struct {
|
||||
TMDBSearchResponse[TMDBDiscoverMoviesResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBDiscoverMoviesResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBDiscoverMoviesResultWithWatched struct {
|
||||
TMDBDiscoverMoviesResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBDiscoverShows struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBDiscoverShowsResult]
|
||||
}
|
||||
|
||||
type TMDBDiscoverShowsWithWatched struct {
|
||||
TMDBSearchResponse[TMDBDiscoverShowsResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBDiscoverShowsResult struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBDiscoverShowsResultWithWatched struct {
|
||||
TMDBDiscoverShowsResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBTrendingAll struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBTrendingAllResult]
|
||||
}
|
||||
|
||||
type TMDBTrendingAllWithWatched struct {
|
||||
TMDBSearchResponse[TMDBTrendingAllResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBTrendingAllResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
}
|
||||
|
||||
type TMDBTrendingAllResultWithWatched struct {
|
||||
TMDBTrendingAllResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBUpcomingMovies struct {
|
||||
@@ -522,46 +610,66 @@ type TMDBUpcomingMovies struct {
|
||||
Maximum string `json:"maximum"`
|
||||
Minimum string `json:"minimum"`
|
||||
} `json:"dates"`
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBUpcomingMoviesResult]
|
||||
}
|
||||
|
||||
type TMDBUpcomingMoviesWithWatched struct {
|
||||
Dates struct {
|
||||
Maximum string `json:"maximum"`
|
||||
Minimum string `json:"minimum"`
|
||||
} `json:"dates"`
|
||||
TMDBSearchResponse[TMDBUpcomingMoviesResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBUpcomingMoviesResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBUpcomingMoviesResultWithWatched struct {
|
||||
TMDBUpcomingMoviesResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBUpcomingShows struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBUpcomingShowsResult]
|
||||
}
|
||||
|
||||
type TMDBUpcomingShowsWithWatched struct {
|
||||
TMDBSearchResponse[TMDBUpcomingShowsResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBUpcomingShowsResult struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBUpcomingShowsResultWithWatched struct {
|
||||
TMDBUpcomingShowsResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBExternalIds struct {
|
||||
|
||||
@@ -28,6 +28,16 @@ type GormModel struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
// Types of media supported by Watcharr
|
||||
// in an overarching way.
|
||||
type SupportedMedia string
|
||||
|
||||
const (
|
||||
SupportedMediaMovie SupportedMedia = "movie"
|
||||
SupportedMediaShow SupportedMedia = "tv"
|
||||
SupportedMediaGame SupportedMedia = "game"
|
||||
)
|
||||
|
||||
var (
|
||||
ServerInSetup = false
|
||||
logLevel = new(slog.LevelVar)
|
||||
|
||||
+102
-3
@@ -75,7 +75,51 @@ type WatchedRemoveResponse struct {
|
||||
NewActivity Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
// Get watched page request extra (GET) options.
|
||||
type WatchedGetPageRequest struct {
|
||||
// Sorting type.
|
||||
Sort WatchedSort `form:"sort"`
|
||||
// Sorting direction (asc or desc).
|
||||
SortDir SortDirection `form:"sortDir,default=desc"`
|
||||
// Filtering options.
|
||||
Filter struct {
|
||||
Type SupportedMedia `form:"filter.type"`
|
||||
Status WatchedStatus `form:"filter.status"`
|
||||
}
|
||||
}
|
||||
|
||||
type WatchedSort string
|
||||
|
||||
const (
|
||||
watchedSortDateAdded WatchedSort = "DATEADDED"
|
||||
watchedSortLastChanged WatchedSort = "LASTCHANGED"
|
||||
watchedSortLastFinished WatchedSort = "LASTFIN"
|
||||
watchedSortRating WatchedSort = "RATING"
|
||||
watchedSortAlphabetical WatchedSort = "ALPHA"
|
||||
)
|
||||
|
||||
type SortDirection string
|
||||
|
||||
const (
|
||||
sortAscending SortDirection = "asc"
|
||||
sortDescending SortDirection = "desc"
|
||||
)
|
||||
|
||||
// This struct is for embedding inside content response structs.
|
||||
// This holds the watched entry response data that will go along
|
||||
// with the content responses.
|
||||
type WatchedAddedToContent struct {
|
||||
// The related watched entry.
|
||||
Watched *Watched `json:"watched,omitempty"`
|
||||
// If we failed to get the watched entry,
|
||||
// set this to true, so the frontend can
|
||||
// notify the user of why there is possibly
|
||||
// missing watched list data.
|
||||
FailedToGetWatched bool `json:"failedToGetWatched,omitempty"`
|
||||
}
|
||||
|
||||
// Get entire watched list
|
||||
func getWatched(db *gorm.DB, userId uint) ([]Watched, error) {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Preload("Content").
|
||||
@@ -88,9 +132,39 @@ func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
Where("user_id = ?", userId).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
slog.Error("getWatched: Failed!", "error", res.Error)
|
||||
return []Watched{}, res.Error
|
||||
}
|
||||
return *watched
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Returns a page of users watched list.
|
||||
func getWatchedPage(db *gorm.DB, userId uint, pp PaginationParams, wr WatchedGetPageRequest) (PaginationResponse[Watched], error) {
|
||||
slog.Debug("getWatchedPage: A page was requested.", "user_id", userId, "pagination_params", pp, "wr", wr)
|
||||
watched := new([]Watched)
|
||||
pRes := &PaginationResponse[Watched]{}
|
||||
res := db.
|
||||
Model(&Watched{}).
|
||||
Where(&Watched{UserID: userId}).
|
||||
Count(&pRes.TotalResults).
|
||||
Joins("Content").
|
||||
Joins("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Tags").
|
||||
Scopes(
|
||||
Paginate(pp, pRes),
|
||||
// NOTE: watchedRefine->watchedSortLastFinished sort changes the SELECT
|
||||
// statement for this query, so keep that in mind if it ever changes.
|
||||
watchedRefine(wr),
|
||||
).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedPage: Failed!", "error", res.Error)
|
||||
return PaginationResponse[Watched]{}, res.Error
|
||||
}
|
||||
pRes.Results = *watched
|
||||
pRes.Finished(pp)
|
||||
return *pRes, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by id (must be for `userId`).
|
||||
@@ -120,6 +194,31 @@ func getWatchedItemByTmdbId(db *gorm.DB, userId uint, tmdbId uint, contentType C
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Same as `getWatchedItemByTmdbId` except for getting in bulk (multiple content ids).
|
||||
// `c` entries should be in format: [tmdb_id, ContentType] (Note: Couldn't figure out
|
||||
// if it's possible to type this to enforce [int, ContentType] type for entries)
|
||||
func getWatchedItemsByTmdbIds(db *gorm.DB, userId uint, c [][]any) ([]Watched, error) {
|
||||
slog.Debug("getWatchedItemsByTmdbIds: Running.", "userId", userId, "c", c)
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Joins("Content").
|
||||
Where("user_id = ?", userId).
|
||||
Where(
|
||||
"(Content.tmdb_id, Content.type) IN ?", c).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemsByTmdbIds: Failed!", "error", res.Error)
|
||||
return []Watched{}, res.Error
|
||||
}
|
||||
slog.Debug(
|
||||
"getWatchedItemsByTmdbIds: Done.",
|
||||
"userId", userId,
|
||||
"watcheds_found", len(*watched),
|
||||
// "wdev", *watched,
|
||||
)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get another users **public** watchlist.
|
||||
func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, error) {
|
||||
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Watched sorting & filtering.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// gorm scope for applying sort and filters to watched
|
||||
// list data.
|
||||
func watchedRefine(wr WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
// Apply filters
|
||||
|
||||
// Apply sort
|
||||
if wr.Sort != "" {
|
||||
obc := func(cn string) clause.OrderByColumn {
|
||||
o := clause.OrderByColumn{}
|
||||
if wr.SortDir == sortAscending {
|
||||
o.Desc = false
|
||||
} else {
|
||||
o.Desc = true
|
||||
}
|
||||
o.Column = clause.Column{Name: cn}
|
||||
return o
|
||||
}
|
||||
switch wr.Sort {
|
||||
case watchedSortDateAdded:
|
||||
db.Order(obc("watcheds.created_at"))
|
||||
case watchedSortLastChanged:
|
||||
db.Order(obc("watcheds.updated_at"))
|
||||
case watchedSortLastFinished:
|
||||
// TODO This can make the query quite slow, look at improving performance.
|
||||
db.
|
||||
Joins("LEFT JOIN activities ON activities.watched_id = watcheds.id").
|
||||
// TODO this whole query looks to work, but we have to add `watcheds.*` to this SELECT,
|
||||
// otherwise it doesn't select them (like it does when we use Model, in the original query build),
|
||||
// is there a better way? Cuz if we modify the Model later in main query, this would break, which isn't ideal.
|
||||
Select("watcheds.*, MAX(MAX(activities.created_at), MAX(activities.custom_date)) as latest_watched_activity").
|
||||
Group("watcheds.id").
|
||||
Order(obc("latest_watched_activity"))
|
||||
case watchedSortRating:
|
||||
db.Order(obc("watcheds.rating"))
|
||||
case watchedSortAlphabetical:
|
||||
db.
|
||||
Order(obc("Content__title")).
|
||||
Order(obc("Game__name"))
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,6 +3,6 @@ import type { ClientInit } from "@sveltejs/kit";
|
||||
export const init: ClientInit = async () => {
|
||||
console.info(
|
||||
`%cWATCHARR v${__WATCHARR_VERSION__}`,
|
||||
"background: white;color: black;font-size: large;padding: 3px 5px;",
|
||||
"background: white;color: black;font-size: 18px;padding: 3px 5px;",
|
||||
);
|
||||
};
|
||||
|
||||
+17
-62
@@ -6,40 +6,25 @@
|
||||
import { store, clearActiveFilters } from "@/store.svelte";
|
||||
import type { Watched } from "@/types";
|
||||
import GamePoster from "./poster/GamePoster.svelte";
|
||||
import { getLatestWatchedInTv } from "./util/helpers";
|
||||
import { notify } from "./util/notify";
|
||||
import { untrack } from "svelte";
|
||||
import Spinner from "./Spinner.svelte";
|
||||
|
||||
interface Props {
|
||||
list: Watched[];
|
||||
isLoading: boolean;
|
||||
isPublicList?: boolean;
|
||||
}
|
||||
|
||||
let { list, isPublicList = false }: Props = $props();
|
||||
let { list, isPublicList = false, isLoading = false }: Props = $props();
|
||||
|
||||
let sort = $derived(store.activeSort);
|
||||
let filters = $derived(store.activeFilters);
|
||||
let settings = $derived(store.userSettings);
|
||||
let watched: Watched[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
if (list) {
|
||||
watched = list;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (list && filters.status && filters.type && sort) {
|
||||
untrack(() => {
|
||||
filt();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks if content has been watched previously
|
||||
* by analyzing the watched entrys activity (with
|
||||
* the latest AI improvements added in of course.)
|
||||
* by analyzing the watched entrys activity.
|
||||
*/
|
||||
function contentWatchedPreviously(w: Watched) {
|
||||
let wp = false;
|
||||
@@ -81,30 +66,7 @@
|
||||
// Set watched to list and sort it.
|
||||
watched = list
|
||||
.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
const atitle = a.content
|
||||
? a.content.title
|
||||
: a.game
|
||||
? a.game.name
|
||||
: "";
|
||||
const btitle = b.content
|
||||
? b.content.title
|
||||
: b.game
|
||||
? b.game.name
|
||||
: "";
|
||||
if (sort[1] === "UP") {
|
||||
return atitle.localeCompare(btitle);
|
||||
} else if (sort[1] === "DOWN") {
|
||||
return btitle.localeCompare(atitle);
|
||||
}
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP")
|
||||
return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN")
|
||||
return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
} else if (sort[0] === "LASTFIN") {
|
||||
if (sort[0] === "LASTFIN") {
|
||||
const aLastFinishActivity = a.activity
|
||||
?.sort(
|
||||
(aa, bb) =>
|
||||
@@ -113,6 +75,7 @@
|
||||
)
|
||||
?.find(
|
||||
(aa) =>
|
||||
// PORT NOTE: ALSO `IMPORTED_WATCHED` & `IMPORTED_ADDED_WATCHED`
|
||||
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
|
||||
(aa.type === "ADDED_WATCHED" &&
|
||||
aa.data?.includes("FINISHED")),
|
||||
@@ -139,10 +102,6 @@
|
||||
return Date.parse(alfaDate) - Date.parse(blfaDate);
|
||||
else if (sort[1] === "DOWN")
|
||||
return Date.parse(blfaDate) - Date.parse(alfaDate);
|
||||
} else if (sort[0] === "RATING") {
|
||||
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
|
||||
else if (sort[1] === "DOWN")
|
||||
return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
@@ -217,13 +176,13 @@
|
||||
*/
|
||||
function itemUpdated() {
|
||||
console.debug("itemUpdated");
|
||||
filt();
|
||||
// filt();
|
||||
}
|
||||
</script>
|
||||
|
||||
<PosterList>
|
||||
{#if watched?.length > 0}
|
||||
{#each watched as w (w.id)}
|
||||
{#if list?.length > 0}
|
||||
{#each list as w, i (w.id)}
|
||||
{#if w.game}
|
||||
<GamePoster
|
||||
id={w.id}
|
||||
@@ -248,7 +207,7 @@
|
||||
/>
|
||||
{:else if w.content}
|
||||
<Poster
|
||||
id={w.id}
|
||||
bind:watched={list[i]}
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
@@ -258,24 +217,14 @@
|
||||
release_date: w.content.release_date,
|
||||
first_air_date: w.content.first_air_date,
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
disableInteraction={isPublicList}
|
||||
extraDetails={{
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(
|
||||
w.watchedSeasons,
|
||||
w.watchedEpisodes,
|
||||
),
|
||||
}}
|
||||
fluidSize={true}
|
||||
pinned={w.pinned}
|
||||
onUpdated={itemUpdated}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{:else if !isLoading}
|
||||
<div class="empty-list">
|
||||
{#if list?.length > 0}
|
||||
<!-- `watched` (filtered list) is empty, but `list` (unfiltered) isn't,
|
||||
@@ -303,6 +252,12 @@
|
||||
{/if}
|
||||
</PosterList>
|
||||
|
||||
{#if isLoading}
|
||||
<div style="margin-bottom: 60px;">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.empty-list {
|
||||
display: flex;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
interface Props {
|
||||
contentTitle: string;
|
||||
thoughts: string;
|
||||
onChange: (newThoughts: string) => Promise<boolean>;
|
||||
onChange: (newThoughts: string) => Promise<void>;
|
||||
}
|
||||
|
||||
let { contentTitle, thoughts, onChange }: Props = $props();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
|
||||
import HorizontalList from "../HorizontalList.svelte";
|
||||
import { store } from "@/store.svelte";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import Poster from "../poster/Poster.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -15,11 +13,11 @@
|
||||
|
||||
{#if similar?.results?.length > 0}
|
||||
<HorizontalList title="Similar">
|
||||
{#each similar.results as content}
|
||||
{#each similar.results as content, i}
|
||||
<Poster
|
||||
media={{ ...content, media_type: type }}
|
||||
{...getWatchedDependedProps(content.id, type, store.watchedList)}
|
||||
small={true}
|
||||
bind:watched={similar.results[i].watched}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import Icon from "../Icon.svelte";
|
||||
import tooltip from "../actions/tooltip";
|
||||
import Menu from "../Menu.svelte";
|
||||
import DropDown from "../DropDown.svelte";
|
||||
import Checkbox from "../Checkbox.svelte";
|
||||
|
||||
function filterClicked(type: keyof Filters, f: string) {
|
||||
if (store.activeFilters[type]?.includes(f)) {
|
||||
@@ -32,20 +34,20 @@
|
||||
</div>
|
||||
<div class="type-filter">
|
||||
<button
|
||||
class={`${store.activeFilters.type.includes("tv") ? "active" : ""}`}
|
||||
class={`pill ${store.activeFilters.type.includes("tv") ? "active" : ""}`}
|
||||
onclick={() => filterClicked("type", "tv")}
|
||||
>
|
||||
SHOW
|
||||
</button>
|
||||
<button
|
||||
class={`${store.activeFilters.type.includes("movie") ? "active" : ""}`}
|
||||
class={`pill ${store.activeFilters.type.includes("movie") ? "active" : ""}`}
|
||||
onclick={() => filterClicked("type", "movie")}
|
||||
>
|
||||
MOVIE
|
||||
</button>
|
||||
{#if store.serverFeatures?.games}
|
||||
<button
|
||||
class={`${store.activeFilters.type.includes("game") ? "active" : ""}`}
|
||||
class={`pill ${store.activeFilters.type.includes("game") ? "active" : ""}`}
|
||||
onclick={() => filterClicked("type", "game")}
|
||||
>
|
||||
GAME
|
||||
@@ -149,24 +151,14 @@
|
||||
.type-filter {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
flex: 1 1 45%;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
|
||||
&:first-of-type {
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
&:not(:first-of-type) {
|
||||
border-left: unset;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<!-- Extra Details View For Posters -->
|
||||
<script lang="ts">
|
||||
import { store } from "@/store.svelte";
|
||||
import {
|
||||
RatingSystem,
|
||||
type PosterExtraDetails,
|
||||
type WatchedStatus,
|
||||
} from "@/types";
|
||||
import { RatingSystem } from "@/types";
|
||||
import {
|
||||
getOrdinalSuffix,
|
||||
monthsShort,
|
||||
@@ -14,14 +10,15 @@
|
||||
import Icon from "../Icon.svelte";
|
||||
import { toShowableRating, toWhichThumb } from "../rating/helpers";
|
||||
import { page } from "$app/state";
|
||||
import type { PosterExtraDetails } from "./lib";
|
||||
|
||||
interface Props {
|
||||
rating: number | undefined;
|
||||
status: WatchedStatus | undefined;
|
||||
details: PosterExtraDetails | undefined;
|
||||
}
|
||||
|
||||
let { rating, status, details }: Props = $props();
|
||||
let {
|
||||
rating,
|
||||
status,
|
||||
dateAdded,
|
||||
dateModified,
|
||||
lastWatched,
|
||||
}: PosterExtraDetails = $props();
|
||||
|
||||
let isUsingThumbs = $derived(
|
||||
store.userSettings &&
|
||||
@@ -39,7 +36,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if (page.url?.pathname === "/" || page.url?.pathname.startsWith("/search")) && details && store.wlDetailedView && store.wlDetailedView.length > 0}
|
||||
{#if (page.url?.pathname === "/" || page.url?.pathname.startsWith("/search")) && store.wlDetailedView && store.wlDetailedView.length > 0}
|
||||
<div class="extra-details">
|
||||
<!--
|
||||
The `if` statements can't be on their own line to look pretty
|
||||
@@ -49,24 +46,24 @@
|
||||
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
|
||||
-->
|
||||
<div>
|
||||
{#if details.dateAdded && store.wlDetailedView.includes("dateAdded")}
|
||||
{#if dateAdded && store.wlDetailedView.includes("dateAdded")}
|
||||
<span title="Date added to watch list">
|
||||
<i><Icon i="calendar" /></i>
|
||||
<span>
|
||||
{formatDate(Date.parse(details.dateAdded))}
|
||||
{formatDate(Date.parse(dateAdded))}
|
||||
</span>
|
||||
</span>
|
||||
{/if}{#if details.dateModified && store.wlDetailedView.includes("dateModified")}
|
||||
{/if}{#if dateModified && store.wlDetailedView.includes("dateModified")}
|
||||
<span title="Date last modified">
|
||||
<i><Icon i="pencil" wh={15} /></i>
|
||||
<span>
|
||||
{formatDate(Date.parse(details.dateModified))}
|
||||
{formatDate(Date.parse(dateModified))}
|
||||
</span>
|
||||
</span>
|
||||
{/if}{#if details.lastWatched && store.wlDetailedView.includes("lastWatched")}
|
||||
{/if}{#if lastWatched && store.wlDetailedView.includes("lastWatched")}
|
||||
<span title="Latest season watched">
|
||||
<i><Icon i="play" wh={15} /></i>
|
||||
<span>{details.lastWatched}</span>
|
||||
<span>{lastWatched}</span>
|
||||
</span>
|
||||
{/if}{#if store.wlDetailedView.includes("statusRating")}
|
||||
<span class="status-rating" title="Status and Rating">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { PosterExtraDetails, MediaType, WatchedStatus } from "@/types";
|
||||
import type { WatchedStatus, Watched, ContentType } from "@/types";
|
||||
import {
|
||||
addClassToParent,
|
||||
calculateTransformOrigin,
|
||||
@@ -13,25 +13,27 @@
|
||||
import PosterStatus from "./PosterStatus.svelte";
|
||||
import PosterRating from "./PosterRating.svelte";
|
||||
import ExtraDetails from "./ExtraDetails.svelte";
|
||||
import { buildExtraDetails } from "./lib";
|
||||
|
||||
interface Props {
|
||||
id?: number | undefined; // Watched list id
|
||||
/**
|
||||
* If this content is on our watched list,
|
||||
* the entry should be provided in full.
|
||||
*/
|
||||
watched?: Watched;
|
||||
media: {
|
||||
poster_path?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
overview?: string;
|
||||
id: number; // tmdb id
|
||||
media_type: MediaType;
|
||||
media_type: ContentType;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
};
|
||||
rating?: number | undefined;
|
||||
status?: WatchedStatus | undefined;
|
||||
small?: boolean;
|
||||
disableInteraction?: boolean;
|
||||
hideButtons?: boolean;
|
||||
extraDetails?: PosterExtraDetails | undefined;
|
||||
fluidSize?: boolean;
|
||||
pinned?: boolean;
|
||||
/**
|
||||
@@ -52,14 +54,15 @@
|
||||
}
|
||||
|
||||
let {
|
||||
id = undefined,
|
||||
media,
|
||||
rating = undefined,
|
||||
status = undefined,
|
||||
// The `watched` prop is bindable so that when we update
|
||||
// or add content to our list, we can let the update flow
|
||||
// back to our parent that passed it in (so state is in sync
|
||||
// between parent and this component).
|
||||
watched = $bindable(undefined),
|
||||
small = false,
|
||||
disableInteraction = false,
|
||||
hideButtons = false,
|
||||
extraDetails = undefined,
|
||||
fluidSize = false,
|
||||
pinned = false,
|
||||
hideIfNotOnList = false,
|
||||
@@ -79,7 +82,7 @@
|
||||
// cached image. Could be improved, since we could have a cached image for
|
||||
// show not on someone elses watched list.
|
||||
let poster = $derived(
|
||||
id
|
||||
watched
|
||||
? `${baseURL}/img${media.poster_path}`
|
||||
: `https://image.tmdb.org/t/p/w500${media.poster_path}`,
|
||||
);
|
||||
@@ -90,33 +93,51 @@
|
||||
let year = $derived(dateStr ? new Date(dateStr).getFullYear() : undefined);
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
if (r == rating) return;
|
||||
updateWatched(media.id, media.media_type, undefined, r).then(() => {
|
||||
if (r == watched?.rating) return;
|
||||
updateWatched(watched, {
|
||||
contentId: media.id,
|
||||
contentType: media.media_type,
|
||||
rating: r,
|
||||
}).then((w) => {
|
||||
if (typeof onUpdated === "function") {
|
||||
onUpdated();
|
||||
runPosterMouseLeaveIfNeeded();
|
||||
}
|
||||
// If watched was just added, we need to assign
|
||||
// it to our `watched` var to get the update.
|
||||
watched = w;
|
||||
});
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (type === "DELETE") {
|
||||
if (!id) {
|
||||
if (!watched) {
|
||||
notify({
|
||||
text: "Content has no watched list id, can't delete.",
|
||||
text: "Content has no watched list entry, can't delete.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
removeWatched(id);
|
||||
removeWatched(watched.id).then((removed) => {
|
||||
if (removed) {
|
||||
watched = undefined;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type == status) return;
|
||||
updateWatched(media.id, media.media_type, type).then(() => {
|
||||
if (type == watched?.status) return;
|
||||
updateWatched(watched, {
|
||||
contentId: media.id,
|
||||
contentType: media.media_type,
|
||||
status: type,
|
||||
}).then((w) => {
|
||||
if (typeof onUpdated === "function") {
|
||||
onUpdated();
|
||||
runPosterMouseLeaveIfNeeded();
|
||||
}
|
||||
// If watched was just added, we need to assign
|
||||
// it to our `watched` var to get the update.
|
||||
watched = w;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,7 +173,8 @@
|
||||
*/
|
||||
function runPosterMouseLeaveIfNeeded() {
|
||||
// Timeout to give enough time for the element to
|
||||
// actually move if it needs to.
|
||||
// actually move if it needs to (which can happen if
|
||||
// certain filters/sorts are applied).
|
||||
setTimeout(() => {
|
||||
if (!mouseOverEl(containerEl)) {
|
||||
posterOnMouseLeave();
|
||||
@@ -212,7 +234,7 @@
|
||||
}
|
||||
}}
|
||||
onkeypress={() => console.log("on kpress")}
|
||||
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}${hideIfNotOnList && !id ? "hidden " : ""}`}
|
||||
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}${hideIfNotOnList && !watched ? "hidden " : ""}`}
|
||||
>
|
||||
<div
|
||||
class={`container${!poster || !media.poster_path ? " details-shown" : ""}`}
|
||||
@@ -232,9 +254,9 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if id && !posterActive}
|
||||
{#if watched && !posterActive}
|
||||
<!-- Must be on watched list, and poster not hovered -->
|
||||
<ExtraDetails details={extraDetails} {status} {rating} />
|
||||
<ExtraDetails {...buildExtraDetails(media.media_type, watched)} />
|
||||
{/if}
|
||||
<div
|
||||
onclick={(e) => {
|
||||
@@ -264,8 +286,16 @@
|
||||
|
||||
{#if !hideButtons}
|
||||
<div class="buttons">
|
||||
<PosterRating {rating} {handleStarClick} {disableInteraction} />
|
||||
<PosterStatus {status} {handleStatusClick} {disableInteraction} />
|
||||
<PosterRating
|
||||
rating={watched?.rating}
|
||||
{handleStarClick}
|
||||
{disableInteraction}
|
||||
/>
|
||||
<PosterStatus
|
||||
status={watched?.status}
|
||||
{handleStatusClick}
|
||||
{disableInteraction}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ContentType, Watched, WatchedStatus } from "@/types";
|
||||
import { getLatestWatchedInTv } from "../util/helpers";
|
||||
|
||||
export type PosterExtraDetails = {
|
||||
rating: number | undefined;
|
||||
status: WatchedStatus | undefined;
|
||||
dateAdded?: string;
|
||||
dateModified?: string;
|
||||
/**
|
||||
* Only for shows.
|
||||
*/
|
||||
lastWatched?: string;
|
||||
};
|
||||
|
||||
export function buildExtraDetails(
|
||||
t: ContentType,
|
||||
w: Watched,
|
||||
): PosterExtraDetails {
|
||||
const obj = {
|
||||
rating: w.rating,
|
||||
status: w.status,
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt,
|
||||
} as PosterExtraDetails;
|
||||
if (t === "tv") {
|
||||
obj.lastWatched = getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
interface Props {
|
||||
rating: number | undefined;
|
||||
onChange: (newRating: number) => Promise<boolean>;
|
||||
onChange: (newRating: number) => Promise<void>;
|
||||
}
|
||||
|
||||
let { rating, onChange }: Props = $props();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
interface Props {
|
||||
rating: number | undefined;
|
||||
onChange: (newRating: number) => Promise<boolean>;
|
||||
onChange: (newRating: number) => Promise<void>;
|
||||
}
|
||||
|
||||
let { rating, onChange }: Props = $props();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
interface Props {
|
||||
rating: number | undefined;
|
||||
onChange: (newRating: number) => Promise<boolean>;
|
||||
onChange: (newRating: number) => Promise<void>;
|
||||
}
|
||||
|
||||
let { rating, onChange }: Props = $props();
|
||||
|
||||
+99
-86
@@ -12,6 +12,7 @@ import {
|
||||
type Follow,
|
||||
type PlayedAddRequest,
|
||||
type ActivityUpdateRequest,
|
||||
type WatchedAddedToContent,
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { notify, unNotify } from "./notify";
|
||||
@@ -26,9 +27,21 @@ export const baseURL =
|
||||
: "/api";
|
||||
console.log("api: baseURL constructed:", baseURL);
|
||||
|
||||
interface UpdateWatchedOptions {
|
||||
/**
|
||||
* TMDB ID.
|
||||
*/
|
||||
contentId: number;
|
||||
contentType: MediaType;
|
||||
status?: WatchedStatus;
|
||||
rating?: number;
|
||||
thoughts?: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates watched item with new status, rating or thoughts.
|
||||
* @returns Was success?
|
||||
* @param wEntry The watched entry to update. Updates properties in this object.
|
||||
*/
|
||||
async function _updateWatched(
|
||||
wEntry: Watched,
|
||||
@@ -36,7 +49,7 @@ async function _updateWatched(
|
||||
rating?: number,
|
||||
thoughts?: string,
|
||||
pinned?: boolean,
|
||||
): Promise<boolean> {
|
||||
) {
|
||||
if (
|
||||
!status &&
|
||||
!rating &&
|
||||
@@ -46,112 +59,112 @@ async function _updateWatched(
|
||||
console.warn(
|
||||
"_updateWatched: Nothing was provided, so nothing can be updated!!!!",
|
||||
);
|
||||
return false;
|
||||
throw new Error("no updated values provided");
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
const obj = {} as WatchedUpdateRequest;
|
||||
if (status) obj.status = status;
|
||||
if (rating) obj.rating = rating;
|
||||
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
|
||||
if (thoughts === "") obj.removeThoughts = true;
|
||||
if (typeof pinned !== "undefined") obj.pinned = pinned;
|
||||
return await axios
|
||||
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
|
||||
.then((resp) => {
|
||||
if (status) wEntry.status = status;
|
||||
if (rating) wEntry.rating = rating;
|
||||
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
|
||||
if (typeof pinned !== "undefined") wEntry.pinned = pinned;
|
||||
if (resp?.data?.newActivity && resp?.data?.newActivity?.id) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(resp.data.newActivity);
|
||||
} else {
|
||||
wEntry.activity = [resp.data.newActivity];
|
||||
}
|
||||
// We want to update the updatedAt field too (so
|
||||
// change is reflected when filtering modified at)
|
||||
// We can piggy back from this data for now.
|
||||
wEntry.updatedAt = resp.data.newActivity.createdAt;
|
||||
}
|
||||
// watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
return true;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
return false;
|
||||
});
|
||||
const resp = await axios.put<WatchedUpdateResponse>(
|
||||
`/watched/${wEntry.id}`,
|
||||
obj,
|
||||
);
|
||||
if (status) wEntry.status = status;
|
||||
if (rating) wEntry.rating = rating;
|
||||
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
|
||||
if (typeof pinned !== "undefined") wEntry.pinned = pinned;
|
||||
if (resp?.data?.newActivity && resp?.data?.newActivity?.id) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(resp.data.newActivity);
|
||||
} else {
|
||||
wEntry.activity = [resp.data.newActivity];
|
||||
}
|
||||
// We want to update the updatedAt field too (so
|
||||
// change is reflected when filtering modified at)
|
||||
// We can piggy back from this data for now.
|
||||
wEntry.updatedAt = resp.data.newActivity.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update watched show/movie.
|
||||
* @param contentId TMDB ID
|
||||
* @param contentType show/movie
|
||||
* @param status
|
||||
* @param rating
|
||||
* @returns Was success?
|
||||
* @param wEntry The watched entry (movie or tv only) we are updating.
|
||||
* @param opts Update options.
|
||||
* @returns Always returns Watched obj unless failed to add.
|
||||
* If updating fails, existing Watched obj will always return.
|
||||
*/
|
||||
export async function updateWatched(
|
||||
contentId: number,
|
||||
contentType: MediaType,
|
||||
status?: WatchedStatus,
|
||||
rating?: number,
|
||||
thoughts?: string,
|
||||
pinned?: boolean,
|
||||
): Promise<boolean> {
|
||||
// If item is already in watched store, run update request instead
|
||||
const wEntry = store.watchedList.find(
|
||||
(w) => w.content?.tmdbId === contentId && w.content?.type === contentType,
|
||||
);
|
||||
if (wEntry?.id) {
|
||||
return await _updateWatched(wEntry, status, rating, thoughts, pinned);
|
||||
}
|
||||
// Add new watched item
|
||||
const nid = notify({ text: `Adding`, type: "loading" });
|
||||
return await axios
|
||||
.post("/watched", {
|
||||
contentId,
|
||||
contentType,
|
||||
rating,
|
||||
status,
|
||||
} as WatchedAddRequest)
|
||||
.then((resp) => {
|
||||
wEntry: Watched | undefined,
|
||||
opts: UpdateWatchedOptions,
|
||||
): Promise<Watched | undefined> {
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
try {
|
||||
// If exists, run update request instead
|
||||
if (wEntry?.id) {
|
||||
try {
|
||||
await _updateWatched(
|
||||
wEntry,
|
||||
opts.status,
|
||||
opts.rating,
|
||||
opts.thoughts,
|
||||
opts.pinned,
|
||||
);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
} catch (err) {
|
||||
console.error("updateWatched: Failed to update!", err);
|
||||
notify({ id: nid, text: `Saving Failed!`, type: "error" });
|
||||
}
|
||||
// We are updating, so a wEntry exists here.
|
||||
// So we will always return the existing entry,
|
||||
// regardless of if we fail above.
|
||||
return wEntry;
|
||||
}
|
||||
try {
|
||||
// Add new watched item
|
||||
notify({ id: nid, text: `Adding`, type: "loading" });
|
||||
const resp = await axios.post<Watched>("/watched", {
|
||||
contentId: opts.contentId,
|
||||
contentType: opts.contentType,
|
||||
rating: opts.rating,
|
||||
status: opts.status,
|
||||
} as WatchedAddRequest);
|
||||
console.log("Added watched:", resp.data);
|
||||
store.watchedList.push(resp.data as Watched);
|
||||
notify({ id: nid, text: `Added!`, type: "success" });
|
||||
return true;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Add!", type: "error" });
|
||||
return false;
|
||||
});
|
||||
return resp.data;
|
||||
} catch (err) {
|
||||
console.error("updateWatched: Failed to add!", err);
|
||||
notify({ id: nid, text: `Adding Failed!`, type: "error" });
|
||||
// Watched entry not added so returning undefined is fine,
|
||||
// that will be the current value everywhere anyways.
|
||||
return undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("updateWatched: Failed!", err);
|
||||
notify({ id: nid, text: `Failed!`, type: "error" });
|
||||
return wEntry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an item from watched list.
|
||||
* @param id Watched Entry ID
|
||||
* @returns Deleted?
|
||||
*/
|
||||
export function removeWatched(id: number) {
|
||||
const nid = notify({ text: `Removing`, type: "loading" });
|
||||
const wEntry = store.watchedList.find((w) => w.id === id);
|
||||
if (!wEntry) {
|
||||
console.log("Watched entry does not exist!");
|
||||
notify({ text: "Item Doesn't Exist On Watched List!", type: "error" });
|
||||
return;
|
||||
export async function removeWatched(id: number): Promise<boolean> {
|
||||
console.log("removeWatched: Removing:", id);
|
||||
const nid = notify({ text: "Removing", type: "loading" });
|
||||
try {
|
||||
const resp = await axios.delete(`/watched/${id}`);
|
||||
console.log("removeWatched: Removed resp:", resp.data);
|
||||
notify({ id: nid, text: "Removed!", type: "success" });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("removeWatched: Failed!", err);
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
}
|
||||
axios
|
||||
.delete(`/watched/${id}`)
|
||||
.then((resp) => {
|
||||
console.log("Removed watched:", resp.data);
|
||||
store.watchedList = store.watchedList.filter((w) => w.id !== id);
|
||||
notify({ id: nid, text: "Removed!", type: "error" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function updatePlayed(
|
||||
|
||||
+3
-38
@@ -56,45 +56,10 @@ export function isTouch() {
|
||||
return "ontouchstart" in window;
|
||||
}
|
||||
|
||||
// Not passing wList from #each loop caused it not to have reactivity.
|
||||
// Passing it through must allow it to recognize it as a dependency?
|
||||
export function getWatchedDependedProps(
|
||||
wid: number,
|
||||
wtype: MediaType,
|
||||
list: Watched[],
|
||||
) {
|
||||
const wel = list.find(
|
||||
(wl) => wl.content?.tmdbId === wid && wl.content?.type === wtype,
|
||||
);
|
||||
if (!wel) return {};
|
||||
console.log(wid, wtype, wel?.content?.title, wel?.status, wel?.rating);
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
rating: wel.rating,
|
||||
extraDetails: {
|
||||
dateAdded: wel.createdAt,
|
||||
dateModified: wel.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(
|
||||
wel.watchedSeasons,
|
||||
wel.watchedEpisodes,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlayedDependedProps(wid: number, list: Watched[]) {
|
||||
const wel = list.find((wl) => wl.game?.igdbId === wid);
|
||||
if (!wel) return {};
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
rating: wel.rating,
|
||||
extraDetails: {
|
||||
dateAdded: wel.createdAt,
|
||||
dateModified: wel.updatedAt,
|
||||
},
|
||||
};
|
||||
// TODO this is only here to avoid error for now, but we are removing
|
||||
// this func, just like we removed getWatchedDependedProps, because
|
||||
// the poster component will now just accept the whole `watched` entry obj
|
||||
}
|
||||
|
||||
// Get biggest season watching or biggest season watched.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { page } from "$app/state";
|
||||
|
||||
export interface ToolTipOptions {
|
||||
/**
|
||||
* Infinite scroll threshold.
|
||||
* Distance from bottom.
|
||||
*/
|
||||
threshold?: number;
|
||||
/**
|
||||
* Ran when we reach the end of scroll.
|
||||
* This callback should load the new data.
|
||||
*/
|
||||
callback: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infinite scroll helper.
|
||||
*
|
||||
* **Ensure:**
|
||||
* - `destroy()` is ran after the component
|
||||
* this is used in is destroyed.
|
||||
* - `dataLoaded()` is ran after a page of data
|
||||
* is loaded.
|
||||
* - `opts.callback()` has its own 'isLoading' logic
|
||||
* to prevent itself from running extra times while
|
||||
* still loading a previous request. This will be the
|
||||
* thing stopping extra scrolls while data is still
|
||||
* loading from causing extra data requests, etc.
|
||||
*/
|
||||
export default function infScroll(opts: ToolTipOptions) {
|
||||
let { threshold = 150, callback } = opts;
|
||||
|
||||
// Store current pathname at point of infScroll
|
||||
// initialization, this ensures we have a point
|
||||
// of reference for our fix below (that ensures
|
||||
// we don't allow asking for next data load if
|
||||
// user navigates to a different page).
|
||||
const startPagePathLower = page.url?.pathname?.toLowerCase();
|
||||
|
||||
const addEvents = () => {
|
||||
console.debug("infScroll->addEvents()");
|
||||
window.addEventListener("scroll", run);
|
||||
window.addEventListener("resize", run);
|
||||
};
|
||||
|
||||
const removeEvents = () => {
|
||||
console.debug("infScroll->removeEvents()");
|
||||
window.removeEventListener("scroll", run);
|
||||
window.removeEventListener("resize", run);
|
||||
};
|
||||
|
||||
const isAtBottom = () => {
|
||||
return (
|
||||
window.innerHeight + Math.round(window.scrollY) + threshold >=
|
||||
document.body.offsetHeight
|
||||
);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (isAtBottom()) {
|
||||
console.log("infiniteScroll: Reached end");
|
||||
removeEvents();
|
||||
await callback();
|
||||
addEvents();
|
||||
console.log("infiniteScroll: Callback ran");
|
||||
} else {
|
||||
console.debug("infiniteScroll: Not at bottom");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This needs to be called after data is loaded.
|
||||
* It runs the infScroll logic incase the user is
|
||||
* already at the bottom after initial data load
|
||||
* without scroll/resize (eg: using high dpi screen).
|
||||
*/
|
||||
const dataLoaded = () => {
|
||||
// If results don't fill the page enough to enable scrolling,
|
||||
// the user could be stuck and not be able to get more results
|
||||
// to show, run `infiniteScroll` to load more if we can.
|
||||
// Smol timeout to give ui time to render so end of page calc
|
||||
// can be accurate.
|
||||
setTimeout(() => {
|
||||
// Quick fix, if user navigates away from search page while response is loading,
|
||||
// we don't want to call infiniteScroll or we could end up loading all pages
|
||||
// in the background.
|
||||
if (startPagePathLower === page.url?.pathname?.toLowerCase()) {
|
||||
console.debug(
|
||||
"infiniteScroll->dataLoaded(): Still at bottom.. asking for more data.",
|
||||
);
|
||||
run();
|
||||
} else {
|
||||
console.debug(
|
||||
"infiniteScroll->dataLoaded(): No longer on initial page, not calling infiniteScroll.",
|
||||
);
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
|
||||
addEvents();
|
||||
|
||||
return {
|
||||
isAtBottom,
|
||||
run,
|
||||
dataLoaded,
|
||||
destroy: () => {
|
||||
console.debug("infScroll->destroy()");
|
||||
removeEvents();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -121,6 +121,10 @@
|
||||
fill: $bg-color;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,17 +85,13 @@
|
||||
|
||||
async function getInitialData() {
|
||||
if (localStorage.getItem("token")) {
|
||||
const [w, u, s, f, fo, ts] = await Promise.all([
|
||||
axios.get("/watched"),
|
||||
const [u, s, f, fo, ts] = await Promise.all([
|
||||
axios.get("/user"),
|
||||
axios.get("/user/settings"),
|
||||
axios.get("/features"),
|
||||
axios.get("/follow"),
|
||||
axios.get("/tag"),
|
||||
]);
|
||||
if (w?.data?.length > 0) {
|
||||
store.watchedList = w.data;
|
||||
}
|
||||
if (u?.data) {
|
||||
store.userInfo = u.data;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,141 @@
|
||||
<script lang="ts">
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import infScroll from "@/lib/util/infScroll";
|
||||
import WatchedList from "@/lib/WatchedList.svelte";
|
||||
import { store } from "@/store.svelte";
|
||||
import type { PaginationResponse, Watched } from "@/types";
|
||||
import axios from "axios";
|
||||
import { onDestroy, onMount, untrack } from "svelte";
|
||||
|
||||
const scroll = infScroll({ callback: onScrollToBottom });
|
||||
|
||||
let reqController = new AbortController();
|
||||
let list: Watched[] = $state([]);
|
||||
// Current list page loaded
|
||||
let listPage = $state(0);
|
||||
// Max amount of pages for list
|
||||
let listPageMax = $state(1);
|
||||
let listLoading = $state(false);
|
||||
let listLoadError: any = $state();
|
||||
|
||||
/**
|
||||
* Fetches paginated watched list.
|
||||
*/
|
||||
async function loadWatchedList() {
|
||||
const logStyle = "font-weight: bold; font-size: 18px;";
|
||||
if (listLoading) {
|
||||
console.warn("%cloadWatchedList: already running", logStyle);
|
||||
return;
|
||||
}
|
||||
if (listPage >= listPageMax) {
|
||||
console.warn("%cloadWatchedList: max page reached", logStyle);
|
||||
return;
|
||||
}
|
||||
console.debug(
|
||||
`%cloadWatchedList: Page=${listPage} Max=${listPageMax}`,
|
||||
logStyle,
|
||||
);
|
||||
listLoading = true;
|
||||
reqController = new AbortController();
|
||||
try {
|
||||
const pl = await axios.get<PaginationResponse<Watched>>(`/watched`, {
|
||||
params: {
|
||||
p: listPage + 1,
|
||||
...store.sortAndFiltersForQueryParams,
|
||||
},
|
||||
signal: reqController.signal,
|
||||
});
|
||||
if (pl.data.results.length <= 0) {
|
||||
console.log("loadWatchedList: No results.");
|
||||
return;
|
||||
}
|
||||
listPage = pl.data.page;
|
||||
listPageMax = pl.data.totalPages;
|
||||
list.push(...pl.data.results);
|
||||
list = list;
|
||||
scroll.dataLoaded();
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ERR_CANCELED") {
|
||||
console.warn("loadWatchedList: Cancelled, not showing error.");
|
||||
} else {
|
||||
console.error("loadWatchedList: failed!", err);
|
||||
listLoadError = err;
|
||||
}
|
||||
}
|
||||
listLoading = false;
|
||||
}
|
||||
|
||||
async function onScrollToBottom() {
|
||||
// If an error is being shown, no more infinite scroll.
|
||||
if (listLoadError) {
|
||||
return;
|
||||
}
|
||||
loadWatchedList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets our loaded watched list data, page data, etc.
|
||||
*/
|
||||
function resetWatchedList() {
|
||||
list = [];
|
||||
listPage = 0;
|
||||
listPageMax = 1;
|
||||
listLoading = false;
|
||||
listLoadError = undefined;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// When our sort/filter query params change,
|
||||
// load our list again.
|
||||
// Since it exists at load, this performs our
|
||||
// initial load of data too.
|
||||
if (store.sortAndFiltersForQueryParams) {
|
||||
untrack(() => {
|
||||
// We don't want to trigger another re-run of this
|
||||
// effect when state inside these funcs changes.
|
||||
resetWatchedList();
|
||||
loadWatchedList();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
console.log("MAIN PAGE DESTROYED");
|
||||
scroll.destroy();
|
||||
reqController.abort("page destroyed");
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Watched List</title>
|
||||
</svelte:head>
|
||||
|
||||
<WatchedList list={store.watchedList} />
|
||||
<span style="position: fixed; top: 80px; background-color: white; z-index: 60;"
|
||||
>listPage: {listPage} listPageMax: {listPageMax} listLoading: {listLoading}<br
|
||||
/>
|
||||
sort: {JSON.stringify(store.activeSort)} filter: {JSON.stringify(
|
||||
store.activeFilters,
|
||||
)}<br />
|
||||
{JSON.stringify(store.sortAndFiltersForQueryParams)}</span
|
||||
>
|
||||
|
||||
<WatchedList {list} isLoading={listLoading} />
|
||||
{#if listLoadError}
|
||||
<div style="margin-bottom: 60px;">
|
||||
<Error
|
||||
pretty="Failed to load results!"
|
||||
error={listLoadError}
|
||||
onRetry={() => {
|
||||
listLoadError = undefined;
|
||||
loadWatchedList();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- TODO: A 'Thats It! All {watcheds_count} of your records.' message
|
||||
or similar message to indicate that we are now at the bottom of the
|
||||
users list (only show when listPage === listPageMax & we have any items.. etc
|
||||
probs best adding this to WatchedList component.. but message probs needs to be
|
||||
a bit different depending on viewing own list or someone elses). -->
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { store } from "@/store.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import axios from "axios";
|
||||
@@ -11,11 +10,8 @@
|
||||
TMDBUpcomingShows,
|
||||
} from "@/types";
|
||||
import Poster from "@/lib/poster/Poster.svelte";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import PosterList from "@/lib/poster/PosterList.svelte";
|
||||
|
||||
let wList = $derived(store.watchedList);
|
||||
|
||||
async function allTrending() {
|
||||
return (await axios.get(`/content/trending`)).data as TMDBTrendingAll;
|
||||
}
|
||||
@@ -56,8 +52,8 @@
|
||||
{#if trend.media_type === "movie" || trend.media_type === "tv"}
|
||||
<Poster
|
||||
media={{ ...trend, media_type: trend.media_type }}
|
||||
{...getWatchedDependedProps(trend.id, trend.media_type, wList)}
|
||||
small={true}
|
||||
watched={trend.watched}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -74,8 +70,8 @@
|
||||
{#each movies.results as movie}
|
||||
<Poster
|
||||
media={{ ...movie, media_type: "movie" }}
|
||||
{...getWatchedDependedProps(movie.id, "movie", wList)}
|
||||
small={true}
|
||||
watched={movie.watched}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
@@ -91,8 +87,8 @@
|
||||
{#each shows.results as tv}
|
||||
<Poster
|
||||
media={{ ...tv, media_type: "tv" }}
|
||||
{...getWatchedDependedProps(tv.id, "tv", wList)}
|
||||
small={true}
|
||||
watched={tv.watched}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
@@ -105,11 +101,11 @@
|
||||
<Spinner />
|
||||
{:then shows}
|
||||
<PosterList type="vertical">
|
||||
{#each shows.results as tv}
|
||||
{#each shows.results as movie}
|
||||
<Poster
|
||||
media={{ ...tv, media_type: "movie" }}
|
||||
{...getWatchedDependedProps(tv.id, "movie", wList)}
|
||||
media={{ ...movie, media_type: "movie" }}
|
||||
small={true}
|
||||
watched={movie.watched}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
@@ -125,8 +121,8 @@
|
||||
{#each shows.results as tv}
|
||||
<Poster
|
||||
media={{ ...tv, media_type: "tv" }}
|
||||
{...getWatchedDependedProps(tv.id, "tv", wList)}
|
||||
small={true}
|
||||
watched={tv.watched}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import axios from "axios";
|
||||
import {
|
||||
getWatchedDependedProps,
|
||||
getPlayedDependedProps,
|
||||
} from "@/lib/util/helpers";
|
||||
import PersonPoster from "@/lib/poster/PersonPoster.svelte";
|
||||
import type {
|
||||
ContentSearch,
|
||||
@@ -29,6 +25,7 @@
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import { afterNavigate, goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import infScroll from "@/lib/util/infScroll.js";
|
||||
|
||||
type GameWithMediaType = GameSearch & { media_type: "game" };
|
||||
type CombinedResult =
|
||||
@@ -48,7 +45,7 @@
|
||||
let searchRunning = $state(false);
|
||||
let contentSearchErr: any = $state();
|
||||
|
||||
const infiniteScrollThreshold = 150;
|
||||
const scroll = infScroll({ callback: infiniteScroll });
|
||||
let reqController = new AbortController();
|
||||
|
||||
async function searchMovies(query: string, page: number) {
|
||||
@@ -58,7 +55,7 @@
|
||||
{
|
||||
params: {
|
||||
q: encodeURIComponent(query),
|
||||
page,
|
||||
p: page,
|
||||
},
|
||||
signal: reqController.signal,
|
||||
},
|
||||
@@ -75,7 +72,7 @@
|
||||
const shows = await axios.get<ShowsSearchResponse>(`/content/search/tv`, {
|
||||
params: {
|
||||
q: encodeURIComponent(query),
|
||||
page,
|
||||
p: page,
|
||||
},
|
||||
signal: reqController.signal,
|
||||
});
|
||||
@@ -93,7 +90,7 @@
|
||||
{
|
||||
params: {
|
||||
q: encodeURIComponent(query),
|
||||
page,
|
||||
p: page,
|
||||
},
|
||||
signal: reqController.signal,
|
||||
},
|
||||
@@ -110,7 +107,7 @@
|
||||
return await axios.get<ContentSearch>(`/content/search/multi`, {
|
||||
params: {
|
||||
q: encodeURIComponent(query),
|
||||
page,
|
||||
p: page,
|
||||
},
|
||||
signal: reqController.signal,
|
||||
});
|
||||
@@ -134,7 +131,7 @@
|
||||
const games = await axios.get<GameSearch[]>(`/game/search`, {
|
||||
params: {
|
||||
q: encodeURIComponent(query),
|
||||
page,
|
||||
p: page,
|
||||
},
|
||||
signal: reqController.signal,
|
||||
});
|
||||
@@ -394,23 +391,7 @@
|
||||
console.debug("allSearchResults:", allSearchResults);
|
||||
|
||||
searchRunning = false;
|
||||
// If results don't fill the page enough to enable scrolling,
|
||||
// the user could be stuck and not be able to get more results
|
||||
// to show, run `infiniteScroll` to load more if we can.
|
||||
// Smol timeout to give ui time to render so end of page calc
|
||||
// can be accurate.
|
||||
setTimeout(() => {
|
||||
// Quick fix, if user navigates away from search page while response is loading,
|
||||
// we don't want to call infiniteScroll or we could end up loading all pages
|
||||
// in the background.
|
||||
if (page.url?.pathname?.toLowerCase()?.startsWith("/search")) {
|
||||
infiniteScroll();
|
||||
} else {
|
||||
console.debug(
|
||||
"No longer on search page, not calling infiniteScroll.",
|
||||
);
|
||||
}
|
||||
}, 250);
|
||||
scroll.dataLoaded();
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ERR_CANCELED") {
|
||||
console.warn("search was cancelled, not showing error.");
|
||||
@@ -448,18 +429,9 @@
|
||||
if (contentSearchErr) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
window.innerHeight +
|
||||
Math.round(window.scrollY) +
|
||||
infiniteScrollThreshold >=
|
||||
document.body.offsetHeight
|
||||
) {
|
||||
console.log("reached end");
|
||||
window.removeEventListener("scroll", infiniteScroll);
|
||||
if (store.searchQuery) await search(store.searchQuery);
|
||||
window.addEventListener("scroll", infiniteScroll);
|
||||
console.debug(`Page: ${curPage} / ${maxContentPage}`);
|
||||
}
|
||||
console.log("reached end");
|
||||
if (store.searchQuery) await search(store.searchQuery);
|
||||
console.debug(`Page: ${curPage} / ${maxContentPage}`);
|
||||
}
|
||||
|
||||
async function searchUsers(query: string) {
|
||||
@@ -472,14 +444,6 @@
|
||||
store.searchQuery = data?.query;
|
||||
}
|
||||
doCleanSearch();
|
||||
|
||||
window.addEventListener("scroll", infiniteScroll);
|
||||
window.addEventListener("resize", infiniteScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", infiniteScroll);
|
||||
window.removeEventListener("resize", infiniteScroll);
|
||||
};
|
||||
});
|
||||
|
||||
afterNavigate((e) => {
|
||||
@@ -507,6 +471,7 @@
|
||||
onDestroy(() => {
|
||||
console.debug("SEARCH PAGE DESTROYED");
|
||||
store.searchQuery = "";
|
||||
scroll.destroy();
|
||||
reqController.abort("page destroyed");
|
||||
});
|
||||
</script>
|
||||
@@ -584,17 +549,12 @@
|
||||
summary: w.summary,
|
||||
firstReleaseDate: w.first_release_date,
|
||||
}}
|
||||
{...getPlayedDependedProps(w.id, store.watchedList)}
|
||||
fluidSize
|
||||
/>
|
||||
{:else if w.media_type === "movie" || w.media_type === "tv"}
|
||||
{:else if searchResults[i].media_type === "movie" || searchResults[i].media_type === "tv"}
|
||||
<Poster
|
||||
media={w}
|
||||
{...getWatchedDependedProps(
|
||||
w.id,
|
||||
w.media_type,
|
||||
store.watchedList,
|
||||
)}
|
||||
bind:watched={searchResults[i].watched}
|
||||
fluidSize
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import type {
|
||||
TMDBContentCredits,
|
||||
TMDBContentCreditsCrew,
|
||||
TMDBShowDetails,
|
||||
TMDBShowDetailsWithWatched,
|
||||
WatchedStatus,
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
@@ -36,17 +36,13 @@
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let wListItem = $derived(
|
||||
store.watchedList.find(
|
||||
(w) => w.content?.type === "tv" && w.content?.tmdbId === data.tvId,
|
||||
),
|
||||
);
|
||||
// let wListItem: Watched | undefined = $state(undefined);
|
||||
let trailer: string | undefined = $state();
|
||||
let trailerShown = $state(false);
|
||||
let requestModalShown = $state(false);
|
||||
let jellyfinUrl: string | undefined = $state();
|
||||
let arrRequestButtonComp: ArrRequestButton | undefined = $state();
|
||||
let show: TMDBShowDetails | undefined = $state();
|
||||
let show: TMDBShowDetailsWithWatched | undefined = $state();
|
||||
let pageError: Error | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
@@ -61,23 +57,27 @@
|
||||
await axios.get(`/content/tv/${data.tvId}`, {
|
||||
params: { region: store.userSettings?.country },
|
||||
})
|
||||
).data as TMDBShowDetails;
|
||||
if (resp.videos?.results?.length > 0) {
|
||||
const t = resp.videos.results.find(
|
||||
(v) => v.type?.toLowerCase() === "trailer",
|
||||
);
|
||||
if (t?.key) {
|
||||
if (t?.site?.toLowerCase() === "youtube") {
|
||||
trailer = `https://www.youtube.com/embed/${t?.key}`;
|
||||
).data as TMDBShowDetailsWithWatched;
|
||||
if (resp) {
|
||||
if (resp?.videos?.results && resp?.videos?.results?.length > 0) {
|
||||
const t = resp?.videos.results.find(
|
||||
(v) => v.type?.toLowerCase() === "trailer",
|
||||
);
|
||||
if (t?.key) {
|
||||
if (t?.site?.toLowerCase() === "youtube") {
|
||||
trailer = `https://www.youtube.com/embed/${t?.key}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
contentExistsOnJellyfin("tv", resp.name, resp.id).then((j) => {
|
||||
if (j?.hasContent && j?.url !== "") {
|
||||
jellyfinUrl = j.url;
|
||||
}
|
||||
});
|
||||
show = resp;
|
||||
} else {
|
||||
show = undefined;
|
||||
}
|
||||
contentExistsOnJellyfin("tv", resp.name, resp.id).then((j) => {
|
||||
if (j?.hasContent && j?.url !== "") {
|
||||
jellyfinUrl = j.url;
|
||||
}
|
||||
});
|
||||
show = resp;
|
||||
} catch (err: any) {
|
||||
show = undefined;
|
||||
pageError = err;
|
||||
@@ -99,19 +99,33 @@
|
||||
newRating?: number,
|
||||
newThoughts?: string,
|
||||
pinned?: boolean,
|
||||
): Promise<boolean> {
|
||||
) {
|
||||
if (!data.tvId) {
|
||||
console.error("contentChanged: no tvId");
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
return await updateWatched(
|
||||
data.tvId,
|
||||
"tv",
|
||||
newStatus,
|
||||
newRating,
|
||||
newThoughts,
|
||||
pinned,
|
||||
);
|
||||
if (!show) {
|
||||
console.error("contentChanged: no show");
|
||||
return;
|
||||
}
|
||||
show.watched = await updateWatched(show.watched, {
|
||||
contentId: data.tvId,
|
||||
contentType: "tv",
|
||||
status: newStatus,
|
||||
rating: newRating,
|
||||
thoughts: newThoughts,
|
||||
pinned: pinned,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteWatched() {
|
||||
if (show?.watched) {
|
||||
if (await removeWatched(show.watched.id)) {
|
||||
show.watched = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error("deleteWatched: no wlistItem.. can't delete");
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -199,30 +213,27 @@
|
||||
bind:this={arrRequestButtonComp}
|
||||
/>
|
||||
{/if}
|
||||
{#if wListItem}
|
||||
{#if show.watched}
|
||||
<div class="other-side">
|
||||
<AddToTagButton watchedItem={wListItem} />
|
||||
<AddToTagButton watchedItem={show.watched} />
|
||||
<button
|
||||
onclick={() => {
|
||||
if (wListItem?.pinned) {
|
||||
if (show?.watched?.pinned) {
|
||||
contentChanged(undefined, undefined, undefined, false);
|
||||
} else {
|
||||
contentChanged(undefined, undefined, undefined, true);
|
||||
}
|
||||
}}
|
||||
use:tooltip={{
|
||||
text: `${wListItem?.pinned ? "Unpin from" : "Pin to"} top of list`,
|
||||
text: `${show.watched?.pinned ? "Unpin from" : "Pin to"} top of list`,
|
||||
pos: "bot",
|
||||
}}
|
||||
>
|
||||
<Icon i={wListItem?.pinned ? "unpin" : "pin"} wh={19} />
|
||||
<Icon i={show.watched?.pinned ? "unpin" : "pin"} wh={19} />
|
||||
</button>
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={() =>
|
||||
wListItem
|
||||
? removeWatched(wListItem.id)
|
||||
: console.error("no wlistItem.. can't delete")}
|
||||
onclick={() => deleteWatched()}
|
||||
use:tooltip={{ text: "Delete", pos: "bot" }}
|
||||
>
|
||||
<Icon i="trash" wh={19} />
|
||||
@@ -252,17 +263,17 @@
|
||||
<div class="review">
|
||||
<!-- <span>What did you think?</span> -->
|
||||
<Rating
|
||||
rating={wListItem?.rating}
|
||||
rating={show.watched?.rating}
|
||||
onChange={(n) => contentChanged(undefined, n)}
|
||||
/>
|
||||
<Status
|
||||
status={wListItem?.status}
|
||||
status={show.watched?.status}
|
||||
onChange={(n) => contentChanged(n)}
|
||||
/>
|
||||
{#if wListItem}
|
||||
{#if show.watched}
|
||||
<MyThoughts
|
||||
contentTitle={show.name}
|
||||
thoughts={wListItem?.thoughts}
|
||||
thoughts={show.watched?.thoughts}
|
||||
onChange={(newThoughts) => {
|
||||
return contentChanged(undefined, undefined, newThoughts);
|
||||
}}
|
||||
@@ -307,14 +318,14 @@
|
||||
|
||||
<SimilarContent type="tv" similar={show.similar} />
|
||||
|
||||
{#if wListItem}
|
||||
<Activity wListId={wListItem.id} activity={wListItem.activity} />
|
||||
{#if show.watched}
|
||||
<Activity wListId={show.watched.id} activity={show.watched.activity} />
|
||||
{/if}
|
||||
{#if data?.tvId}
|
||||
<SeasonsList
|
||||
tvId={data.tvId}
|
||||
seasons={show.seasons}
|
||||
watchedItem={wListItem}
|
||||
watchedItem={show.watched}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+28
-9
@@ -19,10 +19,10 @@ export const defaultSort = ["DATEADDED", "DOWN"];
|
||||
interface Store {
|
||||
userInfo: PrivateUser | undefined;
|
||||
userSettings: UserSettings | undefined;
|
||||
watchedList: Watched[];
|
||||
notifications: Notification[];
|
||||
activeSort: string[];
|
||||
activeFilters: Filters;
|
||||
sortAndFiltersForQueryParams: {};
|
||||
appTheme: Theme;
|
||||
importedList:
|
||||
| {
|
||||
@@ -50,10 +50,10 @@ interface Store {
|
||||
* This is our actual (private) store.
|
||||
*/
|
||||
const _store: Store = $state({
|
||||
watchedList: [],
|
||||
notifications: [],
|
||||
activeSort: defaultSort,
|
||||
activeFilters: { type: [], status: [] },
|
||||
sortAndFiltersForQueryParams: {},
|
||||
appTheme: "light",
|
||||
importedList: undefined,
|
||||
parsedImportedList: undefined,
|
||||
@@ -66,6 +66,19 @@ const _store: Store = $state({
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const updateSortAndFiltersForQueryParams = () => {
|
||||
try {
|
||||
_store.sortAndFiltersForQueryParams = {
|
||||
sort: store.activeSort[0],
|
||||
sortDir: store.activeSort[1] === "UP" ? "asc" : "desc",
|
||||
"filter.type": store.activeFilters?.type?.join(","),
|
||||
"filter.status": store.activeFilters?.status?.join(","),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("updateSortAndFiltersForQueryParams: Failed!", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Expose store to app through getters/setters
|
||||
* to control what can and can't be accessed.
|
||||
@@ -74,12 +87,6 @@ const _store: Store = $state({
|
||||
* they are updated.
|
||||
*/
|
||||
export const store = {
|
||||
get watchedList() {
|
||||
return _store.watchedList;
|
||||
},
|
||||
set watchedList(w) {
|
||||
_store.watchedList = w;
|
||||
},
|
||||
get notifications() {
|
||||
return _store.notifications;
|
||||
},
|
||||
@@ -93,6 +100,7 @@ export const store = {
|
||||
_store.activeSort = v;
|
||||
localStorage.setItem("activeFilter", JSON.stringify(v));
|
||||
console.debug("Store: Saved activeSort:", v);
|
||||
updateSortAndFiltersForQueryParams();
|
||||
},
|
||||
get activeFilters() {
|
||||
return _store.activeFilters;
|
||||
@@ -101,6 +109,15 @@ export const store = {
|
||||
_store.activeFilters = v;
|
||||
localStorage.setItem("activeFilterReal", JSON.stringify(v));
|
||||
console.debug("Store: Saved activeFilters:", v);
|
||||
updateSortAndFiltersForQueryParams();
|
||||
},
|
||||
/**
|
||||
* Return our `activeSort` and `activeFilters` in an object
|
||||
* that is in the correct format for our get watched page
|
||||
* requests (object that is given to axios for query params).
|
||||
*/
|
||||
get sortAndFiltersForQueryParams() {
|
||||
return _store.sortAndFiltersForQueryParams;
|
||||
},
|
||||
get appTheme() {
|
||||
return _store.appTheme;
|
||||
@@ -183,7 +200,6 @@ export const store = {
|
||||
* Reset everything in `store` back to default values.
|
||||
*/
|
||||
export const clearAllStores = () => {
|
||||
store.watchedList = [];
|
||||
store.notifications = [];
|
||||
store.activeSort = defaultSort;
|
||||
store.appTheme = "light";
|
||||
@@ -234,6 +250,9 @@ function rehydrateStore() {
|
||||
$state.snapshot(store.activeFilters),
|
||||
);
|
||||
}
|
||||
// After restoring activeSort and activeFilter, set
|
||||
// an initial value for our related query param state.
|
||||
updateSortAndFiltersForQueryParams();
|
||||
// Restore appTheme
|
||||
const theme = localStorage.getItem("theme") as Theme;
|
||||
if (theme) {
|
||||
|
||||
+60
-45
@@ -66,14 +66,6 @@ export type WLDetailedViewOption =
|
||||
| "lastWatched"
|
||||
| "dateAdded"
|
||||
| "dateModified";
|
||||
export type PosterExtraDetails = {
|
||||
dateAdded?: string;
|
||||
dateModified?: string;
|
||||
/**
|
||||
* Only for shows.
|
||||
*/
|
||||
lastWatched?: string;
|
||||
};
|
||||
|
||||
export enum UserType {
|
||||
Watcharr = 0,
|
||||
@@ -89,6 +81,14 @@ interface dbModel {
|
||||
deletedAt: string;
|
||||
}
|
||||
|
||||
export interface PaginationResponse<T> {
|
||||
limit: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalResults: number;
|
||||
results: T[];
|
||||
}
|
||||
|
||||
export interface Content {
|
||||
// id: number; // Not used
|
||||
tmdbId: number;
|
||||
@@ -179,6 +179,14 @@ export interface WatchedEpisodeAddResponse {
|
||||
episodeStatusChangedHookResponse?: EpisodeStatusChangedHookResponse;
|
||||
}
|
||||
|
||||
export interface WatchedAddedToContent {
|
||||
watched?: Watched;
|
||||
failedToGetWatched: boolean;
|
||||
}
|
||||
|
||||
export type TMDBShowDetailsWithWatched = WatchedAddedToContent &
|
||||
TMDBShowDetails;
|
||||
|
||||
export interface EpisodeStatusChangedHookResponse {
|
||||
newShowStatus?: WatchedStatus;
|
||||
watchedSeason?: WatchedSeason;
|
||||
@@ -498,50 +506,52 @@ export interface TMDBContentCreditsCrew {
|
||||
job: string;
|
||||
}
|
||||
|
||||
export interface TMDBShowSimilar {
|
||||
// TODO use this type everywhere needed to simplify
|
||||
export interface TMDBPaginatedResponse<T> {
|
||||
page: number;
|
||||
results: {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
origin_country: string[];
|
||||
original_language: string;
|
||||
original_name: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string;
|
||||
first_air_date: string;
|
||||
name: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
results: T[];
|
||||
}
|
||||
|
||||
export interface TMDBMovieSimilar {
|
||||
page: number;
|
||||
results: {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string;
|
||||
release_date: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
export interface TMDBShowSimilarResult extends WatchedAddedToContent {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
origin_country: string[];
|
||||
original_language: string;
|
||||
original_name: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string;
|
||||
first_air_date: string;
|
||||
name: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export type TMDBShowSimilar = TMDBPaginatedResponse<TMDBShowSimilarResult>;
|
||||
|
||||
export interface TMDBMovieSimilarResult extends WatchedAddedToContent {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string;
|
||||
release_date: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export type TMDBMovieSimilar = TMDBPaginatedResponse<TMDBMovieSimilarResult>;
|
||||
|
||||
export interface TMDBPersonDetails {
|
||||
birthday?: string;
|
||||
known_for_department?: string;
|
||||
@@ -763,6 +773,7 @@ export interface ContentSearchMovie {
|
||||
vote_count?: number;
|
||||
video?: boolean;
|
||||
vote_average?: number;
|
||||
watched?: Watched;
|
||||
}
|
||||
|
||||
export interface ContentSearchTv {
|
||||
@@ -780,6 +791,7 @@ export interface ContentSearchTv {
|
||||
vote_count?: number;
|
||||
name?: string;
|
||||
original_name?: string;
|
||||
watched?: Watched;
|
||||
}
|
||||
|
||||
export interface ContentSearchPerson {
|
||||
@@ -810,6 +822,7 @@ export interface MoviesSearchResponse {
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
media_type: "movie";
|
||||
watched?: Watched;
|
||||
}[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
@@ -833,6 +846,7 @@ export interface ShowsSearchResponse {
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
media_type: "tv";
|
||||
watched?: Watched;
|
||||
}[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
@@ -882,6 +896,7 @@ export interface GameSearch {
|
||||
name: string;
|
||||
summary?: string;
|
||||
version_title?: string;
|
||||
// watched?: Watched; TODO
|
||||
}
|
||||
|
||||
export enum ImportResponseType {
|
||||
|
||||
Reference in New Issue
Block a user