Compare commits

..

24 Commits

Author SHA1 Message Date
IRHM 7e6ca542ec FilterMenu: Fix type btn overflow and style to be pills 2025-05-17 17:33:57 +01:00
IRHM a60710e384 wip: Watched sorting, fixed watcheds loader 2025-05-17 17:32:56 +01:00
IRHM fbf3002fa1 fix search person (use PaginatedRequest) 2025-04-23 22:28:12 +01:00
IRHM fce668ef97 add watched to discover data and fix search pagination 2025-04-23 22:22:46 +01:00
IRHM 939ff6cdaf getWatchedItemsByTmdbIds: Update log to comment out all watcheds 2025-04-20 22:36:15 +01:00
IRHM 6a716f7507 getWatched: return err instead of panicking 2025-04-20 22:35:32 +01:00
IRHM 8f80153d22 Support poster adding/removing watched, and make watched prop bindable to update parent data too 2025-04-20 13:24:37 +01:00
IRHM d3d476cc3f fix get watched caching and move addWatched to content stuff to functions 2025-04-20 13:23:40 +01:00
IRHM 2f78fdd0a5 removeWatched: Make notify call success when successfully removed 2025-04-18 21:17:09 +01:00
IRHM 79f1d1b242 Update WatchedList.svelte 2025-04-11 21:05:45 +01:00
IRHM b946dfd3c9 forgot what i did but progressed 2025-04-11 21:05:09 +01:00
IRHM 69c656bc97 infScroll: Add callback isloading requirement for implementing in comment 2025-04-03 22:35:33 +01:00
IRHM 10bf5324b2 infScroll: Create dataLoaded func for ensuring full page of data is present after page loads 2025-04-03 22:29:54 +01:00
IRHM fcdf752c6c Create go-cache GetCache helper func
Easier to use this method so we can simply pass a pointer of our var in and let it do everything for us, relieves us of our manual type assertion duty everywhere we get from cache.
2025-04-03 22:28:42 +01:00
IRHM df425b9587 tv: remove old comment 2025-03-26 23:38:29 +00:00
IRHM 78eee0e27e wip: getting show watched data in request 2025-03-26 23:36:47 +00:00
IRHM 0ea1fea88a wip: content: move watched entry to inside content object
instead of separating both, should be easier to work with this way and enable it to work for search results without complex mapping etc to find related watched entries
2025-02-18 22:08:59 +00:00
IRHM 18c0882650 wip: content: returning watched along with tv response 2025-02-18 21:40:24 +00:00
IRHM b70ae5d3c7 main page: show spinner while loading next list page 2025-02-16 14:23:30 +00:00
IRHM 01e175ee9f watched list pagination in ui 2025-02-16 14:23:30 +00:00
IRHM 409b2c9d41 search: Use infScroll 2025-02-16 14:23:30 +00:00
IRHM 902a444704 Create infScroll helper with reusable logic
based on the infinite scroll code from search page
2025-02-16 14:23:30 +00:00
IRHM 2c0983724a finalize PaginationResponse, get count in watchedpage 2025-02-16 14:23:30 +00:00
IRHM bf0a1eaa14 watched: Create getWatchedPage for paginated response 2025-02-16 14:23:30 +00:00
70 changed files with 1780 additions and 1181 deletions
-3
View File
@@ -1,3 +0,0 @@
# Docker dev volume
container_data/
data/
+1 -1
View File
@@ -54,7 +54,7 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
# Set Up
[Checkout our documentation](https://watcharr.app/docs/category/installation) for an up to date guide on setup! If you hate manuals, but love docker, this [compose.yml](./compose.yml) file is your friend.
[Checkout our documentation](https://watcharr.app/docs/category/installation) for an up to date guide on setup! If you hate manuals, but love docker, this [docker-compose.yml](./docker-compose.yml) file is your friend.
# Community Made Tools
-24
View File
@@ -1,24 +0,0 @@
# This compose file will replace watcharrs
# built in UI with a custom one provided in
# a named volume (for use with ui-build image).
services:
watcharr:
# We dont need to build here too for testing if
# a custom ui works, so using latest prod image.
image: ghcr.io/sbondco/watcharr:latest
container_name: watcharr
ports:
- 3080:3080
volumes:
- ./container_data:/data
- type: volume
source: watcharr-ui
target: /ui
volume:
nocopy: true
subpath: build
volumes:
watcharr-ui:
external: true
@@ -1,104 +0,0 @@
---
sidebar_position: 2
description: Install and setup with Docker Compose for access through a subpath.
---
# Docker Compose (subpath)
Install and setup with Docker Compose for access through a subpath.
:::info Not a great experience at the moment!
Currently, hosting Watcharr via a subpath on your server is not very easy (unlike hosting under a subdomain). Hopefully this will change in the future, but as a temporary measure to at least allow hosting under a subpath, this method has been provided.
The following issue will continue to track this: https://github.com/sbondCo/Watcharr/issues/312
:::
## Installing
### Build UI
First we have to build the frontend with our subpath provided as an environment variable.
If you don't want to use `/watcharr` as your subpath, replace the value of `WATCHARR_BASE` before running the command.
```bash
docker run -e WATCHARR_BASE=/watcharr -v watcharr-ui:/ui --rm ghcr.io/sbondco/watcharr-ui-build:latest
```
Once the container has finished running the build script, it will exit and remove itself. The `watcharr-ui` volume will contain the built files.
### Install Watcharr
Now we can install Watcharr. You can copy the example below to get started:
```yaml title="compose.yml"
services:
watcharr:
# The :latest tag is used for simplicity, it is recommended
# to use an actual version, then when updating check the releases for changelogs.
image: ghcr.io/sbondco/watcharr:latest
container_name: watcharr
ports:
- 3080:3080
volumes:
# Contains all of watcharr data (database & cache)
- ./data:/data
# Use our volume containing built ui files
# instead of default ui included in image.
- type: volume
source: watcharr-ui
target: /ui
volume:
nocopy: true
subpath: build
restart: unless-stopped
volumes:
watcharr-ui:
external: true
```
:::danger first account
When **first** running Watcharr, make sure only you have access. The first user created will become admin.
:::
You can now start `Watcharr` like so:
```bash
docker compose up -d
```
If you didn't change the ports in the example, the server will be available at [http://localhost:3080/](http://localhost:3080/).
## Updating
:::danger Take care
We try taking care as to not release breaking changes, however it is still recommended that
you lookover changelogs before updating!
Breaking changes are marked at the top of releases: https://github.com/sbondCo/Watcharr/releases
:::
1. Update your built ui files by following the [Build UI](#build-ui) step again.
2. Update the `image` version in your `compose.yml` file.
Skip this step if you are using the `latest` tag.
```yaml
# eg. update v1.19.0 to v1.20.0 (or whatever version you are updating to)
image: ghcr.io/sbondco/watcharr:v1.19.0
```
3. Pull the new changes and re-create your container:
```bash
docker compose pull && docker compose down && docker compose up -d
```
And that is it!
+2 -2
View File
@@ -9,7 +9,7 @@ description: Install and setup with Docker Compose.
Installing Watcharr with a docker compose file is easy. You can copy the example below to get started:
```yaml title="compose.yml"
```yaml title="docker-compose.yml"
services:
watcharr:
# The :latest tag is used for simplicity, it is recommended
@@ -51,7 +51,7 @@ Breaking changes are marked at the top of releases: https://github.com/sbondCo/W
Updating your server can be done in two steps:
1. Update the `image` version in your `compose.yml` file.
1. Update the `image` version in your `docker-compose.yml` file.
Skip this step if you are using the `latest` tag.
```yaml
+3
View File
@@ -80,6 +80,9 @@ const config = {
position: "left",
label: "Docs",
},
{
type: "docsVersionDropdown",
},
{
href: "https://beta.watcharr.app",
label: "Demo",
View File
-3
View File
@@ -1,3 +0,0 @@
This folder is for any _extra_ images that we produce.
The main watcharr Dockerfile remains in project root.
-16
View File
@@ -1,16 +0,0 @@
#
# Frontend
#
FROM node:20-alpine AS ui
COPY ./docker/ui-build/entrypoint.sh /entrypoint.sh
RUN ["chmod", "+x", "/entrypoint.sh"]
WORKDIR /app
COPY package*.json vite.config.ts svelte.config.js tsconfig.json ./
COPY ./src ./src
COPY ./static ./static
VOLUME /ui
ENTRYPOINT ["/entrypoint.sh"]
-7
View File
@@ -1,7 +0,0 @@
Image for building watcharr ui.
Accepts environment variables:
- `WATCHARR_BASE`
https://watcharr.app/docs/installation/docker-compose-subpath
-15
View File
@@ -1,15 +0,0 @@
# To test prod
services:
watcharr-ui-build:
build:
context: ../../
dockerfile: ./docker/ui-build/Dockerfile
container_name: watcharr-ui-build
volumes:
- watcharr-ui:/ui
environment:
WATCHARR_BASE: "/sp"
volumes:
watcharr-ui:
name: "watcharr-ui"
-25
View File
@@ -1,25 +0,0 @@
#!/bin/sh
# This is loosely based on the main containers Dockerfile commands.
# Just everything needed to get ui built and ready and nothing else.
set -x
echo "wuib: Starting build"
# Install all deps
/usr/local/bin/npm install
# Run build
/usr/local/bin/npm run build
# Remove any existing build files from volume
rm -rf /ui/build
# Move build folder to /ui
mv /app/build /ui
# Move package.json and lock file to /ui/build for final npm ci call
mv /app/package.json /app/package-lock.json /ui/build
# cd to build files final destination and install dependencies for production
cd /ui/build && /usr/local/bin/npm ci --omit=dev --ignore-scripts=true
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "watcharr",
"version": "2.0.2",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watcharr",
"version": "2.0.2",
"version": "2.0.1",
"dependencies": {
"axios": "^1.7.4",
"blurhash": "^2.0.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "watcharr",
"version": "2.0.2",
"version": "2.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
+64
View File
@@ -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
View File
@@ -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
}
+313
View File
@@ -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
}
+50
View File
@@ -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()
}
}
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+10
View File
@@ -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
View File
@@ -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)
+53
View File
@@ -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
View File
@@ -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;",
);
};
-48
View File
@@ -645,54 +645,6 @@
d="M368 192h-16v-80a96 96 0 10-192 0v80h-16a64.07 64.07 0 00-64 64v176a64.07 64.07 0 0064 64h224a64.07 64.07 0 0064-64V256a64.07 64.07 0 00-64-64zm-48 0H192v-80a64 64 0 11128 0z"
/>
</svg>
{:else if i === "github"}
<svg
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
viewBox="0 0 24 25"
>
<path
fill="currentColor"
d="M12.301 0h.093c2.242 0 4.34.613 6.137 1.68l-.055-.031a12.35 12.35 0 0 1 4.449 4.422l.031.058a12.2 12.2 0 0 1 1.654 6.166c0 5.406-3.483 10-8.327 11.658l-.087.026a.72.72 0 0 1-.642-.113l.002.001a.62.62 0 0 1-.208-.466v-.014v.001l.008-1.226q.008-1.178.008-2.154a2.84 2.84 0 0 0-.833-2.274a11 11 0 0 0 1.718-.305l-.076.017a6.5 6.5 0 0 0 1.537-.642l-.031.017a4.5 4.5 0 0 0 1.292-1.058l.006-.007a4.9 4.9 0 0 0 .84-1.645l.009-.035a7.9 7.9 0 0 0 .329-2.281l-.001-.136v.007l.001-.072a4.73 4.73 0 0 0-1.269-3.23l.003.003c.168-.44.265-.948.265-1.479a4.25 4.25 0 0 0-.404-1.814l.011.026a2.1 2.1 0 0 0-1.31.181l.012-.005a8.6 8.6 0 0 0-1.512.726l.038-.022l-.609.384c-.922-.264-1.981-.416-3.075-.416s-2.153.152-3.157.436l.081-.02q-.256-.176-.681-.433a9 9 0 0 0-1.272-.595l-.066-.022A2.17 2.17 0 0 0 5.837 5.1l.013-.002a4.2 4.2 0 0 0-.393 1.788c0 .531.097 1.04.275 1.509l-.01-.029a4.72 4.72 0 0 0-1.265 3.303v-.004l-.001.13c0 .809.12 1.591.344 2.327l-.015-.057c.189.643.476 1.202.85 1.693l-.009-.013a4.4 4.4 0 0 0 1.267 1.062l.022.011c.432.252.933.465 1.46.614l.046.011c.466.125 1.024.227 1.595.284l.046.004c-.431.428-.718 1-.784 1.638l-.001.012a3 3 0 0 1-.699.236l-.021.004c-.256.051-.549.08-.85.08h-.066h.003a1.9 1.9 0 0 1-1.055-.348l.006.004a2.84 2.84 0 0 1-.881-.986l-.007-.015a2.6 2.6 0 0 0-.768-.827l-.009-.006a2.3 2.3 0 0 0-.776-.38l-.016-.004l-.32-.048a1.05 1.05 0 0 0-.471.074l.007-.003q-.128.072-.08.184q.058.128.145.225l-.001-.001q.092.108.205.19l.003.002l.112.08c.283.148.516.354.693.603l.004.006c.191.237.359.505.494.792l.01.024l.16.368c.135.402.38.738.7.981l.005.004c.3.234.662.402 1.057.478l.016.002c.33.064.714.104 1.106.112h.007q.069.002.15.002q.392 0 .767-.062l-.027.004l.368-.064q0 .609.008 1.418t.008.873v.014c0 .185-.08.351-.208.466h-.001a.72.72 0 0 1-.645.111l.005.001C3.486 22.286.006 17.692.006 12.285c0-2.268.612-4.393 1.681-6.219l-.032.058a12.35 12.35 0 0 1 4.422-4.449l.058-.031a11.9 11.9 0 0 1 6.073-1.645h.098h-.005zm-7.64 17.666q.048-.112-.112-.192q-.16-.048-.208.032q-.048.112.112.192q.144.096.208-.032m.497.545q.112-.08-.032-.256q-.16-.144-.256-.048q-.112.08.032.256q.159.157.256.047zm.48.72q.144-.112 0-.304q-.128-.208-.272-.096q-.144.08 0 .288t.272.112m.672.673q.128-.128-.064-.304q-.192-.192-.32-.048q-.144.128.064.304q.192.192.32.044zm.913.4q.048-.176-.208-.256q-.24-.064-.304.112t.208.24q.24.097.304-.096m1.009.08q0-.208-.272-.176q-.256 0-.256.176q0 .208.272.176q.256.001.256-.175zm.929-.16q-.032-.176-.288-.144q-.256.048-.224.24t.288.128t.225-.224z"
/>
</svg>
{:else if i === "website"}
<svg
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
viewBox="0 0 2048 2048"
>
<path
fill="currentColor"
d="M1024 0q141 0 272 36t245 103t207 160t160 208t103 245t37 272q0 141-36 272t-103 245t-160 207t-208 160t-245 103t-272 37q-141 0-272-36t-245-103t-207-160t-160-208t-103-244t-37-273q0-141 36-272t103-245t160-207t208-160T751 37t273-37m0 1920q123 0 237-32t214-90t182-141t140-181t91-214t32-238q0-123-32-237t-90-214t-141-182t-181-140t-214-91t-238-32q-123 0-237 32t-214 90t-182 141t-140 181t-91 214t-32 238q0 123 32 237t90 214t141 182t181 140t214 91t238 32m597-880l48-144h75l-85 256h-75l-48-144l-48 144h-75l-85-256h75l48 144l48-144h74zm-464-144h75l-85 256h-75l-48-144l-48 144h-75l-85-256h75l48 144l48-144h74l48 144zm-512 0h75l-85 256h-75l-48-144l-48 144h-75l-85-256h75l48 144l48-144h74l48 144z"
/>
</svg>
{:else if i === "tmdb"}
<svg
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
viewBox="0 0 185.04 133.4"
>
<path
fill="currentColor"
d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"
/>
</svg>
{:else if i === "igdb"}
<svg
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M24 6.228H0v11.543a89 89 0 0 1 2.271-.333a74 74 0 0 1 17.038-.28c1.57.153 3.134.363 4.69.614zm-.706.707v10.013a74.8 74.8 0 0 0-22.588 0V6.934zM7.729 8.84a2.62 2.62 0 0 0-1.857.72a2.55 2.55 0 0 0-.73 1.33c-.098.5-.063 1.03.112 1.51c.177.488.515.917.954 1.196c.547.354 1.224.472 1.865.401a3.24 3.24 0 0 0 1.786-.777c-.003-.724.002-1.449-.002-2.173c-.725.004-1.45-.002-2.174.003c.003.317 0 .634.001.951h1.105c.002.236 0 .473.002.71a1.7 1.7 0 0 1-.932.298c-.32.02-.65-.05-.922-.225a1.46 1.46 0 0 1-.59-.744c-.18-.499-.134-1.085.163-1.53c.23-.355.619-.61 1.043-.647a1.8 1.8 0 0 1 1.012.206c.152.082.286.192.424.295q.344-.42.692-.838a3 3 0 0 0-.595-.403c-.418-.212-.892-.285-1.357-.283m11.66.086q-.14.002-.28 0c-.68.002-1.359-.004-2.038.003c.003 1.666 0 3.332.002 4.998h2.497q.36-.003.709-.097c.276-.076.546-.208.742-.422c.194-.208.297-.492.304-.776c.016-.278-.032-.572-.195-.804c-.175-.252-.453-.408-.734-.514c.211-.122.407-.285.521-.505c.134-.246.149-.535.117-.807a1.16 1.16 0 0 0-.436-.73c-.264-.207-.599-.304-.93-.334a3 3 0 0 0-.279-.012m-16.715 0v5.002h1.102V8.927q-.552-.002-1.102 0zm8.524 0v5.002h2.016a2.9 2.9 0 0 0 1.07-.211a2.44 2.44 0 0 0 1.174-.993c.34-.555.429-1.244.292-1.876a2.37 2.37 0 0 0-.828-1.338c-.478-.387-1.096-.577-1.707-.584zm6.949.967c.392.002.784-.001 1.176.002c.183.011.38.054.51.19c.11.112.136.28.112.43a.44.44 0 0 1-.22.316a1.1 1.1 0 0 1-.483.116c-.365.002-.73-.001-1.094.001l-.001-1.054zm-5.031.026c.28 0 .567.053.815.19c.274.149.491.396.607.685c.113.272.138.574.107.865a1.46 1.46 0 0 1-.335.786a1.43 1.43 0 0 1-.865.466c-.168.031-.34.022-.51.023h-.632V9.92zm5.03 1.948h1.36c.174.006.354.035.505.127a.45.45 0 0 1 .212.308c.025.15.004.32-.099.44c-.102.12-.258.176-.409.2c-.172.032-.348.02-.522.022c-.35-.001-.698.002-1.047-.001z"
/>
</svg>
{/if}
<style lang="scss">
+1 -22
View File
@@ -100,31 +100,10 @@
}
}
a.menu-footer,
button.menu-footer {
font-size: 11px;
padding: 2px;
text-align: center;
transition: inherit;
color: $text-color-accent;
cursor: pointer;
border: none;
display: inline;
font-weight: initial;
width: auto;
&:hover,
&:focus-visible {
text-decoration: underline;
background-color: $bg-color;
color: $text-color;
}
}
span {
margin-top: 8px;
font-size: 11px;
color: $text-color-accent;
color: gray;
text-align: center;
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import type { PublicUser } from "@/types";
import { toBaseUrl } from "./util/url";
interface Props {
users: PublicUser[];
@@ -14,7 +13,7 @@
<ul>
{#each users as user}
<li title={user.username}>
<a href={toBaseUrl(`/lists/${user.id}/${user.username}`)}>
<a href="/lists/{user.id}/{user.username}">
<span>{user.username}</span>
</a>
</li>
+18 -64
View File
@@ -6,41 +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 { toBaseUrl } from "./util/url";
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;
@@ -82,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) =>
@@ -114,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")),
@@ -140,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);
@@ -218,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}
@@ -249,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,
@@ -259,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,
@@ -297,13 +245,19 @@
<h4 class="norm">
Try searching for something you would like to add.
</h4>
<button onclick={() => goto(toBaseUrl("/import"))}>Import</button>
<button onclick={() => goto("/import")}>Import</button>
{/if}
{/if}
</div>
{/if}
</PosterList>
{#if isLoading}
<div style="margin-bottom: 60px;">
<Spinner />
</div>
{/if}
<style lang="scss">
.empty-list {
display: flex;
+1 -1
View File
@@ -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();
+2 -4
View File
@@ -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>
+1 -2
View File
@@ -6,7 +6,6 @@
import Spinner from "../Spinner.svelte";
import { clearWatcharrData } from ".";
import { goto } from "$app/navigation";
import { toBaseUrl } from "../util/url";
interface Props {
onClose: () => void;
@@ -35,7 +34,7 @@
function logout() {
clearWatcharrData();
goto(toBaseUrl("/login?noAuto=1"));
goto("/login?noAuto=1");
}
function proxyLogout() {
-101
View File
@@ -1,101 +0,0 @@
<script lang="ts">
import Icon from "../Icon.svelte";
import Modal from "../Modal.svelte";
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
</script>
<Modal title="About" {onClose} maxWidth="400px">
<div class="content">
<div class="inner">
<p>
An open source project that helps you keep the content you are watching
or playing organized and places you in control.
</p>
<div class="horizontal-icon-list">
<a
href="https://github.com/sbondCo/Watcharr"
target="_blank"
title="Github"
>
<Icon i="github" wh={60} />
Source<br />
Code
</a>
<a
href="https://watcharr.app/"
target="_blank"
title="Watcharr Website & Documentation"
>
<Icon i="website" wh={60} />
Website<br />
& Docs
</a>
</div>
<h5 class="norm">Watcharr uses the following media databases:</h5>
<div class="horizontal-icon-list">
<a href="https://www.themoviedb.org/" target="_blank" title="TMDB">
<Icon i="tmdb" wh={60} />
</a>
<a href="https://www.igdb.com/" target="_blank" title="IGDB">
<Icon i="igdb" wh={60} />
</a>
</div>
<h5 class="norm">
<!-- Linking to the file in `dev` branch would break every old version of
Watcharr if the file were ever moved in the future. Using the current version
as the branch should ensure this file is always accessible (but only as up to
date as the current version) -->
<a
href="https://github.com/sbondCo/Watcharr/blob/v{__WATCHARR_VERSION__}/ATTRIBUTION.md"
target="_blank"
>
See our attribution file.
</a>
</h5>
</div>
</div>
</Modal>
<style lang="scss">
p {
margin: 8px 0px;
}
h5.norm {
margin: 8px 0px;
> a {
text-decoration: underline;
}
}
div.horizontal-icon-list {
display: flex;
justify-content: center;
align-items: center;
padding-bottom: 20px;
padding-top: 5px;
> a {
display: flex;
flex-flow: column;
align-items: center;
gap: 5px;
margin: 0 10px;
text-align: center;
}
}
a {
transition: opacity 150ms ease-in;
&:hover {
opacity: 0.5;
}
}
</style>
-1
View File
@@ -9,7 +9,6 @@
store.wlDetailedView = store.wlDetailedView.filter((a) => a !== d);
} else {
store.wlDetailedView.push(d);
store.wlDetailedView = store.wlDetailedView;
}
}
</script>
+7 -37
View File
@@ -7,12 +7,9 @@
import { goto } from "$app/navigation";
import { clearWatcharrData } from "../logout";
import { notify } from "../util/notify";
import AboutModal from "./AboutModal.svelte";
import { toBaseUrl } from "../util/url";
let user = $derived(store.userInfo);
let proxyUserLogoutShown = $state(false);
let aboutModalOpen = $state(false);
function logout() {
if (user?.type === UserType.Proxy) {
@@ -21,23 +18,23 @@
return;
}
clearWatcharrData();
goto(toBaseUrl("/login"));
goto("/login");
}
function profile() {
goto(toBaseUrl("/profile"));
goto("/profile");
}
function serverSettings() {
goto(toBaseUrl("/server"));
goto("/server");
}
function userManagement() {
goto(toBaseUrl("/manage_users"));
goto("/manage_users");
}
function requestManagement() {
goto(toBaseUrl("/arr_requests"));
goto("/arr_requests");
}
function shareWatchedList() {
@@ -45,9 +42,7 @@
const ud = parseTokenPayload();
console.log(ud);
if (ud?.userId && ud?.username) {
const shareLink = `${window.location.origin}${toBaseUrl(
`/lists/${ud.userId}/${ud.username}`,
)}`;
const shareLink = `${window.location.origin}/lists/${ud.userId}/${ud.username}`;
navigator.clipboard
.writeText(shareLink)
.then(() => {
@@ -66,10 +61,6 @@
notify({ id: nid, type: "error", text: "Failed to get link" });
}
}
function closeAbout() {
aboutModalOpen = false;
}
</script>
<Menu conf={{ arrowRight: "10px" }}>
@@ -94,26 +85,5 @@
{#if proxyUserLogoutShown}
<ProxyUserLogoutModal onClose={() => (proxyUserLogoutShown = false)} />
{/if}
<span>
<button
class="menu-footer"
onclick={() => {
aboutModalOpen = !aboutModalOpen;
}}
>
about
</button>
|
<a
class="menu-footer"
href="https://github.com/sbondCo/Watcharr/releases"
target="_blank"
>
v{__WATCHARR_VERSION__}
</a>
</span>
<span>v{__WATCHARR_VERSION__}</span>
</Menu>
{#if aboutModalOpen}
<AboutModal onClose={closeAbout} />
{/if}
+9 -17
View File
@@ -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 -4
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { store } from "@/store.svelte";
import Menu from "../Menu.svelte";
import { toBaseUrl } from "../util/url";
interface Props {
close: () => {};
@@ -16,9 +15,7 @@
<div class="list">
{#each store.follows as f}
<a
href={toBaseUrl(
`/lists/${f.followedUser.id}/${f.followedUser.username}`,
)}
href="/lists/{f.followedUser.id}/{f.followedUser.username}"
onclick={() => close()}
>
{f.followedUser.username}
+16 -19
View File
@@ -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 -2
View File
@@ -18,7 +18,6 @@
import PosterRating from "./PosterRating.svelte";
import { decode } from "blurhash";
import ExtraDetails from "./ExtraDetails.svelte";
import { toBaseUrl } from "../util/url";
interface Props {
id?: number | undefined; // Watched list id
@@ -73,7 +72,7 @@
const poster = media.poster?.path
? `${baseURL}/${media.poster.path}`
: `https://images.igdb.com/igdb/image/upload/t_cover_big/${media.coverId}.jpg`;
const link = media.id ? toBaseUrl(`/game/${media.id}`) : undefined;
const link = media.id ? `/game/${media.id}` : undefined;
const dateStr = media.firstReleaseDate;
const year = dateStr ? new Date(dateStr).getFullYear() : undefined;
+1 -2
View File
@@ -4,7 +4,6 @@
addClassToParent,
calculateTransformOrigin,
} from "@/lib/util/helpers";
import { toBaseUrl } from "../util/url";
interface Props {
id: number | undefined;
@@ -25,7 +24,7 @@
const poster = path
? `https://image.tmdb.org/t/p/w300_and_h450_bestv2${path}`
: undefined;
const link = id ? toBaseUrl(`/person/${id}`) : undefined;
const link = id ? `/person/${id}` : undefined;
</script>
<!-- Quick fix to ignore error, should be fixed -->
+55 -26
View File
@@ -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,26 +13,27 @@
import PosterStatus from "./PosterStatus.svelte";
import PosterRating from "./PosterRating.svelte";
import ExtraDetails from "./ExtraDetails.svelte";
import { toBaseUrl } from "../util/url";
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;
/**
@@ -53,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,
@@ -80,44 +82,62 @@
// 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}`,
);
let link = $derived(
media.id ? toBaseUrl(`/${media.media_type}/${media.id}`) : undefined,
media.id ? `/${media.media_type}/${media.id}` : undefined,
);
let dateStr = $derived(media.release_date || media.first_air_date);
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;
});
}
@@ -153,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();
@@ -213,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" : ""}`}
@@ -233,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) => {
@@ -265,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>
+29
View File
@@ -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;
}
+1 -1
View File
@@ -6,7 +6,7 @@
interface Props {
rating: number | undefined;
onChange: (newRating: number) => Promise<boolean>;
onChange: (newRating: number) => Promise<void>;
}
let { rating, onChange }: Props = $props();
+1 -1
View File
@@ -9,7 +9,7 @@
interface Props {
rating: number | undefined;
onChange: (newRating: number) => Promise<boolean>;
onChange: (newRating: number) => Promise<void>;
}
let { rating, onChange }: Props = $props();
+1 -1
View File
@@ -6,7 +6,7 @@
interface Props {
rating: number | undefined;
onChange: (newRating: number) => Promise<boolean>;
onChange: (newRating: number) => Promise<void>;
}
let { rating, onChange }: Props = $props();
+100 -88
View File
@@ -12,11 +12,11 @@ import {
type Follow,
type PlayedAddRequest,
type ActivityUpdateRequest,
type WatchedAddedToContent,
} from "@/types";
import axios from "axios";
import { notify, unNotify } from "./notify";
import { browser } from "$app/environment";
import { toBaseUrl } from "./url";
const { MODE } = import.meta.env;
export const baseURL =
@@ -24,12 +24,24 @@ export const baseURL =
? browser
? `${location.protocol}//${location.hostname}:3080/api`
: "http://127.0.0.1:3080/api"
: toBaseUrl("/api");
: "/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,
@@ -37,7 +49,7 @@ async function _updateWatched(
rating?: number,
thoughts?: string,
pinned?: boolean,
): Promise<boolean> {
) {
if (
!status &&
!rating &&
@@ -47,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
View File
@@ -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.
+111
View File
@@ -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();
},
};
}
-10
View File
@@ -1,10 +0,0 @@
import { base } from "$app/paths";
/**
* Takes in a `path` and prefixes it with `base`.
* This enables support for using base paths to access
* the frontend by fixing all absolute paths passed through.
*/
export function toBaseUrl(path: string) {
return base + path;
}
+4
View File
@@ -121,6 +121,10 @@
fill: $bg-color;
opacity: 1;
}
&.active {
border-color: $bg-color;
}
}
}
+7 -13
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { afterNavigate, goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import Icon from "@/lib/Icon.svelte";
import PageError from "@/lib/PageError.svelte";
@@ -13,7 +12,6 @@
import SortMenu from "@/lib/nav/SortMenu.svelte";
import TagMenu from "@/lib/tag/TagMenu.svelte";
import { isTouch } from "@/lib/util/helpers";
import { toBaseUrl } from "@/lib/util/url";
import { store, defaultSort } from "@/store.svelte";
import axios from "axios";
import { onMount } from "svelte";
@@ -36,7 +34,7 @@
function handleProfileClick() {
if (!localStorage.getItem("token")) {
goto(toBaseUrl("/login"));
goto("/login");
} else {
closeAllSubMenus("sub");
subMenuShown = !subMenuShown;
@@ -66,7 +64,7 @@
// Using autofocus seems to work. Disables after goto runs.
// https://github.com/sbondCo/Watcharr/issues/169
target.autofocus = true;
goto(toBaseUrl(`/search?q=${encodeURIComponent(query)}`)).then(() => {
goto(`/search?q=${encodeURIComponent(query)}`).then(() => {
// Use mainSearchEl if nav not split, otherwise use ev target.
if (
!document.body.classList.contains("split-nav") &&
@@ -87,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;
}
@@ -114,7 +108,7 @@
store.tags = ts.data;
}
} else {
goto(toBaseUrl("/login?again=1"));
goto("/login?again=1");
}
}
@@ -189,7 +183,7 @@
<nav bind:this={navEl}>
<div class="wrapper">
<a href={toBaseUrl("/")}>
<a href="/">
<span class="large">Watcharr</span>
<span class="small">W</span>
</a>
@@ -280,7 +274,7 @@
{#if tagMenuShown}
<TagMenu
onTagClick={(tag) => {
goto(toBaseUrl(`/tag/${tag.id}`));
goto(`/tag/${tag.id}`);
tagMenuShown = false;
}}
showManageBtn={true}
@@ -288,7 +282,7 @@
{/if}
<button
class="plain other discover"
onclick={() => goto(toBaseUrl("/discover"))}
onclick={() => goto("/discover")}
use:tooltip={{ text: "Discover", pos: "bot" }}
>
<Icon i="compass" wh={26} />
+2 -3
View File
@@ -7,7 +7,6 @@ import axios from "axios";
import { baseURL } from "@/lib/util/api";
import { notify } from "@/lib/util/notify";
import { clearWatcharrData } from "@/lib/logout";
import { toBaseUrl } from "@/lib/util/url";
axios.interceptors.request.use(
(config) => {
@@ -19,7 +18,7 @@ axios.interceptors.request.use(
// Don't require token check if going to auth route (login/register)
if (!token && !config.url?.includes("/auth")) {
console.error("No token, going to login. Endpoint:", config.url);
goto(toBaseUrl("/login?again=1"));
goto("/login?again=1");
throw new axios.Cancel("No auth token found");
}
config.headers.set("Authorization", token);
@@ -41,7 +40,7 @@ axios.interceptors.response.use(
console.error("Recieved 401 response, going to login.");
notify({ text: "Request Authorization Failed!", type: "error" });
clearWatcharrData();
goto(toBaseUrl("/login?again=1"));
goto("/login?again=1");
}
return Promise.reject(error);
},
+132 -1
View File
@@ -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 -2
View File
@@ -6,7 +6,6 @@
import { baseURL } from "@/lib/util/api";
import { toRelativeDate } from "@/lib/util/helpers";
import { notify } from "@/lib/util/notify";
import { toBaseUrl } from "@/lib/util/url";
import {
type ArrRequestResponse,
type TMDBMovieDetails,
@@ -95,7 +94,7 @@
<h2 class="norm">
<a
data-sveltekit-preload-data="tap"
href={toBaseUrl(`/${r.content.type}/${r.content.tmdbId}`)}
href={`/${r.content.type}/${r.content.tmdbId}`}
class="plain"
>
{r.content.title}
+7 -11
View File
@@ -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>
+8 -9
View File
@@ -26,7 +26,6 @@
TodoMoviesMovie,
} from "@/types";
import Icon from "@/lib/Icon.svelte";
import { toBaseUrl } from "@/lib/util/url";
let isDragOver = $state(false);
let isLoading = $state(false);
@@ -84,7 +83,7 @@
data: r.result.toString(),
type,
};
goto(toBaseUrl("/import/process"));
goto("/import/process");
}
},
false,
@@ -256,7 +255,7 @@
data: JSON.stringify(toImport),
type: "movary",
};
goto(toBaseUrl("/import/process"));
goto("/import/process");
} catch (err) {
isLoading = false;
notify({ type: "error", text: "Failed to read files!" });
@@ -332,7 +331,7 @@
data: JSON.stringify(toImport),
type: "watcharr",
};
goto(toBaseUrl("/import/process"));
goto("/import/process");
} catch (err) {
isLoading = false;
notify({ type: "error", text: "Failed to read file!" });
@@ -381,7 +380,7 @@
data: r.result.toString(),
type: "myanimelist",
};
goto(toBaseUrl("/import/process"));
goto("/import/process");
}
},
false,
@@ -540,7 +539,7 @@
data: JSON.stringify(toImport),
type: "ryot",
};
goto(toBaseUrl("/import/process"));
goto("/import/process");
} catch (err) {
isLoading = false;
notify({ type: "error", text: "Failed to read file!" });
@@ -658,7 +657,7 @@
type: "todomovies",
};
goto(toBaseUrl("/import/process"));
goto("/import/process");
} catch (err) {
isLoading = false;
notify({ type: "error", text: "Failed to read files!" });
@@ -668,7 +667,7 @@
onMount(() => {
if (!localStorage.getItem("token")) {
goto(toBaseUrl("/login"));
goto("/login");
}
});
</script>
@@ -703,7 +702,7 @@
filesSelected={(f) => processFiles(f, "tmdb")}
/>
<button class="plain" onclick={() => goto(toBaseUrl("/import/trakt"))}>
<button class="plain" onclick={() => goto("/import/trakt")}>
<Icon i="trakt" wh="100%" />
<h4 class="norm">Trakt Import</h4>
</button>
+4 -6
View File
@@ -29,7 +29,6 @@
import { onDestroy } from "svelte";
import papa from "papaparse";
import Status from "@/lib/Status.svelte";
import { toBaseUrl } from "@/lib/util/url";
interface ImportedListItemMultiProblem {
original: ImportedList;
@@ -59,7 +58,7 @@
const list = store.importedList;
if (!list) {
console.log("import/process, no list, returning to /import");
goto(toBaseUrl("/import"));
goto("/import");
return;
}
console.log("getList", list);
@@ -432,14 +431,14 @@
) {
// Some items failed.. go to some-failed
store.parsedImportedList = rList;
goto(toBaseUrl("/import/some-failed"));
goto("/import/some-failed");
} else {
notify({
type: "success",
text: "All content successfully imported! Try refreshing if you are missing data.",
time: 15000,
});
goto(toBaseUrl("/"));
goto("/");
}
}
@@ -678,8 +677,7 @@
</tbody>
</table>
<div class="btns">
<button onclick={() => goto(toBaseUrl("/import"))}
><Icon i="arrow" />Back</button
<button onclick={() => goto("/import")}><Icon i="arrow" />Back</button
>
<button onclick={() => changeAllStatuses()} disabled={isImporting}>
Change Statuses
@@ -12,7 +12,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { toBaseUrl } from "@/lib/util/url";
import { store } from "@/store.svelte";
import { ImportResponseType, type ImportedList } from "@/types";
import { onMount } from "svelte";
@@ -37,7 +36,7 @@
}
console.log("failedlen", failed.length);
} else {
goto(toBaseUrl("/import"));
goto("/import");
}
});
</script>
+1 -2
View File
@@ -21,7 +21,6 @@
import SyncModal from "./modals/SyncModal.svelte";
import RegionDropDown from "@/lib/RegionDropDown.svelte";
import RatingSetting from "@/lib/rating/RatingSetting.svelte";
import { toBaseUrl } from "@/lib/util/url";
let user = $derived(store.userInfo);
let settings = $derived(store.userSettings);
@@ -346,7 +345,7 @@
<RatingSetting />
<div class="row btns">
<button onclick={() => goto(toBaseUrl("/import"))}>Import</button>
<button onclick={() => goto("/import")}>Import</button>
<button onclick={() => downloadWatchedList()} disabled={exportDisabled}
>Export</button
>
+1 -2
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { notify } from "@/lib/util/notify";
import { toBaseUrl } from "@/lib/util/url";
import { store } from "@/store.svelte";
import { UserPermission } from "@/types";
import axios from "axios";
@@ -33,7 +32,7 @@
if (store.userInfo) {
store.userInfo.permissions = UserPermission.PERM_ADMIN;
}
goto(toBaseUrl("/"));
goto("/");
})
.catch((err) => {
console.error(
+17 -62
View File
@@ -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,7 +25,7 @@
import Icon from "@/lib/Icon.svelte";
import { afterNavigate, goto } from "$app/navigation";
import { page } from "$app/state";
import { toBaseUrl } from "@/lib/util/url.js";
import infScroll from "@/lib/util/infScroll.js";
type GameWithMediaType = GameSearch & { media_type: "game" };
type CombinedResult =
@@ -49,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) {
@@ -59,7 +55,7 @@
{
params: {
q: encodeURIComponent(query),
page,
p: page,
},
signal: reqController.signal,
},
@@ -76,7 +72,7 @@
const shows = await axios.get<ShowsSearchResponse>(`/content/search/tv`, {
params: {
q: encodeURIComponent(query),
page,
p: page,
},
signal: reqController.signal,
});
@@ -94,7 +90,7 @@
{
params: {
q: encodeURIComponent(query),
page,
p: page,
},
signal: reqController.signal,
},
@@ -111,7 +107,7 @@
return await axios.get<ContentSearch>(`/content/search/multi`, {
params: {
q: encodeURIComponent(query),
page,
p: page,
},
signal: reqController.signal,
});
@@ -135,7 +131,7 @@
const games = await axios.get<GameSearch[]>(`/game/search`, {
params: {
q: encodeURIComponent(query),
page,
p: page,
},
signal: reqController.signal,
});
@@ -279,7 +275,7 @@
data[0],
);
goto(toBaseUrl(`/game/${data[0].id}`));
goto(`/game/${data[0].id}`);
return;
}
allSearchResults.push(...data);
@@ -291,7 +287,7 @@
// assuming that people paste the id in, this should work
// without the debounce going to an incomplete id.
// Flesh out if anyone has issues.
goto(toBaseUrl(`/${extProvider.provider}/${extProvider.id}`));
goto(`/${extProvider.provider}/${extProvider.id}`);
return;
} else {
// Else call tmdb `external id` endpoint
@@ -325,11 +321,7 @@
mediaType,
);
} else {
goto(
toBaseUrl(
`/${data.results[0].media_type}/${data.results[0].id}`,
),
);
goto(`/${data.results[0].media_type}/${data.results[0].id}`);
return;
}
}
@@ -399,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.");
@@ -453,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) {
@@ -477,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) => {
@@ -512,6 +471,7 @@
onDestroy(() => {
console.debug("SEARCH PAGE DESTROYED");
store.searchQuery = "";
scroll.destroy();
reqController.abort("page destroyed");
});
</script>
@@ -589,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}
+3 -9
View File
@@ -24,7 +24,6 @@
import RegionDropDown from "@/lib/RegionDropDown.svelte";
import TaskScheduleModal from "./modals/TaskScheduleModal.svelte";
import TrustedHeaderAuthModal from "./modals/TrustedHeaderAuthModal.svelte";
import { toBaseUrl } from "@/lib/util/url";
let serverConfig: ServerConfig | undefined = $state();
let jellyfinOrEmby = $derived(serverConfig?.USE_EMBY ? "Emby" : "Jellyfin");
@@ -109,12 +108,7 @@
{#await getServerStats()}
<Spinner />
{:then stats}
<Stat
name="Users"
value={stats.users}
href={toBaseUrl("/manage_users")}
large
/>
<Stat name="Users" value={stats.users} href="/manage_users" large />
<Stat name="Private Users" value={stats.privateUsers} large />
<Stat name="Watched Movies" value={stats.watchedMovies} large />
<Stat name="Watched Shows" value={stats.watchedShows} large />
@@ -124,14 +118,14 @@
<Stat
name="Most Watched Movie"
value={stats.mostWatchedMovie.title}
href={toBaseUrl(`/movie/${stats.mostWatchedMovie.tmdbId}`)}
href="/movie/{stats.mostWatchedMovie.tmdbId}"
/>
{/if}
{#if stats.mostWatchedShow?.title}
<Stat
name="Most Watched Show"
value={stats.mostWatchedShow.title}
href={toBaseUrl(`/tv/${stats.mostWatchedShow.tmdbId}`)}
href="/tv/{stats.mostWatchedShow.tmdbId}"
/>
{/if}
{:catch err}
+58 -47
View File
@@ -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>
+5 -6
View File
@@ -6,7 +6,6 @@
import { noAuthAxios } from "@/lib/util/api";
import { onMount } from "svelte";
import { notify, unNotify } from "@/lib/util/notify";
import { toBaseUrl } from "@/lib/util/url";
let error: string | undefined = $state();
let login = $state(true);
@@ -19,7 +18,7 @@
onMount(() => {
if (localStorage.getItem("token")) {
goto(toBaseUrl("/"));
goto("/");
}
if (!error && page.url.searchParams.get("again")) {
@@ -36,7 +35,7 @@
if (r?.data) {
if (r.data.isInSetup) {
console.log("Server is in setup.. navigating to web setup page.");
goto(toBaseUrl("/setup"));
goto("/setup");
}
availableProviders = r.data.available;
apHeader = availableProviders?.includes("header");
@@ -84,7 +83,7 @@
} else {
localStorage.removeItem("useEmby");
}
goto(toBaseUrl("/"));
goto("/");
notify({ id: nid, text: `Welcome ${user}!`, type: "success" });
}
})
@@ -121,7 +120,7 @@
if (resp.data?.token) {
console.log("Received token... logging in.");
localStorage.setItem("token", resp.data.token);
goto(toBaseUrl("/"));
goto("/");
notify({ id: nid, text: `Welcome!`, type: "success" });
}
})
@@ -149,7 +148,7 @@
if (resp.data?.token) {
console.log("Received token... logging in.");
localStorage.setItem("token", resp.data.token);
goto(toBaseUrl("/"));
goto("/");
notify({ id: nid, text: `Welcome!`, type: "success" });
}
})
+7 -7
View File
@@ -1,30 +1,30 @@
<script lang="ts">
import { preventDefault } from "svelte/legacy";
import { goto } from "$app/navigation";
import type { AvailableAuthProviders } from "@/types";
import { noAuthAxios } from "@/lib/util/api";
import { onMount } from "svelte";
import { notify, unNotify } from "@/lib/util/notify";
import { toBaseUrl } from "@/lib/util/url";
let error: string | undefined = $state();
let error: string = $state();
onMount(() => {
if (localStorage.getItem("token")) {
goto(toBaseUrl("/"));
goto("/");
}
noAuthAxios.get<AvailableAuthProviders>("/auth/available").then((r) => {
if (r?.data) {
if (!r?.data?.isInSetup) {
console.log("Server not in setup.. navigating to login page.");
goto(toBaseUrl("/login"));
goto("/login");
}
}
});
});
function handleLogin(ev: SubmitEvent) {
ev.preventDefault();
const fd = new FormData(ev.target! as HTMLFormElement);
const user = fd.get("username");
const pass = fd.get("password");
@@ -44,7 +44,7 @@
if (resp.data?.token) {
console.log("Received token... logging in.");
localStorage.setItem("token", resp.data.token);
goto(toBaseUrl("/"));
goto("/");
notify({ id: nid, text: `Welcome ${user}!`, type: "success" });
}
})
@@ -74,7 +74,7 @@
<span class="error">{error}!</span>
{/if}
<form onsubmit={handleLogin}>
<form onsubmit={preventDefault(handleLogin)}>
<label for="username">Username</label>
<input type="text" name="username" placeholder="Username" />
+1 -2
View File
@@ -1,7 +1,6 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { toBaseUrl } from "@/lib/util/url";
</script>
<div>
@@ -17,7 +16,7 @@
<h4 class="norm">We couldn't load this page</h4>
{/if}
<div class="btns">
<button onclick={() => goto(toBaseUrl("/"))}>Home</button>
<button onclick={() => goto("/")}>Home</button>
<button onclick={() => location.reload()}>Refresh</button>
</div>
</div>
+28 -9
View File
@@ -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) {
+61 -50
View File
@@ -57,11 +57,7 @@ export type Icon =
| "sparkles"
| "tag"
| "ticket"
| "lock-closed"
| "github"
| "website"
| "tmdb"
| "igdb";
| "lock-closed";
export type Theme = "light" | "dark";
@@ -70,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,
@@ -93,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;
@@ -183,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;
@@ -502,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;
@@ -767,6 +773,7 @@ export interface ContentSearchMovie {
vote_count?: number;
video?: boolean;
vote_average?: number;
watched?: Watched;
}
export interface ContentSearchTv {
@@ -784,6 +791,7 @@ export interface ContentSearchTv {
vote_count?: number;
name?: string;
original_name?: string;
watched?: Watched;
}
export interface ContentSearchPerson {
@@ -814,6 +822,7 @@ export interface MoviesSearchResponse {
vote_average: number;
vote_count: number;
media_type: "movie";
watched?: Watched;
}[];
total_pages: number;
total_results: number;
@@ -837,6 +846,7 @@ export interface ShowsSearchResponse {
vote_average: number;
vote_count: number;
media_type: "tv";
watched?: Watched;
}[];
total_pages: number;
total_results: number;
@@ -886,6 +896,7 @@ export interface GameSearch {
name: string;
summary?: string;
version_title?: string;
// watched?: Watched; TODO
}
export enum ImportResponseType {
-13
View File
@@ -2,14 +2,6 @@ import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
import { sveltePreprocess } from "svelte-preprocess";
if (
process.env.WATCHARR_BASE &&
(!process.env.WATCHARR_BASE.startsWith("/") ||
process.env.WATCHARR_BASE.endsWith("/"))
) {
throw new Error("WATCHARR_BASE must start with, but not end with '/'");
}
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
@@ -29,11 +21,6 @@ const config = {
alias: {
"@": "src",
},
paths: {
base: process.env.WATCHARR_BASE,
relative: true,
},
},
};