Compare commits

...

12 Commits

Author SHA1 Message Date
IRHM b6d6e66f04 v2.0.2 2025-02-16 21:18:27 +00:00
IRHM 0b4f3c8604 2.0.2-dev 2025-02-16 21:00:09 +00:00
Mr 0a5a4e39f5 DetailedMenu: Fix wlDetailedView not setting correctly (#814)
wasn't causing setter to trigger and save to localStorage
2025-02-16 19:57:55 +00:00
IRHM 2d8def18ed v2.0.1 2025-02-14 04:14:25 +00:00
IRHM d53f62e96c 2.0.1-dev 2025-02-14 03:50:53 +00:00
Mr 4dd0b419e0 SeasonsList: Horizontal scroller for mobile view (#808)
* SeasonsList: Turn into horizontal list for mobile

* SeasonsList: Make seasons sticky for mobile view
2025-02-14 03:48:50 +00:00
Antonio Sarro 41948f15ba SeasonsList: Show season status and episode count (#784)
* feat: improved overall look of the series season sidebar

* SeasonsList: Responsivity fixes

- Fix responsivity for seasons when names are very long (eg on shows that have custom season names)
- Increase status icon size
- Create color variables for season episodes text for resuability and to fix bug causing z index rendering issues with opacity usage.

* SeasonsList: checkSeasonStatus: Improve log

* SeasonsList: Remove $inspect: Causing errors in console for some reason

* vars: Fix --text-color-accent for dark theme

---------

Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2025-02-14 03:27:50 +00:00
Mr 49f0739503 Merge pull request #807 from sbondCo/search-by-id
Search by IGDB ID & TMDB ID support
2025-02-14 01:21:03 +00:00
IRHM 1c763e9761 search: by tmdb id, return after goto call 2025-02-14 01:17:32 +00:00
IRHM 18a30639b9 Search by tmdbId with movie or tv or series provider aliases
barbarically
2025-02-14 01:10:08 +00:00
IRHM a3847ccb3c Search by igdb id (with igdb or game provider aliases) 2025-02-14 00:58:48 +00:00
Mr 5f5eb3e9f2 game: Fix search query parameter (#806)
Was passing encoded query parameter, now unescaped first, fixing request to igdb.
2025-02-14 00:38:20 +00:00
11 changed files with 270 additions and 53 deletions
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "watcharr",
"version": "2.0.0",
"version": "2.0.2",
"private": true,
"scripts": {
"dev": "vite dev",
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -9,6 +9,7 @@
store.wlDetailedView = store.wlDetailedView.filter((a) => a !== d);
} else {
store.wlDetailedView.push(d);
store.wlDetailedView = store.wlDetailedView;
}
}
</script>
+4
View File
@@ -0,0 +1,4 @@
@mixin nav-blur {
backdrop-filter: blur(2.5px) saturate(120%);
background-color: var(--nav-color);
}
+1
View File
@@ -1,3 +1,4 @@
@import "./mixins.scss";
@import "./vars.scss";
@font-face {
+1 -2
View File
@@ -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;
+90 -32
View File
@@ -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) {
+6
View File
@@ -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);