mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 15:25:29 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e6ca542ec | |||
| a60710e384 | |||
| fbf3002fa1 | |||
| fce668ef97 | |||
| 939ff6cdaf | |||
| 6a716f7507 | |||
| 8f80153d22 | |||
| d3d476cc3f | |||
| 2f78fdd0a5 | |||
| 79f1d1b242 | |||
| b946dfd3c9 | |||
| 69c656bc97 | |||
| 10bf5324b2 | |||
| fcdf752c6c | |||
| df425b9587 | |||
| 78eee0e27e | |||
| 0ea1fea88a | |||
| 18c0882650 | |||
| b70ae5d3c7 | |||
| 01e175ee9f | |||
| 409b2c9d41 | |||
| 902a444704 | |||
| 2c0983724a | |||
| bf0a1eaa14 |
@@ -1,3 +0,0 @@
|
||||
# Docker dev volume
|
||||
container_data/
|
||||
data/
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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!
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +80,9 @@ const config = {
|
||||
position: "left",
|
||||
label: "Docs",
|
||||
},
|
||||
{
|
||||
type: "docsVersionDropdown",
|
||||
},
|
||||
{
|
||||
href: "https://beta.watcharr.app",
|
||||
label: "Demo",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
This folder is for any _extra_ images that we produce.
|
||||
|
||||
The main watcharr Dockerfile remains in project root.
|
||||
@@ -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"]
|
||||
@@ -1,7 +0,0 @@
|
||||
Image for building watcharr ui.
|
||||
|
||||
Accepts environment variables:
|
||||
|
||||
- `WATCHARR_BASE`
|
||||
|
||||
https://watcharr.app/docs/installation/docker-compose-subpath
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/robfig/go-cache"
|
||||
)
|
||||
|
||||
// Extension of the `.Get` method for `go-cache`.
|
||||
// This method will simplify our usage so we don't need
|
||||
// to type assert everywhere, this method will handle
|
||||
// everything related to getting the value from cache.
|
||||
// Returns `true` if `rv` was set to the cached value.
|
||||
// Returns `false` if we couldn't get anything from cache.
|
||||
func GetCache(c *cache.Cache, k string, rv any) bool {
|
||||
if val, found := c.Get(k); found {
|
||||
v := reflect.ValueOf(rv)
|
||||
if v.Type().Kind() == reflect.Ptr && v.Elem().CanSet() {
|
||||
v.Elem().Set(reflect.ValueOf(val))
|
||||
slog.Debug("cachefunc: Cache found.", "key", k)
|
||||
return true
|
||||
}
|
||||
slog.Error("cachefunc: Cache not set", "key", k)
|
||||
return false
|
||||
}
|
||||
slog.Debug("cachefunc: Cache not found", "key", k)
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a cache key for our in-mem cache.
|
||||
//
|
||||
// `name` should be the name of the function response we are caching.
|
||||
//
|
||||
// `...u` can be any amount of values that will make this key unique.
|
||||
// Currently supports types:
|
||||
// - `string`
|
||||
// - `map[string]string`
|
||||
// - `int`
|
||||
func CreateCacheKey(name string, u ...any) string {
|
||||
str := name
|
||||
appnd := func(s string) {
|
||||
str += "-" + s
|
||||
}
|
||||
for _, v := range u {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
appnd(vv)
|
||||
case map[string]string:
|
||||
for k, e := range vv {
|
||||
appnd(k + "_" + e)
|
||||
}
|
||||
case int:
|
||||
appnd(strconv.Itoa(vv))
|
||||
default:
|
||||
// This should never happen, but incase of unknown
|
||||
// value passed, hopefully this should make it easier
|
||||
// to catch in logs.
|
||||
str = str + "KEYTYPEERR"
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
+90
-19
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"github.com/robfig/go-cache"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -22,7 +22,8 @@ const (
|
||||
SHOW_EPISODE ContentType = "tv_episode"
|
||||
)
|
||||
|
||||
var ContentStore = persistence.NewInMemoryStore(time.Hour * 24)
|
||||
// inmemory content cache
|
||||
var ContentStore = cache.New(time.Hour*24, time.Minute)
|
||||
|
||||
// For storing cached content, so we can serve the basic local data for watched list to work
|
||||
type Content struct {
|
||||
@@ -250,11 +251,17 @@ func searchContent(query string, pageNum int) (TMDBSearchMultiResponse, error) {
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchContent", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchContent: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete multi search request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -263,6 +270,11 @@ func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchMovies", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/search/movie", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie search request!", "error", err.Error())
|
||||
@@ -271,6 +283,7 @@ func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "movie"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -279,6 +292,11 @@ func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchTv", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/search/tv", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv search request!", "error", err.Error())
|
||||
@@ -287,6 +305,7 @@ func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "tv"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -295,7 +314,10 @@ func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/person", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
err := tmdbRequest("/search/person", map[string]string{
|
||||
"query": query,
|
||||
"page": strconv.Itoa(pageNum),
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete people search request!", "error", err.Error())
|
||||
return TMDBSearchPeopleResponse{}, errors.New("failed to complete people search request")
|
||||
@@ -325,16 +347,23 @@ func searchByExternalId(id string, source string) (TMDBSearchMultiResponse, erro
|
||||
comb = append(comb, resp.TvSeasonResults...)
|
||||
comb = append(comb, resp.TvEpisodeResults...)
|
||||
return TMDBSearchMultiResponse{TMDBSearchResponse: TMDBSearchResponse[TMDBSearchMultiResults]{
|
||||
Results: comb,
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
Results: comb,
|
||||
TMDBPageFields: TMDBPageFields{
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
cacheKey := CreateCacheKey("movieDetails", id, country, rParams)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("movieDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie details request!", "error", err.Error())
|
||||
@@ -342,6 +371,7 @@ func movieDetails(db *gorm.DB, id string, country string, rParams map[string]str
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentMovie(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -355,8 +385,18 @@ func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
func tvDetails(
|
||||
db *gorm.DB,
|
||||
id string,
|
||||
country string,
|
||||
rParams map[string]string,
|
||||
) (TMDBShowDetails, error) {
|
||||
cacheKey := CreateCacheKey("tvDetails", id, country, rParams)
|
||||
resp := new(TMDBShowDetails)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("tvDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv details request!", "error", err.Error())
|
||||
@@ -364,6 +404,7 @@ func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentTv(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -379,13 +420,9 @@ func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
|
||||
// This method is manually cached, so it can be easily used in other places (on the server) with cache benefits
|
||||
func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error) {
|
||||
var cacheKey = "contentstore-seasondetails-" + tvId + "-" + seasonNumber
|
||||
cacheKey := CreateCacheKey("seasonDetails", tvId, seasonNumber)
|
||||
resp := new(TMDBSeasonDetails)
|
||||
if err := ContentStore.Get(cacheKey, &resp); err != nil {
|
||||
if err != persistence.ErrCacheMiss {
|
||||
slog.Error("seasonDetails: Cache failed for some reason", "error", err)
|
||||
}
|
||||
} else {
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("seasonDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
@@ -394,9 +431,7 @@ func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error)
|
||||
slog.Error("seasonDetails: Failed to complete season details request!", "error", err.Error())
|
||||
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
if err := ContentStore.Set(cacheKey, resp, time.Hour*24); err != nil {
|
||||
slog.Error("seasonDetails: Failed to set cache!", "error", err)
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -421,56 +456,92 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
}
|
||||
|
||||
func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
cacheKey := CreateCacheKey("discoverMovies")
|
||||
resp := new(TMDBDiscoverMovies)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("discoverMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/discover/movie", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover movies request!", "error", err.Error())
|
||||
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func discoverTv() (TMDBDiscoverShows, error) {
|
||||
cacheKey := CreateCacheKey("discoverTv")
|
||||
resp := new(TMDBDiscoverShows)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("discoverTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover tv request!", "error", err.Error())
|
||||
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func allTrending() (TMDBTrendingAll, error) {
|
||||
cacheKey := CreateCacheKey("allTrending")
|
||||
resp := new(TMDBTrendingAll)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("allTrending: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/trending/all/day", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete all trending request!", "error", err.Error())
|
||||
return TMDBTrendingAll{}, errors.New("failed to complete all trending request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func upcomingMovies() (TMDBUpcomingMovies, error) {
|
||||
cacheKey := CreateCacheKey("upcomingMovies")
|
||||
resp := new(TMDBUpcomingMovies)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("upcomingMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/movie/upcoming", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming movies request!", "error", err.Error())
|
||||
return TMDBUpcomingMovies{}, errors.New("failed to complete upcoming movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Theres no upcoming endpoint for tv ;( - using discover with future dates
|
||||
func upcomingTv() (TMDBUpcomingShows, error) {
|
||||
cacheKey := CreateCacheKey("upcomingTv")
|
||||
resp := new(TMDBUpcomingShows)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("upcomingTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
dFmt := "2006-01-02"
|
||||
mind := time.Now().Format(dFmt)
|
||||
maxd := time.Now().AddDate(0, 0, 15).Format(dFmt)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1", "first_air_date.gte": mind, "first_air_date.lte": maxd, "sort_by": "popularity.desc", "with_type": "2|3"}, &resp)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{
|
||||
"page": "1",
|
||||
"first_air_date.gte": mind,
|
||||
"first_air_date.lte": maxd,
|
||||
"sort_by": "popularity.desc",
|
||||
"with_type": "2|3",
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming tv request!", "error", err.Error())
|
||||
return TMDBUpcomingShows{}, errors.New("failed to complete upcoming tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// All the functions that help us turn a TMDB response struct
|
||||
// into one that will also include Watched data.
|
||||
// This process is very verbose. As far as I am aware, golangs
|
||||
// generics are not mature (powerful) enough to support us doing
|
||||
// this all with one function.
|
||||
//
|
||||
// TODO When possible look at turning all these funcs into one that
|
||||
// is reuable for any tmdb search response type.
|
||||
//
|
||||
// Each function will basically perform these simple steps:
|
||||
// 1. Repackage tmdb response so we can add Watched data to
|
||||
// 2. Get all watched data for the tmdb results
|
||||
// 4. Add any watched data to our new *WithWatched struct
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func searchContentAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchMultiResponse,
|
||||
) TMDBSearchMultiResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchMultiResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMultiResultsWithWatched{
|
||||
TMDBSearchMultiResults: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func searchMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchMoviesResponse,
|
||||
) TMDBSearchMoviesResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchMoviesResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMovieResultWithWatched{
|
||||
TMDBSearchMovieResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func searchTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchShowsResponse,
|
||||
) TMDBSearchShowsResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchShowsResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchShowsResultWithWatched{
|
||||
TMDBSearchShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func tvDetailsAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBShowDetails,
|
||||
) TMDBShowDetailsWithWatched {
|
||||
withWatchedResp := TMDBShowDetailsWithWatched{}
|
||||
withWatchedResp.TMDBShowDetailsBase = content.TMDBShowDetailsBase
|
||||
// Append watched list entry if exists
|
||||
if watchedEntry, err := getWatchedItemByTmdbId(db, userId, uint(content.ID), SHOW); err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
withWatchedResp.FailedToGetWatched = true
|
||||
}
|
||||
} else {
|
||||
withWatchedResp.Watched = &watchedEntry
|
||||
}
|
||||
// Add similar content with any watched entries
|
||||
similarContentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Similar.Results {
|
||||
withWatchedResp.Similar.Results = append(withWatchedResp.Similar.Results, TMDBShowSimilarResultWithWatched{
|
||||
TMDBShowSimilarResult: v,
|
||||
})
|
||||
similarContentIdAndTypePairs = append(similarContentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, similarContentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Similar.Results {
|
||||
if vv.ID == v.Content.TmdbID && string(SHOW) == string(v.Content.Type) {
|
||||
withWatchedResp.Similar.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func allTrendingAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBTrendingAll,
|
||||
) TMDBTrendingAllWithWatched {
|
||||
withWatchedResp := TMDBTrendingAllWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBTrendingAllResultWithWatched{
|
||||
TMDBTrendingAllResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func discoverTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBDiscoverShows,
|
||||
) TMDBDiscoverShowsWithWatched {
|
||||
withWatchedResp := TMDBDiscoverShowsWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverShowsResultWithWatched{
|
||||
TMDBDiscoverShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func upcomingTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBUpcomingShows,
|
||||
) TMDBUpcomingShowsWithWatched {
|
||||
withWatchedResp := TMDBUpcomingShowsWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingShowsResultWithWatched{
|
||||
TMDBUpcomingShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func discoverMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBDiscoverMovies,
|
||||
) TMDBDiscoverMoviesWithWatched {
|
||||
withWatchedResp := TMDBDiscoverMoviesWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverMoviesResultWithWatched{
|
||||
TMDBDiscoverMoviesResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
MOVIE,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func upcomingMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBUpcomingMovies,
|
||||
) TMDBUpcomingMoviesWithWatched {
|
||||
withWatchedResp := TMDBUpcomingMoviesWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingMoviesResultWithWatched{
|
||||
TMDBUpcomingMoviesResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
MOVIE,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -29,3 +31,51 @@ func WhereaboutsRequired() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination middleware
|
||||
// Reusable way to get pagination values.
|
||||
// If force=true then will default to page=1, otherwise
|
||||
// assume pagination is disabled when query params not present.
|
||||
func PaginatedRequest(force bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
pageStr := c.Query("p")
|
||||
page := 0
|
||||
if pageStr == "" && force {
|
||||
page = 1
|
||||
slog.Debug("PossiblyPaginated: Pagination is forced, but no page was provided. Using default.")
|
||||
} else if pageStr == "" {
|
||||
slog.Debug("PossiblyPaginated: Pagination is disabled. No parameter provided.")
|
||||
c.Set("paginationEnabled", false)
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
num, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
slog.Error("PossiblyPaginated: Query paramater 'p' was not parseable as an int", "err", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query param 'p' must be a number"})
|
||||
return
|
||||
}
|
||||
page = num
|
||||
}
|
||||
limitStr := c.Query("l")
|
||||
limit := 40
|
||||
if limitStr != "" {
|
||||
num, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
slog.Error("PossiblyPaginated: Query paramater 'l' was not parseable as an int", "err", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query param 'l' must be a number"})
|
||||
return
|
||||
}
|
||||
limit = num
|
||||
} else {
|
||||
slog.Debug("PossiblyPaginated: Using default limit.")
|
||||
}
|
||||
slog.Debug("PossiblyPaginated: middleware hit", "page", page, "page_limit", limit)
|
||||
c.Set("paginationEnabled", true)
|
||||
c.Set("paginationParams", PaginationParams{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
})
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Parameters that the paginator uses to
|
||||
// know what to return.
|
||||
type PaginationParams struct {
|
||||
// Page limit (max amount of items to get for each page).
|
||||
Limit int `json:"limit"`
|
||||
// Page number.
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
// Pagination response struct.
|
||||
type PaginationResponse[T interface{}] struct {
|
||||
PaginationParams
|
||||
// Max amount of pages we can produce from total_results
|
||||
TotalPages int `json:"totalPages"`
|
||||
TotalResults int64 `json:"totalResults"`
|
||||
Results []T `json:"results"`
|
||||
}
|
||||
|
||||
// Call when finished with PaginationResponse, before returning to user.
|
||||
// Performs final calculations.
|
||||
func (r *PaginationResponse[T]) Finished(p PaginationParams) {
|
||||
r.PaginationParams = p
|
||||
if r.TotalResults != 0 && r.Limit != 0 {
|
||||
r.TotalPages = int(math.Ceil(float64(r.TotalResults) / float64(r.Limit)))
|
||||
} else {
|
||||
slog.Warn(
|
||||
"PaginationResponse->Finished: TotalPages not calculated.",
|
||||
"total_results", r.TotalResults,
|
||||
"limit", r.Limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination gorm scope.
|
||||
// Pass in `PaginationParams` and the `PaginationResponse` will be filled out,
|
||||
// just fill out the `Results` manually.
|
||||
func Paginate[T interface{}](
|
||||
p PaginationParams,
|
||||
r *PaginationResponse[T],
|
||||
) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
offset := (p.Page - 1) * p.Limit
|
||||
return db.Offset(offset).Limit(p.Limit)
|
||||
}
|
||||
}
|
||||
+89
-79
@@ -80,98 +80,68 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
exp := time.Hour * 24
|
||||
|
||||
// Search for content
|
||||
content.GET("/search/multi", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/search/multi", PaginatedRequest(true), func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchContent(query, pageNum)
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
content, err := searchContent(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := searchContentAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Search for movies
|
||||
content.GET("/search/movie", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/search/movie", PaginatedRequest(true), func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchMovies(query, pageNum)
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
content, err := searchMovies(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := searchMoviesAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Search for shows
|
||||
content.GET("/search/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/search/tv", PaginatedRequest(true), func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchTv(query, pageNum)
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
content, err := searchTv(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := searchTvAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Search for people
|
||||
content.GET("/search/person", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/search/person", PaginatedRequest(true), cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchPeople(query, pageNum)
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
content, err := searchPeople(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -222,18 +192,24 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get tv details (for tv page)
|
||||
content.GET("/tv/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvDetails(b.db, c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
|
||||
content.GET("/tv/:id", WhereaboutsRequired(), func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
// 1. Get details
|
||||
content, err := tvDetails(
|
||||
b.db,
|
||||
c.Param("id"),
|
||||
c.MustGet("userCountry").(string),
|
||||
map[string]string{
|
||||
"append_to_response": "videos,watch/providers,similar,external_ids,keywords",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := tvDetailsAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Get tv cast
|
||||
content.GET("/tv/:id/credits", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
@@ -315,54 +291,64 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Discover movies
|
||||
content.GET("/discover/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/discover/movies", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := discoverMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := discoverMoviesAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Discover shows
|
||||
content.GET("/discover/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/discover/tv", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := discoverTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := discoverTvAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Get all trending (movies, tv, people)
|
||||
content.GET("/trending", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/trending", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := allTrending()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := allTrendingAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Upcoming Movies
|
||||
content.GET("/upcoming/movies", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/upcoming/movies", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := upcomingMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := upcomingMoviesAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Upcoming Tv
|
||||
content.GET("/upcoming/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/upcoming/tv", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
content, err := upcomingTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
withWatchedResp := upcomingTvAddWatched(b.db, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
})
|
||||
|
||||
// Available regions for watch providers
|
||||
content.GET("/regions", func(c *gin.Context) {
|
||||
@@ -482,9 +468,33 @@ func (b *BaseRouter) addGameRoutes() {
|
||||
func (b *BaseRouter) addWatchedRoutes() {
|
||||
watched := b.rg.Group("/watched").Use(AuthRequired(nil))
|
||||
|
||||
watched.GET("", func(c *gin.Context) {
|
||||
watched.GET("", PaginatedRequest(false), func(c *gin.Context) {
|
||||
isPaginated := c.MustGet("paginationEnabled").(bool)
|
||||
userId := c.MustGet("userId").(uint)
|
||||
c.JSON(http.StatusOK, getWatched(b.db, userId))
|
||||
if isPaginated {
|
||||
pp := c.MustGet("paginationParams").(PaginationParams)
|
||||
wp := WatchedGetPageRequest{
|
||||
// Defaults..
|
||||
Sort: watchedSortDateAdded,
|
||||
SortDir: sortAscending,
|
||||
}
|
||||
if err := c.ShouldBind(&wp); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "failed to get request parameters"})
|
||||
return
|
||||
}
|
||||
if wp, err := getWatchedPage(b.db, userId, pp, wp); err == nil {
|
||||
c.JSON(http.StatusOK, wp)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "failed to get page"})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non paginated response (doesn't support sorting/filtering atm)
|
||||
if w, err := getWatched(b.db, userId); err == nil {
|
||||
c.JSON(http.StatusOK, w)
|
||||
} else {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "failed"})
|
||||
}
|
||||
})
|
||||
|
||||
watched.GET(":id/:username", func(c *gin.Context) {
|
||||
|
||||
+237
-129
@@ -10,24 +10,40 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type TMDBSearchResponse[R any] struct {
|
||||
// Separated from `TMDBSearchResponse` so we can embed it for
|
||||
// easily assigning all page fields in one.
|
||||
type TMDBPageFields struct {
|
||||
Page int `json:"page"`
|
||||
Results []R `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBSearchResponse[R any] struct {
|
||||
TMDBPageFields
|
||||
Results []R `json:"results"`
|
||||
}
|
||||
|
||||
// A common "base" type for search results.
|
||||
type TMDBSearchResult struct {
|
||||
// TMDB ID
|
||||
ID int `json:"id"`
|
||||
// Media Type (movie, show, person)
|
||||
// Some requests won't return this value
|
||||
// (namely any request other than a multi
|
||||
// type search), but we add it in manually.
|
||||
MediaType string `json:"media_type"`
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResults struct {
|
||||
TMDBSearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int64 `json:"genre_ids"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
@@ -49,15 +65,24 @@ type TMDBSearchMultiResults struct {
|
||||
StillPath string `json:"still_path,omitempty"`
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResultsWithWatched struct {
|
||||
TMDBSearchMultiResults
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchMultiResults]
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResponseWithWatched struct {
|
||||
TMDBSearchResponse[TMDBSearchMultiResultsWithWatched]
|
||||
}
|
||||
|
||||
type TMDBSearchMovieResult struct {
|
||||
TMDBSearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
@@ -68,18 +93,26 @@ type TMDBSearchMovieResult struct {
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
}
|
||||
|
||||
type TMDBSearchMovieResultWithWatched struct {
|
||||
TMDBSearchMovieResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBSearchMoviesResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchMovieResult]
|
||||
}
|
||||
|
||||
type TMDBSearchMoviesResponseWithWatched struct {
|
||||
TMDBSearchResponse[TMDBSearchMovieResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResult struct {
|
||||
TMDBSearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
@@ -90,23 +123,30 @@ type TMDBSearchShowsResult struct {
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResultWithWatched struct {
|
||||
TMDBSearchShowsResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchShowsResult]
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResponseWithWatched struct {
|
||||
TMDBSearchResponse[TMDBSearchShowsResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBSearchPeopleResult struct {
|
||||
TMDBSearchResult
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
KnownFor []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
@@ -195,7 +235,12 @@ type TMDBMovieDetails struct {
|
||||
ExternalIds TMDBExternalIdsMovie `json:"external_ids"`
|
||||
}
|
||||
|
||||
type TMDBShowDetails struct {
|
||||
type TMDBMovieDetailsWithWatched struct {
|
||||
WatchedAddedToContent
|
||||
*TMDBShowDetails
|
||||
}
|
||||
|
||||
type TMDBShowDetailsBase struct {
|
||||
TMDBContentDetails
|
||||
CreatedBy []struct {
|
||||
ID int `json:"id"`
|
||||
@@ -245,13 +290,24 @@ type TMDBShowDetails struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
// Extra items because we use `append_to_response` on the request
|
||||
// Similar SS `json:"similar"`
|
||||
Videos TMDBContentVideos `json:"videos"`
|
||||
WatchProviders interface{} `json:"watch/providers"`
|
||||
Similar TMDBShowSimilar `json:"similar"`
|
||||
ExternalIds TMDBExternalIdsShow `json:"external_ids"`
|
||||
Keywords TMDBKeywords `json:"keywords"`
|
||||
}
|
||||
|
||||
type TMDBShowDetails struct {
|
||||
TMDBShowDetailsBase
|
||||
Similar TMDBShowSimilar `json:"similar"`
|
||||
}
|
||||
|
||||
type TMDBShowDetailsWithWatched struct {
|
||||
WatchedAddedToContent
|
||||
TMDBShowDetailsBase
|
||||
Similar TMDBShowSimilarWithWatched `json:"similar"`
|
||||
}
|
||||
|
||||
type WatchProvider struct {
|
||||
ProviderID int `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
@@ -325,25 +381,33 @@ type TMDBSeasonDetails struct {
|
||||
}
|
||||
|
||||
type TMDBShowSimilar struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBShowSimilarResult]
|
||||
}
|
||||
|
||||
type TMDBShowSimilarResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBShowSimilarWithWatched struct {
|
||||
TMDBSearchResponse[TMDBShowSimilarResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBShowSimilarResultWithWatched struct {
|
||||
TMDBShowSimilarResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBMovieSimilar struct {
|
||||
@@ -448,73 +512,97 @@ type TMDBContentCredits struct {
|
||||
}
|
||||
|
||||
type TMDBDiscoverMovies struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBDiscoverMoviesResult]
|
||||
}
|
||||
|
||||
type TMDBDiscoverMoviesWithWatched struct {
|
||||
TMDBSearchResponse[TMDBDiscoverMoviesResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBDiscoverMoviesResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBDiscoverMoviesResultWithWatched struct {
|
||||
TMDBDiscoverMoviesResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBDiscoverShows struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBDiscoverShowsResult]
|
||||
}
|
||||
|
||||
type TMDBDiscoverShowsWithWatched struct {
|
||||
TMDBSearchResponse[TMDBDiscoverShowsResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBDiscoverShowsResult struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBDiscoverShowsResultWithWatched struct {
|
||||
TMDBDiscoverShowsResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBTrendingAll struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBTrendingAllResult]
|
||||
}
|
||||
|
||||
type TMDBTrendingAllWithWatched struct {
|
||||
TMDBSearchResponse[TMDBTrendingAllResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBTrendingAllResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
}
|
||||
|
||||
type TMDBTrendingAllResultWithWatched struct {
|
||||
TMDBTrendingAllResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBUpcomingMovies struct {
|
||||
@@ -522,46 +610,66 @@ type TMDBUpcomingMovies struct {
|
||||
Maximum string `json:"maximum"`
|
||||
Minimum string `json:"minimum"`
|
||||
} `json:"dates"`
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBUpcomingMoviesResult]
|
||||
}
|
||||
|
||||
type TMDBUpcomingMoviesWithWatched struct {
|
||||
Dates struct {
|
||||
Maximum string `json:"maximum"`
|
||||
Minimum string `json:"minimum"`
|
||||
} `json:"dates"`
|
||||
TMDBSearchResponse[TMDBUpcomingMoviesResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBUpcomingMoviesResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBUpcomingMoviesResultWithWatched struct {
|
||||
TMDBUpcomingMoviesResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBUpcomingShows struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
TMDBSearchResponse[TMDBUpcomingShowsResult]
|
||||
}
|
||||
|
||||
type TMDBUpcomingShowsWithWatched struct {
|
||||
TMDBSearchResponse[TMDBUpcomingShowsResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBUpcomingShowsResult struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBUpcomingShowsResultWithWatched struct {
|
||||
TMDBUpcomingShowsResult
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBExternalIds struct {
|
||||
|
||||
@@ -28,6 +28,16 @@ type GormModel struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
// Types of media supported by Watcharr
|
||||
// in an overarching way.
|
||||
type SupportedMedia string
|
||||
|
||||
const (
|
||||
SupportedMediaMovie SupportedMedia = "movie"
|
||||
SupportedMediaShow SupportedMedia = "tv"
|
||||
SupportedMediaGame SupportedMedia = "game"
|
||||
)
|
||||
|
||||
var (
|
||||
ServerInSetup = false
|
||||
logLevel = new(slog.LevelVar)
|
||||
|
||||
+102
-3
@@ -75,7 +75,51 @@ type WatchedRemoveResponse struct {
|
||||
NewActivity Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
// Get watched page request extra (GET) options.
|
||||
type WatchedGetPageRequest struct {
|
||||
// Sorting type.
|
||||
Sort WatchedSort `form:"sort"`
|
||||
// Sorting direction (asc or desc).
|
||||
SortDir SortDirection `form:"sortDir,default=desc"`
|
||||
// Filtering options.
|
||||
Filter struct {
|
||||
Type SupportedMedia `form:"filter.type"`
|
||||
Status WatchedStatus `form:"filter.status"`
|
||||
}
|
||||
}
|
||||
|
||||
type WatchedSort string
|
||||
|
||||
const (
|
||||
watchedSortDateAdded WatchedSort = "DATEADDED"
|
||||
watchedSortLastChanged WatchedSort = "LASTCHANGED"
|
||||
watchedSortLastFinished WatchedSort = "LASTFIN"
|
||||
watchedSortRating WatchedSort = "RATING"
|
||||
watchedSortAlphabetical WatchedSort = "ALPHA"
|
||||
)
|
||||
|
||||
type SortDirection string
|
||||
|
||||
const (
|
||||
sortAscending SortDirection = "asc"
|
||||
sortDescending SortDirection = "desc"
|
||||
)
|
||||
|
||||
// This struct is for embedding inside content response structs.
|
||||
// This holds the watched entry response data that will go along
|
||||
// with the content responses.
|
||||
type WatchedAddedToContent struct {
|
||||
// The related watched entry.
|
||||
Watched *Watched `json:"watched,omitempty"`
|
||||
// If we failed to get the watched entry,
|
||||
// set this to true, so the frontend can
|
||||
// notify the user of why there is possibly
|
||||
// missing watched list data.
|
||||
FailedToGetWatched bool `json:"failedToGetWatched,omitempty"`
|
||||
}
|
||||
|
||||
// Get entire watched list
|
||||
func getWatched(db *gorm.DB, userId uint) ([]Watched, error) {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Preload("Content").
|
||||
@@ -88,9 +132,39 @@ func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
Where("user_id = ?", userId).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
slog.Error("getWatched: Failed!", "error", res.Error)
|
||||
return []Watched{}, res.Error
|
||||
}
|
||||
return *watched
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Returns a page of users watched list.
|
||||
func getWatchedPage(db *gorm.DB, userId uint, pp PaginationParams, wr WatchedGetPageRequest) (PaginationResponse[Watched], error) {
|
||||
slog.Debug("getWatchedPage: A page was requested.", "user_id", userId, "pagination_params", pp, "wr", wr)
|
||||
watched := new([]Watched)
|
||||
pRes := &PaginationResponse[Watched]{}
|
||||
res := db.
|
||||
Model(&Watched{}).
|
||||
Where(&Watched{UserID: userId}).
|
||||
Count(&pRes.TotalResults).
|
||||
Joins("Content").
|
||||
Joins("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Tags").
|
||||
Scopes(
|
||||
Paginate(pp, pRes),
|
||||
// NOTE: watchedRefine->watchedSortLastFinished sort changes the SELECT
|
||||
// statement for this query, so keep that in mind if it ever changes.
|
||||
watchedRefine(wr),
|
||||
).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedPage: Failed!", "error", res.Error)
|
||||
return PaginationResponse[Watched]{}, res.Error
|
||||
}
|
||||
pRes.Results = *watched
|
||||
pRes.Finished(pp)
|
||||
return *pRes, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by id (must be for `userId`).
|
||||
@@ -120,6 +194,31 @@ func getWatchedItemByTmdbId(db *gorm.DB, userId uint, tmdbId uint, contentType C
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Same as `getWatchedItemByTmdbId` except for getting in bulk (multiple content ids).
|
||||
// `c` entries should be in format: [tmdb_id, ContentType] (Note: Couldn't figure out
|
||||
// if it's possible to type this to enforce [int, ContentType] type for entries)
|
||||
func getWatchedItemsByTmdbIds(db *gorm.DB, userId uint, c [][]any) ([]Watched, error) {
|
||||
slog.Debug("getWatchedItemsByTmdbIds: Running.", "userId", userId, "c", c)
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Joins("Content").
|
||||
Where("user_id = ?", userId).
|
||||
Where(
|
||||
"(Content.tmdb_id, Content.type) IN ?", c).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemsByTmdbIds: Failed!", "error", res.Error)
|
||||
return []Watched{}, res.Error
|
||||
}
|
||||
slog.Debug(
|
||||
"getWatchedItemsByTmdbIds: Done.",
|
||||
"userId", userId,
|
||||
"watcheds_found", len(*watched),
|
||||
// "wdev", *watched,
|
||||
)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get another users **public** watchlist.
|
||||
func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, error) {
|
||||
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Watched sorting & filtering.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// gorm scope for applying sort and filters to watched
|
||||
// list data.
|
||||
func watchedRefine(wr WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
// Apply filters
|
||||
|
||||
// Apply sort
|
||||
if wr.Sort != "" {
|
||||
obc := func(cn string) clause.OrderByColumn {
|
||||
o := clause.OrderByColumn{}
|
||||
if wr.SortDir == sortAscending {
|
||||
o.Desc = false
|
||||
} else {
|
||||
o.Desc = true
|
||||
}
|
||||
o.Column = clause.Column{Name: cn}
|
||||
return o
|
||||
}
|
||||
switch wr.Sort {
|
||||
case watchedSortDateAdded:
|
||||
db.Order(obc("watcheds.created_at"))
|
||||
case watchedSortLastChanged:
|
||||
db.Order(obc("watcheds.updated_at"))
|
||||
case watchedSortLastFinished:
|
||||
// TODO This can make the query quite slow, look at improving performance.
|
||||
db.
|
||||
Joins("LEFT JOIN activities ON activities.watched_id = watcheds.id").
|
||||
// TODO this whole query looks to work, but we have to add `watcheds.*` to this SELECT,
|
||||
// otherwise it doesn't select them (like it does when we use Model, in the original query build),
|
||||
// is there a better way? Cuz if we modify the Model later in main query, this would break, which isn't ideal.
|
||||
Select("watcheds.*, MAX(MAX(activities.created_at), MAX(activities.custom_date)) as latest_watched_activity").
|
||||
Group("watcheds.id").
|
||||
Order(obc("latest_watched_activity"))
|
||||
case watchedSortRating:
|
||||
db.Order(obc("watcheds.rating"))
|
||||
case watchedSortAlphabetical:
|
||||
db.
|
||||
Order(obc("Content__title")).
|
||||
Order(obc("Game__name"))
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,6 +3,6 @@ import type { ClientInit } from "@sveltejs/kit";
|
||||
export const init: ClientInit = async () => {
|
||||
console.info(
|
||||
`%cWATCHARR v${__WATCHARR_VERSION__}`,
|
||||
"background: white;color: black;font-size: large;padding: 3px 5px;",
|
||||
"background: white;color: black;font-size: 18px;padding: 3px 5px;",
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
interface Props {
|
||||
contentTitle: string;
|
||||
thoughts: string;
|
||||
onChange: (newThoughts: string) => Promise<boolean>;
|
||||
onChange: (newThoughts: string) => Promise<void>;
|
||||
}
|
||||
|
||||
let { contentTitle, thoughts, onChange }: Props = $props();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
|
||||
import HorizontalList from "../HorizontalList.svelte";
|
||||
import { store } from "@/store.svelte";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import Poster from "../poster/Poster.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -15,11 +13,11 @@
|
||||
|
||||
{#if similar?.results?.length > 0}
|
||||
<HorizontalList title="Similar">
|
||||
{#each similar.results as content}
|
||||
{#each similar.results as content, i}
|
||||
<Poster
|
||||
media={{ ...content, media_type: type }}
|
||||
{...getWatchedDependedProps(content.id, type, store.watchedList)}
|
||||
small={true}
|
||||
bind:watched={similar.results[i].watched}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
@@ -9,7 +9,6 @@
|
||||
store.wlDetailedView = store.wlDetailedView.filter((a) => a !== d);
|
||||
} else {
|
||||
store.wlDetailedView.push(d);
|
||||
store.wlDetailedView = store.wlDetailedView;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ContentType, Watched, WatchedStatus } from "@/types";
|
||||
import { getLatestWatchedInTv } from "../util/helpers";
|
||||
|
||||
export type PosterExtraDetails = {
|
||||
rating: number | undefined;
|
||||
status: WatchedStatus | undefined;
|
||||
dateAdded?: string;
|
||||
dateModified?: string;
|
||||
/**
|
||||
* Only for shows.
|
||||
*/
|
||||
lastWatched?: string;
|
||||
};
|
||||
|
||||
export function buildExtraDetails(
|
||||
t: ContentType,
|
||||
w: Watched,
|
||||
): PosterExtraDetails {
|
||||
const obj = {
|
||||
rating: w.rating,
|
||||
status: w.status,
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt,
|
||||
} as PosterExtraDetails;
|
||||
if (t === "tv") {
|
||||
obj.lastWatched = getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
interface Props {
|
||||
rating: number | undefined;
|
||||
onChange: (newRating: number) => Promise<boolean>;
|
||||
onChange: (newRating: number) => Promise<void>;
|
||||
}
|
||||
|
||||
let { rating, onChange }: Props = $props();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
interface Props {
|
||||
rating: number | undefined;
|
||||
onChange: (newRating: number) => Promise<boolean>;
|
||||
onChange: (newRating: number) => Promise<void>;
|
||||
}
|
||||
|
||||
let { rating, onChange }: Props = $props();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
interface Props {
|
||||
rating: number | undefined;
|
||||
onChange: (newRating: number) => Promise<boolean>;
|
||||
onChange: (newRating: number) => Promise<void>;
|
||||
}
|
||||
|
||||
let { rating, onChange }: Props = $props();
|
||||
|
||||
+100
-88
@@ -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
@@ -56,45 +56,10 @@ export function isTouch() {
|
||||
return "ontouchstart" in window;
|
||||
}
|
||||
|
||||
// Not passing wList from #each loop caused it not to have reactivity.
|
||||
// Passing it through must allow it to recognize it as a dependency?
|
||||
export function getWatchedDependedProps(
|
||||
wid: number,
|
||||
wtype: MediaType,
|
||||
list: Watched[],
|
||||
) {
|
||||
const wel = list.find(
|
||||
(wl) => wl.content?.tmdbId === wid && wl.content?.type === wtype,
|
||||
);
|
||||
if (!wel) return {};
|
||||
console.log(wid, wtype, wel?.content?.title, wel?.status, wel?.rating);
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
rating: wel.rating,
|
||||
extraDetails: {
|
||||
dateAdded: wel.createdAt,
|
||||
dateModified: wel.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(
|
||||
wel.watchedSeasons,
|
||||
wel.watchedEpisodes,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlayedDependedProps(wid: number, list: Watched[]) {
|
||||
const wel = list.find((wl) => wl.game?.igdbId === wid);
|
||||
if (!wel) return {};
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
rating: wel.rating,
|
||||
extraDetails: {
|
||||
dateAdded: wel.createdAt,
|
||||
dateModified: wel.updatedAt,
|
||||
},
|
||||
};
|
||||
// TODO this is only here to avoid error for now, but we are removing
|
||||
// this func, just like we removed getWatchedDependedProps, because
|
||||
// the poster component will now just accept the whole `watched` entry obj
|
||||
}
|
||||
|
||||
// Get biggest season watching or biggest season watched.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { page } from "$app/state";
|
||||
|
||||
export interface ToolTipOptions {
|
||||
/**
|
||||
* Infinite scroll threshold.
|
||||
* Distance from bottom.
|
||||
*/
|
||||
threshold?: number;
|
||||
/**
|
||||
* Ran when we reach the end of scroll.
|
||||
* This callback should load the new data.
|
||||
*/
|
||||
callback: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infinite scroll helper.
|
||||
*
|
||||
* **Ensure:**
|
||||
* - `destroy()` is ran after the component
|
||||
* this is used in is destroyed.
|
||||
* - `dataLoaded()` is ran after a page of data
|
||||
* is loaded.
|
||||
* - `opts.callback()` has its own 'isLoading' logic
|
||||
* to prevent itself from running extra times while
|
||||
* still loading a previous request. This will be the
|
||||
* thing stopping extra scrolls while data is still
|
||||
* loading from causing extra data requests, etc.
|
||||
*/
|
||||
export default function infScroll(opts: ToolTipOptions) {
|
||||
let { threshold = 150, callback } = opts;
|
||||
|
||||
// Store current pathname at point of infScroll
|
||||
// initialization, this ensures we have a point
|
||||
// of reference for our fix below (that ensures
|
||||
// we don't allow asking for next data load if
|
||||
// user navigates to a different page).
|
||||
const startPagePathLower = page.url?.pathname?.toLowerCase();
|
||||
|
||||
const addEvents = () => {
|
||||
console.debug("infScroll->addEvents()");
|
||||
window.addEventListener("scroll", run);
|
||||
window.addEventListener("resize", run);
|
||||
};
|
||||
|
||||
const removeEvents = () => {
|
||||
console.debug("infScroll->removeEvents()");
|
||||
window.removeEventListener("scroll", run);
|
||||
window.removeEventListener("resize", run);
|
||||
};
|
||||
|
||||
const isAtBottom = () => {
|
||||
return (
|
||||
window.innerHeight + Math.round(window.scrollY) + threshold >=
|
||||
document.body.offsetHeight
|
||||
);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (isAtBottom()) {
|
||||
console.log("infiniteScroll: Reached end");
|
||||
removeEvents();
|
||||
await callback();
|
||||
addEvents();
|
||||
console.log("infiniteScroll: Callback ran");
|
||||
} else {
|
||||
console.debug("infiniteScroll: Not at bottom");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This needs to be called after data is loaded.
|
||||
* It runs the infScroll logic incase the user is
|
||||
* already at the bottom after initial data load
|
||||
* without scroll/resize (eg: using high dpi screen).
|
||||
*/
|
||||
const dataLoaded = () => {
|
||||
// If results don't fill the page enough to enable scrolling,
|
||||
// the user could be stuck and not be able to get more results
|
||||
// to show, run `infiniteScroll` to load more if we can.
|
||||
// Smol timeout to give ui time to render so end of page calc
|
||||
// can be accurate.
|
||||
setTimeout(() => {
|
||||
// Quick fix, if user navigates away from search page while response is loading,
|
||||
// we don't want to call infiniteScroll or we could end up loading all pages
|
||||
// in the background.
|
||||
if (startPagePathLower === page.url?.pathname?.toLowerCase()) {
|
||||
console.debug(
|
||||
"infiniteScroll->dataLoaded(): Still at bottom.. asking for more data.",
|
||||
);
|
||||
run();
|
||||
} else {
|
||||
console.debug(
|
||||
"infiniteScroll->dataLoaded(): No longer on initial page, not calling infiniteScroll.",
|
||||
);
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
|
||||
addEvents();
|
||||
|
||||
return {
|
||||
isAtBottom,
|
||||
run,
|
||||
dataLoaded,
|
||||
destroy: () => {
|
||||
console.debug("infScroll->destroy()");
|
||||
removeEvents();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -121,6 +121,10 @@
|
||||
fill: $bg-color;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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). -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user