mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6d6e66f04 | |||
| 0b4f3c8604 | |||
| 0a5a4e39f5 | |||
| 2d8def18ed | |||
| d53f62e96c | |||
| 4dd0b419e0 | |||
| 41948f15ba | |||
| 49f0739503 | |||
| 1c763e9761 | |||
| 18a30639b9 | |||
| a3847ccb3c | |||
| 5f5eb3e9f2 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "watcharr",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.4",
|
||||
"blurhash": "^2.0.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -33,6 +33,8 @@ func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp i
|
||||
return errors.New("using igdbHost without a clientID or accessToken")
|
||||
}
|
||||
|
||||
slog.Debug("IGDB->req: Creating a request.", "ep", ep, "body", b)
|
||||
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
@@ -181,6 +183,24 @@ func (i *IGDB) Search(q string) (GameSearchResponse, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Should return same details as `Search`, we both are for search page only minimal details required.
|
||||
func (i *IGDB) SearchById(id string) (GameSearchResponse, error) {
|
||||
slog.Debug("IGDB Search called", "id", id)
|
||||
var resp GameSearchResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
"fields name, cover.image_id, version_title, summary, first_release_date; where id = "+id+";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB Search request failed!", "error", err)
|
||||
return GameSearchResponse{}, errors.New("request failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (i *IGDB) GameDetails(id string) (GameDetailsResponse, error) {
|
||||
slog.Debug("IGDB GameDetails called", "id", id)
|
||||
var resp []GameDetailsResponse
|
||||
|
||||
+21
-1
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -399,7 +400,26 @@ func (b *BaseRouter) addGameRoutes() {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
games, err := igdb.Search(query)
|
||||
decodedQuery, err := url.QueryUnescape(query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter invalid"})
|
||||
return
|
||||
}
|
||||
games, err := igdb.Search(decodedQuery)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, games)
|
||||
}))
|
||||
|
||||
// Search for game by id (for search page, same minimal details as /search returned)
|
||||
gamer.GET("/search/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
games, err := igdb.SearchById(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
|
||||
+123
-15
@@ -3,6 +3,7 @@
|
||||
TMDBSeasonDetails,
|
||||
TMDBShowSeason,
|
||||
Watched,
|
||||
WatchedSeason,
|
||||
WatchedSeasonAddResponse,
|
||||
WatchedStatus,
|
||||
} from "@/types";
|
||||
@@ -12,9 +13,10 @@
|
||||
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
|
||||
import PosterStatus from "./poster/PosterStatus.svelte";
|
||||
import { notify } from "./util/notify";
|
||||
import { get } from "svelte/store";
|
||||
import { store } from "@/store.svelte";
|
||||
import PosterRating from "./poster/PosterRating.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { watchedStatuses } from "./util/helpers";
|
||||
|
||||
interface Props {
|
||||
tvId: number;
|
||||
@@ -168,6 +170,24 @@
|
||||
function handleStarClick(rating: number, seasonNumber: number) {
|
||||
updateWatchedSeason(seasonNumber, undefined, rating);
|
||||
}
|
||||
|
||||
function checkSeasonStatus(
|
||||
watchedSeasons: WatchedSeason[] | undefined,
|
||||
currentSeason: TMDBShowSeason,
|
||||
): WatchedStatus | undefined {
|
||||
if (watchedSeasons) {
|
||||
const watchedSeason = watchedSeasons.find(
|
||||
(ws) => ws.seasonNumber === currentSeason.season_number,
|
||||
);
|
||||
console.debug(
|
||||
"checkSeasonStatus:",
|
||||
currentSeason.season_number,
|
||||
watchedSeason?.status,
|
||||
);
|
||||
return watchedSeason?.status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ctr">
|
||||
@@ -179,12 +199,33 @@
|
||||
activeSeason = season.season_number;
|
||||
}}
|
||||
>
|
||||
<h1>{season.name}</h1>
|
||||
{#if season.air_date}
|
||||
<h2>{new Date(Date.parse(season.air_date)).getFullYear()}</h2>
|
||||
{:else if season.season_number > 0}
|
||||
<h2>TBD</h2>
|
||||
{/if}
|
||||
<div>
|
||||
<h1 class="season-name">{season.name}</h1>
|
||||
{#if season.episode_count > 0}
|
||||
<h2 class="season-episodes">{season.episode_count} Episodes</h2>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if season.air_date}
|
||||
<h2 class="season-date">
|
||||
{new Date(Date.parse(season.air_date)).getFullYear()}
|
||||
</h2>
|
||||
{:else if season.season_number > 0}
|
||||
<h2>TBD</h2>
|
||||
{/if}
|
||||
|
||||
{#if watchedItem}
|
||||
{@const status = checkSeasonStatus(
|
||||
watchedItem.watchedSeasons,
|
||||
season,
|
||||
)}
|
||||
{#if status}
|
||||
<div class="plain season-status">
|
||||
<Icon i={watchedStatuses[status]} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="last"></div>
|
||||
@@ -274,41 +315,97 @@
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 18px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 2px solid #302d2d;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
transition: background-color 100ms ease;
|
||||
|
||||
& > div {
|
||||
&:first-of-type {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
margin-left: auto;
|
||||
margin-bottom: auto;
|
||||
padding-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.season-name {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.season-name,
|
||||
.season-episodes {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.season-date,
|
||||
.season-status {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.season-episodes {
|
||||
color: $text-color-accent;
|
||||
}
|
||||
|
||||
.season-status {
|
||||
fill: $text-color;
|
||||
|
||||
:global(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: sans-serif;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: white;
|
||||
background-color: black;
|
||||
color: $bg-color;
|
||||
background-color: $text-color;
|
||||
|
||||
.season-status {
|
||||
fill: $bg-color;
|
||||
}
|
||||
|
||||
.season-episodes {
|
||||
color: $bg-color-accent;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
|
||||
.season-status {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,12 +455,23 @@
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
:global(body.nav-shown) ul.seasons {
|
||||
top: $nav-height;
|
||||
}
|
||||
|
||||
ul.seasons {
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
position: unset;
|
||||
height: unset;
|
||||
justify-content: center;
|
||||
justify-content: unset;
|
||||
overflow: auto;
|
||||
min-width: unset;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
padding: 10px 0;
|
||||
z-index: 5;
|
||||
@include nav-blur;
|
||||
|
||||
button {
|
||||
&:first-of-type {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
store.wlDetailedView = store.wlDetailedView.filter((a) => a !== d);
|
||||
} else {
|
||||
store.wlDetailedView.push(d);
|
||||
store.wlDetailedView = store.wlDetailedView;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@mixin nav-blur {
|
||||
backdrop-filter: blur(2.5px) saturate(120%);
|
||||
background-color: var(--nav-color);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@import "./mixins.scss";
|
||||
@import "./vars.scss";
|
||||
|
||||
@font-face {
|
||||
|
||||
@@ -341,9 +341,8 @@
|
||||
top: 0;
|
||||
gap: 3px;
|
||||
z-index: 99990;
|
||||
backdrop-filter: blur(2.5px) saturate(120%);
|
||||
background-color: $nav-color;
|
||||
transition: top 200ms ease-in-out;
|
||||
@include nav-blur;
|
||||
|
||||
&:global(.scrolled-down) {
|
||||
top: -110px;
|
||||
|
||||
@@ -150,6 +150,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function searchGamesById(id: string) {
|
||||
try {
|
||||
const games = await axios.get<GameSearch[]>(`/game/search/${id}`, {
|
||||
signal: reqController.signal,
|
||||
});
|
||||
return {
|
||||
data: games?.data?.map((g) => ({
|
||||
...g,
|
||||
media_type: "game",
|
||||
})) as GameWithMediaType[],
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`searchGamesById: failed!`, id, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchExternalId(id: string, provider: string) {
|
||||
try {
|
||||
return await axios.get<ContentSearch>(
|
||||
@@ -181,6 +198,8 @@
|
||||
let p = spl[0]?.toLowerCase();
|
||||
switch (p) {
|
||||
// Default names that are supported right out of the box
|
||||
case "movie": // tmdb id target
|
||||
case "tv": // tmdb id target
|
||||
case "imdb":
|
||||
case "tvdb":
|
||||
case "youtube":
|
||||
@@ -189,6 +208,7 @@
|
||||
case "instagram":
|
||||
case "twitter":
|
||||
case "tiktok":
|
||||
case "igdb":
|
||||
break;
|
||||
// Any aliases we want to support
|
||||
case "i":
|
||||
@@ -205,6 +225,12 @@
|
||||
case "thetvdb":
|
||||
p = "tvdb";
|
||||
break;
|
||||
case "game":
|
||||
p = "igdb";
|
||||
break;
|
||||
case "series":
|
||||
p = "tv";
|
||||
break;
|
||||
// If none match, then is invalid provider.
|
||||
default:
|
||||
console.info("checkForExternalIdSearch: Invalid provider found:", p);
|
||||
@@ -237,42 +263,74 @@
|
||||
reqController = new AbortController();
|
||||
try {
|
||||
if (isExtSearch) {
|
||||
console.log("Search: Performing external id search.");
|
||||
const resp = await searchExternalId(
|
||||
extProvider.id,
|
||||
extProvider.provider,
|
||||
);
|
||||
const data = resp.data;
|
||||
if (!data || !data.results) {
|
||||
console.warn("Search: No results from external id search.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
data.results.length === 1 &&
|
||||
data.results[0].media_type &&
|
||||
data.results[0].id
|
||||
) {
|
||||
console.info(
|
||||
"Search: Only one result from external id search. Redirecting..",
|
||||
data.results[0],
|
||||
);
|
||||
const mediaType = data.results[0].media_type;
|
||||
if (
|
||||
mediaType !== "movie" &&
|
||||
mediaType !== "tv" &&
|
||||
mediaType !== "person"
|
||||
) {
|
||||
console.info(
|
||||
"Search: Unsupported media type found in only result.. not redirecting.",
|
||||
mediaType,
|
||||
);
|
||||
} else {
|
||||
goto(`/${data.results[0].media_type}/${data.results[0].id}`);
|
||||
if (extProvider.provider === "igdb") {
|
||||
console.log("Search: Performing igdb id search.");
|
||||
const resp = await searchGamesById(extProvider.id);
|
||||
const data = resp.data;
|
||||
const resultsAmt = data.length;
|
||||
if (!data || resultsAmt <= 0) {
|
||||
console.warn("Search: No results from game id search.");
|
||||
return;
|
||||
}
|
||||
if (resultsAmt === 1) {
|
||||
console.info(
|
||||
"Search: Only one result from game id search. Redirecting..",
|
||||
data[0],
|
||||
);
|
||||
|
||||
goto(`/game/${data[0].id}`);
|
||||
return;
|
||||
}
|
||||
allSearchResults.push(...data);
|
||||
} else if (
|
||||
extProvider.provider === "tv" ||
|
||||
extProvider.provider === "movie"
|
||||
) {
|
||||
// HACK I can't be bothered doing a test api call here,
|
||||
// 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(`/${extProvider.provider}/${extProvider.id}`);
|
||||
return;
|
||||
} else {
|
||||
// Else call tmdb `external id` endpoint
|
||||
console.log("Search: Performing external id search.");
|
||||
const resp = await searchExternalId(
|
||||
extProvider.id,
|
||||
extProvider.provider,
|
||||
);
|
||||
const data = resp.data;
|
||||
if (!data || !data.results) {
|
||||
console.warn("Search: No results from external id search.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
data.results.length === 1 &&
|
||||
data.results[0].media_type &&
|
||||
data.results[0].id
|
||||
) {
|
||||
console.info(
|
||||
"Search: Only one result from external id search. Redirecting..",
|
||||
data.results[0],
|
||||
);
|
||||
const mediaType = data.results[0].media_type;
|
||||
if (
|
||||
mediaType !== "movie" &&
|
||||
mediaType !== "tv" &&
|
||||
mediaType !== "person"
|
||||
) {
|
||||
console.info(
|
||||
"Search: Unsupported media type found in only result.. not redirecting.",
|
||||
mediaType,
|
||||
);
|
||||
} else {
|
||||
goto(`/${data.results[0].media_type}/${data.results[0].id}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
allSearchResults.push(...data.results);
|
||||
}
|
||||
console.info("Search: Multiple results from external id search.");
|
||||
allSearchResults.push(...data.results);
|
||||
searchResults = allSearchResults;
|
||||
curPage++;
|
||||
} else if (activeSearchFilter) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
:root {
|
||||
--bg-color: white;
|
||||
--bg-color-accent: rgb(180, 180, 180);
|
||||
--nav-color: rgba(255, 255, 255, 0.8);
|
||||
--text-color: black;
|
||||
--text-color-accent: rgb(90, 90, 90);
|
||||
--accent-color: rgba(128, 128, 128, 0.226);
|
||||
--accent-color-hover: rgba(46, 46, 46);
|
||||
--backdrop-filter: blur(4px) grayscale(80%);
|
||||
@@ -14,8 +16,10 @@
|
||||
|
||||
:root.theme-dark {
|
||||
--bg-color: rgb(15, 15, 15);
|
||||
--bg-color-accent: rgb(70, 70, 70);
|
||||
--nav-color: rgba(15, 15, 15, 0.438);
|
||||
--text-color: white;
|
||||
--text-color-accent: rgb(180, 180, 180);
|
||||
--accent-color: rgba(46, 46, 46);
|
||||
--accent-color-hover: rgba(255, 255, 255, 0.8);
|
||||
--backdrop-filter: blur(0.5px) grayscale(50%);
|
||||
@@ -27,7 +31,9 @@
|
||||
}
|
||||
|
||||
$bg-color: var(--bg-color);
|
||||
$bg-color-accent: var(--bg-color-accent);
|
||||
$text-color: var(--text-color);
|
||||
$text-color-accent: var(--text-color-accent);
|
||||
$placeholder-color: var(--placeholder-color);
|
||||
$accent-color: var(--accent-color);
|
||||
$accent-color-hover: var(--accent-color-hover);
|
||||
|
||||
Reference in New Issue
Block a user