Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2495895717 | |||
| 40620d93b4 | |||
| 1c351eb867 | |||
| a6cdd0ab0b | |||
| 365c2a7587 |
@@ -130,7 +130,11 @@ anything.
|
||||
|
||||
Here, intent is `Episode.wanted` and `Movie.wanted` only.
|
||||
`Series.auto_track` is not intent — it is a rule one level up: when metadata
|
||||
reveals a new season, that season becomes tracked. An untracked series where you
|
||||
reveals a new season, that season becomes tracked. The rule applies from the
|
||||
second refresh onward: seasons revealed by a series' first refresh — the whole
|
||||
back catalogue present at add time — are never tracked by it. Only seasons that
|
||||
appear after the series was added are covered; back-catalogue seasons are picked
|
||||
by hand. An untracked series where you
|
||||
manually marked S02 needs no special case: three wanted episodes, nothing else.
|
||||
|
||||
`Season.tracked` is also a rule, not intent. Turning tracking on marks every
|
||||
@@ -160,6 +164,12 @@ behind one toggle. A one-off season grab therefore disappears from the default
|
||||
view by itself once satisfied, and a tracked show reappears by itself when a new
|
||||
season is announced. Nothing to remember to flip.
|
||||
|
||||
Status chips carry state colour, not a single informational hue: green
|
||||
(`--signal-ok`) for on disk or complete, amber (`--signal-warn`) for wanted but
|
||||
not yet found, violet (`--status-airing`) for downloading, neutral for
|
||||
untracked. The word stays present in every chip, so state survives with colour
|
||||
removed.
|
||||
|
||||
Season 0 is invisible to all of it: derived status ignores season 0 episodes
|
||||
entirely, so a manually wanted special cannot pin a series at `incomplete` or
|
||||
hold back `ended`. The accepted consequence is that specials are visible and
|
||||
@@ -477,7 +487,10 @@ lower-priority crate** (§9.4), not the primary shape.
|
||||
|
||||
One box. Two grouped result sets: **in library** first (title match, and for TV
|
||||
also episode title, so `bluey hospital` finds the episode), **on TMDB** below.
|
||||
Enter on a TMDB result opens the add flow with root and policy pre-filled.
|
||||
An episode row surfaces only when the query matches something beyond the series
|
||||
title: a query satisfied by the series title alone returns the series row and
|
||||
nothing below it. Enter on a TMDB result opens the add flow with root and
|
||||
policy pre-filled.
|
||||
|
||||
The same box accepts a raw TMDB or IMDb ID, and a pasted magnet or `.torrent`,
|
||||
which skips to the manual-grab flow.
|
||||
@@ -549,12 +562,16 @@ Not notified: grabs, searches, downloads starting or finishing, soft fails.
|
||||
|
||||
One detail surface per kind: `/movies/{id}` and `/series/{id}` (#129). For a
|
||||
movie the release deck becomes a section of the page, and `/movies/{id}/releases`
|
||||
keeps resolving; series keeps its season-and-episode shape.
|
||||
keeps resolving; series keeps its season-and-episode shape, seasons ordered
|
||||
newest-first within the series and episodes newest-first within each season —
|
||||
the seasons you are deciding about now are at the top, not after nine rows of
|
||||
back catalogue.
|
||||
|
||||
**TMDB is the only metadata source.** The rating shown anywhere is TMDB's
|
||||
`vote_average` with its `vote_count`. No OMDb, no IMDb or Rotten Tomatoes
|
||||
scores — each would need a second upstream, a second key and a second thing
|
||||
that can be down.
|
||||
`vote_average` with its `vote_count`, rendered as amber stars (`--signal-warn`
|
||||
— the same state colour a wanted chip carries). No OMDb, no IMDb or Rotten
|
||||
Tomatoes scores — each would need a second upstream, a second key and a second
|
||||
thing that can be down.
|
||||
|
||||
**Images are hotlinked** from `image.tmdb.org`. The API returns TMDB path
|
||||
fragments, never URLs; the browser composes the URL and chooses the size. No
|
||||
@@ -565,11 +582,6 @@ image proxy and no image cache in the service.
|
||||
and `vote_average`, stored on `movies` and `series` and written by the daily
|
||||
metadata refresh (§8), so library views render without a TMDB call.
|
||||
|
||||
**Cast** is the top 10 billed — profile photo, actor name, character name —
|
||||
linking out to that person's TMDB page. This does not contradict §2: nothing
|
||||
about a person is stored, tracked or searched on. The non-goal is following
|
||||
people, not naming them.
|
||||
|
||||
**External links** are TMDB always, IMDb for movies, TVDB for series — all from
|
||||
ids the app already holds — plus a Rotten Tomatoes *search* link, which is a
|
||||
query URL, not a resolved title page.
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
<header class="deck-head library-bar">
|
||||
<h2 class="deck-label">library</h2>
|
||||
<span class="deck-count readout" id="library-summary"></span>
|
||||
<div class="view-toggle">
|
||||
<button type="button" class="view-btn" id="library-view-grid" aria-pressed="true">grid</button>
|
||||
<button type="button" class="view-btn" id="library-view-list" aria-pressed="false">list</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="bucket-toggle readout"
|
||||
|
||||
+215
-16
@@ -1305,6 +1305,12 @@ function castItem(member: MetadataCastMember): HTMLLIElement {
|
||||
img.src = image;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
// hotlinked art can 404 (§9.6); fall back to the same empty frame a
|
||||
// person with no photo gets, rather than a broken-image glyph.
|
||||
img.addEventListener("error", () => {
|
||||
img.remove();
|
||||
photo.classList.add("cast-photo-none");
|
||||
});
|
||||
photo.append(img);
|
||||
} else {
|
||||
photo.classList.add("cast-photo-none");
|
||||
@@ -2380,6 +2386,25 @@ interface LibraryGroup {
|
||||
rows: HTMLUListElement;
|
||||
}
|
||||
|
||||
/* ---- §9.6 poster grid (#151) -------------------------------------------- */
|
||||
|
||||
type LibraryShape = "grid" | "list";
|
||||
|
||||
const LIBRARY_SHAPE_KEY = "arr.libraryView";
|
||||
|
||||
/**
|
||||
* The grid is default and the choice is per-person — no server state. A
|
||||
* corrupt or foreign value falls back to the grid rather than breaking open.
|
||||
*/
|
||||
function loadLibraryShape(): LibraryShape {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LIBRARY_SHAPE_KEY);
|
||||
return stored === "list" ? "list" : "grid";
|
||||
} catch {
|
||||
return "grid";
|
||||
}
|
||||
}
|
||||
|
||||
function libraryMain(
|
||||
board: HTMLElement,
|
||||
movieDetail: MovieView,
|
||||
@@ -2392,6 +2417,8 @@ function libraryMain(
|
||||
const summary = must<HTMLElement>("#library-summary");
|
||||
const toggle = must<HTMLButtonElement>("#library-toggle");
|
||||
const status = must<HTMLElement>("#library-status");
|
||||
const gridBtn = must<HTMLButtonElement>("#library-view-grid");
|
||||
const listBtn = must<HTMLButtonElement>("#library-view-list");
|
||||
const groups: { series: LibraryGroup; movies: LibraryGroup } = {
|
||||
series: {
|
||||
section: must<HTMLElement>("#library-series"),
|
||||
@@ -2407,11 +2434,30 @@ function libraryMain(
|
||||
|
||||
// §4.2: ONE toggle for everything the default view leaves out
|
||||
let showAll = false;
|
||||
// §9.6 library view: the poster grid is default, per-person, localStorage
|
||||
let shape = loadLibraryShape();
|
||||
let data: { series: LibrarySeries[]; movies: LibraryMovie[] } | null = null;
|
||||
let roots: Root[] = [];
|
||||
// guards a stale fetch from painting over a newer view
|
||||
let sequence = 0;
|
||||
|
||||
function setShape(next: LibraryShape) {
|
||||
shape = next;
|
||||
try {
|
||||
window.localStorage.setItem(LIBRARY_SHAPE_KEY, next);
|
||||
} catch {
|
||||
// private mode or full storage: the choice just does not persist
|
||||
}
|
||||
gridBtn.setAttribute("aria-pressed", String(shape === "grid"));
|
||||
listBtn.setAttribute("aria-pressed", String(shape === "list"));
|
||||
render();
|
||||
}
|
||||
|
||||
gridBtn.addEventListener("click", () => setShape("grid"));
|
||||
listBtn.addEventListener("click", () => setShape("list"));
|
||||
gridBtn.setAttribute("aria-pressed", String(shape === "grid"));
|
||||
listBtn.setAttribute("aria-pressed", String(shape === "list"));
|
||||
|
||||
function setStatus(text: string | null, tone?: "fault") {
|
||||
status.hidden = text === null;
|
||||
status.textContent = text ?? "";
|
||||
@@ -2432,6 +2478,7 @@ function libraryMain(
|
||||
}
|
||||
|
||||
function renderGroup<T>(group: LibraryGroup, items: T[], build: (item: T) => HTMLLIElement) {
|
||||
group.rows.className = shape === "grid" ? "deck-rows grid-rows" : "deck-rows";
|
||||
group.rows.replaceChildren();
|
||||
group.section.hidden = items.length === 0;
|
||||
group.count.textContent = String(items.length);
|
||||
@@ -2457,18 +2504,23 @@ function libraryMain(
|
||||
|
||||
const seriesShown = showAll ? library.series : library.series.filter(seriesNeedsAttention);
|
||||
const moviesShown = showAll ? library.movies : library.movies.filter(movieNeedsAttention);
|
||||
renderGroup(groups.series, seriesShown, (series) =>
|
||||
seriesRow(series, roots, (id, origin) => {
|
||||
navigate({ kind: "series", seriesId: id });
|
||||
void seriesDetail.open(id, origin, view, { kind: "library" });
|
||||
}),
|
||||
);
|
||||
renderGroup(groups.movies, moviesShown, (movie) =>
|
||||
libraryRow(movie, roots, (target, origin) => {
|
||||
navigate({ kind: "movie", movieId: target.id });
|
||||
void movieDetail.open(target.id, origin, view, { kind: "library" });
|
||||
}),
|
||||
);
|
||||
|
||||
const openSeries = (id: number, origin: HTMLElement) => {
|
||||
navigate({ kind: "series", seriesId: id });
|
||||
void seriesDetail.open(id, origin, view, { kind: "library" });
|
||||
};
|
||||
const openMovie = (movie: LibraryMovie, origin: HTMLElement) => {
|
||||
navigate({ kind: "movie", movieId: movie.id });
|
||||
void movieDetail.open(movie.id, origin, view, { kind: "library" });
|
||||
};
|
||||
|
||||
if (shape === "grid") {
|
||||
renderGroup(groups.series, seriesShown, (series) => seriesCard(series, roots, openSeries));
|
||||
renderGroup(groups.movies, moviesShown, (movie) => movieCard(movie, roots, openMovie));
|
||||
} else {
|
||||
renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots, openSeries));
|
||||
renderGroup(groups.movies, moviesShown, (movie) => libraryRow(movie, roots, openMovie));
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
setStatus("library is empty — the search box above adds titles");
|
||||
@@ -2601,6 +2653,151 @@ function seriesRow(
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* One §9.6 grid tile (#151): poster at w342, title and year beneath, then
|
||||
* the chips the row view carries — a card is legible before it is pretty.
|
||||
* A missing poster becomes a same-ratio placeholder carrying its title,
|
||||
* so the grid never reflows and never has a hole in it.
|
||||
*/
|
||||
function libraryCard(
|
||||
posterPath: string | null,
|
||||
title: string,
|
||||
year: number | null,
|
||||
chips: HTMLSpanElement[],
|
||||
open: (origin: HTMLElement) => void,
|
||||
): HTMLLIElement {
|
||||
const item = document.createElement("li");
|
||||
const card = document.createElement("button");
|
||||
card.type = "button";
|
||||
card.className = "card";
|
||||
|
||||
const art = document.createElement("span");
|
||||
art.className = "card-art";
|
||||
if (posterPath === null) {
|
||||
const blank = document.createElement("span");
|
||||
blank.className = "card-blank";
|
||||
const blankTitle = document.createElement("span");
|
||||
blankTitle.className = "card-blank-title";
|
||||
blankTitle.textContent = title;
|
||||
blank.append(blankTitle);
|
||||
art.append(blank);
|
||||
} else {
|
||||
const img = document.createElement("img");
|
||||
img.className = "card-poster";
|
||||
img.loading = "lazy";
|
||||
img.src = tmdbImage(posterPath, "w342") ?? "";
|
||||
img.alt = `${title} poster`;
|
||||
// hotlinked art can 404; fall back to the titled blank rather than a hole
|
||||
img.addEventListener("error", () => {
|
||||
const blankTitle = document.createElement("span");
|
||||
blankTitle.className = "card-blank-title";
|
||||
blankTitle.textContent = title;
|
||||
const blank = document.createElement("span");
|
||||
blank.className = "card-blank";
|
||||
blank.append(blankTitle);
|
||||
img.replaceWith(blank);
|
||||
});
|
||||
art.append(img);
|
||||
}
|
||||
|
||||
const id = document.createElement("span");
|
||||
id.className = "card-id";
|
||||
const name = document.createElement("span");
|
||||
name.className = "card-name";
|
||||
name.textContent = title;
|
||||
const when = document.createElement("span");
|
||||
when.className = "card-year readout dim";
|
||||
when.textContent = year === null ? "—" : String(year);
|
||||
id.append(name, when);
|
||||
|
||||
const chipLine = document.createElement("span");
|
||||
chipLine.className = "card-chips";
|
||||
for (const chipEl of chips) {
|
||||
chipLine.append(chipEl);
|
||||
}
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "card-body";
|
||||
body.append(id, chipLine);
|
||||
|
||||
card.append(art, body);
|
||||
card.addEventListener("click", () => {
|
||||
open(card);
|
||||
});
|
||||
item.append(card);
|
||||
return item;
|
||||
}
|
||||
|
||||
function seriesCard(
|
||||
series: LibrarySeries,
|
||||
roots: Root[],
|
||||
open: (id: number, origin: HTMLElement) => void,
|
||||
): HTMLLIElement {
|
||||
const root = roots.find((candidate) => candidate.id === series.root_id);
|
||||
const chips = [chip(root ? root.audience : `root ${series.root_id}`)];
|
||||
if (series.wanted_episodes > 0) {
|
||||
chips.push(
|
||||
chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => {
|
||||
span.setAttribute(
|
||||
"aria-label",
|
||||
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (series.blocked) {
|
||||
chips.push(chip("blocked"));
|
||||
}
|
||||
chips.push(
|
||||
chip(series.status, (span) => {
|
||||
span.dataset.status = series.status;
|
||||
}),
|
||||
);
|
||||
const rating = ratingChip(series.vote_average);
|
||||
if (rating !== null) {
|
||||
chips.push(rating);
|
||||
}
|
||||
return libraryCard(series.poster_path, series.title, series.year, chips, (origin) =>
|
||||
open(series.id, origin),
|
||||
);
|
||||
}
|
||||
|
||||
function movieCard(
|
||||
movie: LibraryMovie,
|
||||
roots: Root[],
|
||||
open: (movie: LibraryMovie, origin: HTMLElement) => void,
|
||||
): HTMLLIElement {
|
||||
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
||||
const chips = [chip(root ? root.audience : `root ${movie.root_id}`)];
|
||||
chips.push(
|
||||
chip(movie.state, (span) => {
|
||||
span.dataset.movieState = movie.state;
|
||||
}),
|
||||
);
|
||||
// §5.7 honesty: a waived import is never presented as a clean match
|
||||
const waiver = waiverLabel(movie.waiver);
|
||||
if (waiver !== null) {
|
||||
chips.push(
|
||||
chip(waiver, (span) => {
|
||||
span.dataset.verdict = "waived";
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!movie.wanted) {
|
||||
chips.push(chip("not wanted"));
|
||||
}
|
||||
if (movie.blocked) {
|
||||
chips.push(chip("blocked"));
|
||||
}
|
||||
const rating = ratingChip(movie.vote_average);
|
||||
if (rating !== null) {
|
||||
chips.push(rating);
|
||||
}
|
||||
return libraryCard(movie.poster_path, movie.title, movie.year, chips, (origin) =>
|
||||
open(movie, origin),
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- tv release deck (§9.3 for season packs and single episodes) ------- */
|
||||
|
||||
interface TvDeckRequest {
|
||||
@@ -3321,16 +3518,18 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
}
|
||||
|
||||
async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) {
|
||||
// this page is one of `views`, so the loop below calls its own hide(),
|
||||
// which nulls seriesId — hide first, then take state (same as a movie)
|
||||
tvDeck.hide();
|
||||
for (const sibling of views) {
|
||||
sibling.hide();
|
||||
}
|
||||
seriesId = id;
|
||||
origin = from;
|
||||
returnTo = container;
|
||||
parentRoute = parent;
|
||||
expanded = new Set();
|
||||
focusKey = null;
|
||||
tvDeck.hide();
|
||||
for (const sibling of views) {
|
||||
sibling.hide();
|
||||
}
|
||||
board.hidden = true;
|
||||
deckEl.hidden = true;
|
||||
view.hidden = false;
|
||||
|
||||
+162
-1
@@ -1159,9 +1159,157 @@ body {
|
||||
}
|
||||
|
||||
/* the toggle's own padding would inset its text past the chip column's
|
||||
right rag; drop it so SATISFIED sits flush with the chips below */
|
||||
right rag; with the shape toggle right of it, SATISFIED sits flush
|
||||
against its neighbour instead of the deck edge */
|
||||
.library-bar .bucket-toggle {
|
||||
padding: 0;
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- library poster grid (§9.6, issue 151) ------------------------------ */
|
||||
|
||||
/* the shape toggle: a joined pair, engaged side lit like any view control */
|
||||
.view-toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
min-height: 1.75rem;
|
||||
padding: 0 var(--space-2);
|
||||
font-family: var(--font-readout);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-faint);
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms var(--ease-out),
|
||||
color 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.view-btn + .view-btn {
|
||||
border-left-width: 0;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
|
||||
.view-btn[aria-pressed="true"] {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* auto-fill keeps every tile the same width down to a phone, where three
|
||||
narrow-but-legible columns beat two oversized ones */
|
||||
.deck-rows.grid-rows {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms var(--ease-out),
|
||||
transform 150ms var(--ease-out);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* the poster is framed evidence, like every other image on a panel */
|
||||
.card-art {
|
||||
display: block;
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.card-poster {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: var(--panel-raised);
|
||||
}
|
||||
|
||||
/* same ratio as a poster, carrying the title: the grid never has a hole */
|
||||
.card-blank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--space-3);
|
||||
background: var(--panel-raised);
|
||||
box-shadow: inset 0 1px var(--highlight);
|
||||
}
|
||||
|
||||
.card-blank-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.card-id {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
flex: 1;
|
||||
/* two clamped lines: a long title stays readable where a single
|
||||
ellipsised line at tile width would not be */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.card-year {
|
||||
flex: none;
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
.card-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* ---- series detail (§4.1 + §4.2, issue 129) --------------------------- */
|
||||
@@ -1625,6 +1773,19 @@ body {
|
||||
gap: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
/* the library bar wraps to two lines at phone widths: identity and count
|
||||
on the first, shape and satisfied toggles on the second, each label
|
||||
whole instead of squeezed into a mid-word break */
|
||||
.library-bar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--space-2);
|
||||
}
|
||||
|
||||
.library-bar .bucket-toggle,
|
||||
.view-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rail-verdict {
|
||||
flex-basis: 100%;
|
||||
order: 3;
|
||||
|
||||
Reference in New Issue
Block a user