Compare commits

...
11 Commits
Author SHA1 Message Date
peter 7339a0b84b release: 0.79.0 (improvement/e9-playlist)
gate / frontend (push) Failing after 1m43s
gate / backend (push) Successful in 2m48s
2026-09-03 21:59:31 +02:00
peter c960c07a6a release: 0.79.0 notes + version bump
E9 Playlist module: total playtime in both playlist headers, the header
compacted to two rows, and the sticky column header sitting flush. Plus the
six defects the epic-end deep review confirmed.
2026-09-03 21:59:09 +02:00
peter 3cfdb36f06 test(format): pin the sub-minute truncation (E9 R2)
Round 2 mutation-checked round 1's own fix and found the one line it left
uncovered: every table row was an integer, so `Math.floor(sec)` in the new
seconds tier could be deleted or swapped for round/ceil with the suite still
green — exactly the "later cleanup removes it as redundant" failure the fix's
comment claims to prevent. Two fractional rows kill all three mutants.
2026-09-03 21:51:14 +02:00
peter ada95d04a4 fix(playlists): one rendering for a playlist total, honest below a minute (E9 R1)
Epic-end deep review, round 1. Six findings survived verification; five of them
were one defect wearing different hats.

formatRuntime floored 1-59s to "0m". Harmless while it only ever formatted a
Plex movie's length, but 12D repurposed it for an aggregate, and a total is the
only input that can land under a minute: a playlist of Shorts reported "0m"
while the rail beside it read "0:45". Fixed in the function, not behind a
`>= 60` guard at each of the seven call sites — the invariant belongs where it
cannot be forgotten. Its contract comment claimed "Plex movie/episode lengths"
and was two epics stale.

The rail rendered the same playlist total as formatDuration (1:23:45) while the
new header rendered formatRuntime (1h 23m) — two shapes of one number, side by
side on one screen. The rail now uses formatRuntime too; formatTotalHours had
already recorded that H:MM:SS is wrong for an aggregate.

The rename input kept min-w-0 without flex-1 in the compacted row, so it was
the only shrinkable child beside a shrink-0 action cluster. The Plex half of
this same epic already had the fix; this restores the symmetry.

plex.browse.items had no plural forms, so a one-item Plex playlist read
"1 items". Pre-existing, but the new header is the call site where count=1 is
ordinary. Both languages get both forms, no key drift.

formatRuntime had no unit tests at all across seven call sites. Added a tier
table plus a sweep asserting no non-zero second ever renders as "0m".

Dropped after 3/3 adversarial refutations: the meta-line segment ordering
(the filtered count is labelled, and count-then-runtime is the convention) and
the null-duration lower bound (the server's own SUM has skipped NULLs since
long before this branch; changing only the client would split them).
2026-09-03 21:43:04 +02:00
peter 1b004e6ccf feat(plex): total playtime in the playlist header (E9 S3)
Parity with the YouTube playlist header (E9 12D): the Plex playlist detail
now carries a meta line under the title — "N items · Xh Ym", summed from the
items' duration_seconds. No backend change: leaf_card already serializes
duration_seconds for both movies and episodes, and no new i18n key either —
the count reuses plex.browse.items, the same key the collection header uses.

The title/rename input moved into a min-w-0 flex-1 column so the meta line
sits under both; the line is hidden on an empty playlist.
2026-09-02 18:30:06 +02:00
peter 95c33e0b0f refactor(playlists): compact the playlist header to two rows (E9 12A)
The header portals into the shell's FIXED toolbar band, so its height is
list height the user never gets back — and it was spending four stacked
blocks on it: title, meta line, an action row, then a separate view-control
row under the cover. Option B collapses that into two tight rows beside a
smaller cover: title + rename/YouTube-link on the first, with Play-all,
push/revert/delete and the unsynced badge pushed right; the meta line
(count · playtime · filtered) and the view controls (group-by, reset view,
save order, view status, undo/redo) sharing the second.

Every control and its gating is unchanged — the view-control group still
appears only for `items.length > 1 || viewActive`, so a one-item playlist
shows just the meta line.

Measured on a 32-item playlist: the band goes 170px -> 88px at rest
(122px while a view is active, where the status sentence wraps), which is
~80px more visible list.
2026-09-02 03:45:07 +02:00
peter 4e6bcbc078 fix(playlists): stop the table header shifting up on first scroll (E9 12B)
The playlist content wrapper carried `pt-3`, so its sticky column header sat
12px below the scroller top at rest and jumped up to `top:0` on the first
scroll. Drop the top padding so the header is flush from rest and never
shifts — the fixed toolbar band above supplies the breathing room, matching
the channel table's already-correct pattern.
2026-09-02 03:39:17 +02:00
peter e17fb5bbf5 feat(playlists): total playtime in header + sticky-header fade fix (E9 S1)
Surface the playlist's total playtime beside the item count in the detail
header (12D) — sums every item's duration (formatRuntime "h m", matching the
approved mockup), hidden when nothing sums. Data is already client-side; no
backend change.

Pass fadeTop:false for the playlists page (12B) so the shell's top edge-fade
no longer sits over the already-sticky column header (tableHeader.tsx:257),
matching the channels/audit pages.
2026-09-02 03:39:17 +02:00
peter a9044ece50 release: 0.78.8 (improvement/email-html-alternative)
gate / frontend (push) Failing after 1m40s
gate / backend (push) Successful in 2m40s
2026-09-02 03:24:59 +02:00
peter 473aafd1c8 release: bump VERSION to 0.78.8 + release notes 2026-09-02 03:24:56 +02:00
peter 7019e3afdf fix(email): give every mail an HTML alternative so its link is clickable
The bodies are text/plain only, so whether the URL in a verification or
password-reset mail is a link at all was the client's decision. Gmail, Outlook and
Apple Mail linkify plain text; lighter web clients render the body verbatim and leave
the link as dead text — in a message whose entire purpose is that one link.

_send now attaches an HTML part generated from the SAME plain body, rather than
per-template HTML that would have to be kept in sync with it: one converter, all
eleven templates, and text/plain stays the primary part and the source of truth.
Escaping runs before linkification — bodies interpolate untrusted values (a
requester's address, the operator contact) — so no caller can inject markup, and a
query string's `&` is already `&` when it reaches the href, which is what an HTML
attribute wants. Sentence-final punctuation is trimmed out of the href while
url-safe token characters (`-`, `_`) are left alone. Inline styles only, no images,
no external CSS, no tracking.
2026-09-02 03:13:23 +02:00
12 changed files with 450 additions and 171 deletions
+1 -1
View File
@@ -1 +1 @@
0.78.7 0.79.0
+47
View File
@@ -10,7 +10,9 @@ verification links, password resets and access-request notices, and the operator
to be a person telling them nothing arrived. to be a person telling them nothing arrived.
""" """
import html
import logging import logging
import re
import smtplib import smtplib
import ssl import ssl
from email.message import EmailMessage from email.message import EmailMessage
@@ -105,6 +107,48 @@ def _admin_contact() -> str | None:
return next(iter(sorted(settings.admin_email_set)), None) return next(iter(sorted(settings.admin_email_set)), None)
# A bare URL in a text/plain mail is only clickable if the CLIENT decides to linkify it. Gmail,
# Outlook and Apple Mail do; plenty of lighter web clients render the body verbatim and leave the
# link as dead text — which, for a verification or password-reset mail, is the whole message. So
# every mail also carries an HTML alternative built from the SAME plain body (below), rather than
# per-template HTML that would have to be kept in sync with it.
#
# `[^\s<]` stops at whitespace and at the `<` of any following markup; the trailing-punctuation trim
# keeps a sentence-final period out of the href. `;` and `:` are deliberately NOT trimmed — escaping
# turns `&` into `&amp;`, so a query string can legitimately end in a semicolon.
_URL_RE = re.compile(r"https?://[^\s<]+")
def _html_body(text: str) -> str:
"""Render a plain-text mail body as minimal HTML: escaped, blank-line-separated paragraphs, with
real links. Escaping happens FIRST and the linkifier then runs over the escaped text — bodies
interpolate untrusted values (a requester's email address, the operator contact), so no caller
can inject markup, and `&` inside a URL is already `&amp;` by the time it reaches the href, which
is what an HTML attribute wants anyway."""
def _anchor(m: re.Match[str]) -> str:
url = m.group(0)
trail = ""
while url and url[-1] in ".,!?)":
url, trail = url[:-1], url[-1] + trail
if not url:
return trail
return f'<a href="{url}">{url}</a>{trail}'
paragraphs = [p for p in html.escape(text).split("\n\n") if p.strip()]
rendered = "".join(
f"<p>{_URL_RE.sub(_anchor, p).replace(chr(10), '<br>')}</p>" for p in paragraphs
)
# Inline styles only — many clients drop <style> blocks. No images, no external CSS, no tracking:
# the HTML part exists to make the link clickable, not to become a template to maintain.
return (
'<html><body style="margin:0;padding:16px;font-family:system-ui,-apple-system,'
"'Segoe UI',sans-serif;font-size:15px;line-height:1.6\">"
f'<div style="max-width:560px">{rendered}</div>'
"</body></html>"
)
def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -> bool: def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -> bool:
recipients = [e for e in to if e] recipients = [e for e in to if e]
if not recipients: if not recipients:
@@ -123,7 +167,10 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
msg["Date"] = formatdate(localtime=True) msg["Date"] = formatdate(localtime=True)
if reply_to: if reply_to:
msg["Reply-To"] = reply_to msg["Reply-To"] = reply_to
# text/plain stays the primary part (it is the source of truth, and a text-only client still
# gets the full message); the HTML alternative below is what makes the link clickable everywhere.
msg.set_content(body) msg.set_content(body)
msg.add_alternative(_html_body(body), subtype="html")
try: try:
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s: with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
s.starttls(context=ssl.create_default_context()) s.starttls(context=ssl.create_default_context())
+119
View File
@@ -0,0 +1,119 @@
"""Every outbound mail carries an HTML alternative, so its link is actually clickable.
The bodies are plain text and stay that way — but a bare URL in a text/plain mail is only a link if
the CLIENT chooses to linkify it. Gmail/Outlook/Apple Mail do; lighter web clients render the body
verbatim and leave the verification or password-reset link as dead text, which is the entire point of
those two messages. So `_send` now attaches an HTML part generated from the same plain body.
These are pure-logic tests except the last one, which drives the real `_send` against a fake SMTP so
the wire format itself is pinned, not just the helper.
"""
import pytest
from app import email as email_mod
_SMTP_OK = {
"host": "smtp.example.com",
"port": 587,
"user": "u",
"password": "p",
"from": "Siftlode <u@example.com>",
}
def test_a_bare_url_becomes_a_real_link():
# THE regression: this is what a client that does not linkify plain text was leaving as dead text.
out = email_mod._html_body(
"Confirm your email:\n\nhttps://siftlode.example/#verify=tok123\n\n— Siftlode"
)
assert '<a href="https://siftlode.example/#verify=tok123">' in out
def test_interpolated_content_cannot_inject_markup():
# Bodies carry untrusted values — a requester's address in the admin notice, the operator contact.
# Escaping must run BEFORE linkification, or a crafted address becomes markup in every admin's mail.
out = email_mod._html_body('A request from <script>alert("x")</script> & co')
assert "<script>" not in out
assert "&lt;script&gt;" in out
assert "&amp; co" in out
def test_a_query_string_keeps_its_escaped_ampersand_inside_the_href():
# `&` is escaped to `&amp;` before the linkifier sees it, which is also what an HTML attribute
# wants — so the href must carry the escaped form and nothing may be trimmed off it.
out = email_mod._html_body("https://siftlode.example/x?a=1&b=2")
assert (
'<a href="https://siftlode.example/x?a=1&amp;b=2">https://siftlode.example/x?a=1&amp;b=2</a>'
in out
)
@pytest.mark.parametrize(
"body,href,trail",
[
("See https://siftlode.example/x.", "https://siftlode.example/x", "."),
("See https://siftlode.example/x, then sign in", "https://siftlode.example/x", ","),
("See (https://siftlode.example/x)", "https://siftlode.example/x", ")"),
],
)
def test_sentence_punctuation_stays_out_of_the_href(body, href, trail):
out = email_mod._html_body(body)
assert f'<a href="{href}">{href}</a>{trail}' in out
def test_a_token_ending_in_a_url_safe_character_is_not_trimmed():
# token_urlsafe output can end in `-` or `_`; trimming either would hand the user a dead link.
for tok in ("abc-", "abc_"):
out = email_mod._html_body(f"https://siftlode.example/#reset={tok}")
assert f'href="https://siftlode.example/#reset={tok}"' in out
def test_blank_lines_become_paragraphs_and_single_breaks_stay_inline():
out = email_mod._html_body("Hi,\n\nline one\nline two\n\n— Siftlode")
assert out.count("<p>") == 3
assert "<p>line one<br>line two</p>" in out
def test_the_sent_message_is_multipart_with_the_plain_body_intact(monkeypatch):
# The wire format, not just the helper: text/plain stays the primary part and the source of
# truth, with text/html alongside it.
monkeypatch.setattr(email_mod, "_smtp", lambda: _SMTP_OK)
sent = []
class _FakeSMTP:
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def starttls(self, *a, **kw):
pass
def login(self, *a, **kw):
pass
def send_message(self, msg):
sent.append(msg)
monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSMTP)
assert (
email_mod.send_verify_email("someone@example.com", "https://siftlode.example/#verify=tok")
is True
)
(msg,) = sent
assert msg.get_content_type() == "multipart/alternative"
plain = msg.get_body(preferencelist=("plain",))
rich = msg.get_body(preferencelist=("html",))
assert plain is not None and rich is not None
# The plain part must be byte-for-byte the template's own body — the HTML part is derived from
# it, never a second copy that could drift.
assert "https://siftlode.example/#verify=tok" in plain.get_content()
assert "<a href=" not in plain.get_content()
assert '<a href="https://siftlode.example/#verify=tok">' in rich.get_content()
+54 -30
View File
@@ -39,6 +39,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName"; import { playlistName } from "../lib/playlistName";
import { useUndoable } from "../lib/useUndoable"; import { useUndoable } from "../lib/useUndoable";
import { cycleSort, type SortState } from "../lib/columnSort"; import { cycleSort, type SortState } from "../lib/columnSort";
import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView"; import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal")); const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
@@ -259,6 +260,13 @@ export default function Playlists() {
return (v: Video) => m.get(v.id) ?? 0; return (v: Video) => m.get(v.id) ?? 0;
}, [items]); }, [items]);
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not
// the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums.
const totalSeconds = useMemo(
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0),
[items],
);
const columns = useMemo( const columns = useMemo(
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }), () => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
[t, storedIndex, onPlay, onRemove, canEdit], [t, storedIndex, onPlay, onRemove, canEdit],
@@ -591,18 +599,26 @@ export default function Playlists() {
} }
return ( return (
<div className="px-4 pb-4 pt-3"> // No top padding: the sticky column header must sit flush at the scroller top so it doesn't
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
<div className="px-4 pb-4">
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's {/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */} fixed band, so only the video list scrolls. */}
<PageToolbar> <PageToolbar>
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto"> {/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
<div className="flex gap-3 items-start mb-4"> view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0"> row it sheds is list height won back — the old layout stacked three rows next to the
cover plus a fourth block under it, for roughly twice this height. */}
<div className="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && ( {items[0]?.thumbnail_url && (
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" /> <img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
{renaming ? ( {renaming ? (
<input <input
autoFocus autoFocus
@@ -614,10 +630,10 @@ export default function Playlists() {
if (e.key === "Escape") setRenaming(false); if (e.key === "Escape") setRenaming(false);
}} }}
aria-label={t("playlists.rename")} aria-label={t("playlists.rename")}
className="text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40" className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/> />
) : ( ) : (
<div className="flex items-center gap-2 min-w-0"> <>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2> <h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && ( {editable && (
<button <button
@@ -644,13 +660,18 @@ export default function Playlists() {
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3.5 h-3.5" />
</a> </a>
)} )}
</div> </>
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)} )}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</div>
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button <button
onClick={() => shown.length && setPlayingId(shown[0]!.id)} onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length} disabled={!shown.length}
@@ -702,21 +723,20 @@ export default function Playlists() {
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
)} )}
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent ml-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && ( {(items.length > 1 || viewActive) && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl"> <>
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none"> <label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input <input
type="checkbox" type="checkbox"
@@ -742,12 +762,13 @@ export default function Playlists() {
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")} <Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button> </button>
)} )}
{/* ONE sentence that names the state. Three loose texts used to pile up here (reset {/* ONE sentence that names the state. Three loose texts used to pile up here
link + save hint + "reordering is off"), and none of them answered the question a (reset link + save hint + "reordering is off"), and none of them answered the
user actually has — "did I just change my playlist?". Worse, sorting a list whose question a user actually has — "did I just change my playlist?". Worse,
stored order ALREADY matches the sort produced a warning, no Save button and no sorting a list whose stored order ALREADY matches the sort produced a
visible change, which reads as a malfunction. Now the line says which of the three warning, no Save button and no visible change, which reads as a malfunction.
situations you are in; the disabled grip still explains itself on hover/focus. */} Now the line says which of the three situations you are in; the disabled grip
still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>} {viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && ( {canEdit && (
<div className="ml-auto"> <div className="ml-auto">
@@ -759,9 +780,12 @@ export default function Playlists() {
/> />
</div> </div>
)} )}
</div> </>
)} )}
</div> </div>
</div>
</div>
</div>
</PageToolbar> </PageToolbar>
{items.length === 0 ? ( {items.length === 0 ? (
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage"; import { LS, readAccountMerged, writeAccount } from "../lib/storage";
@@ -247,7 +247,7 @@ export default function PlaylistsRail({
<div className="text-2xs text-muted"> <div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })} {t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 && {pl.total_duration_seconds > 0 &&
` · ${formatDuration(pl.total_duration_seconds)}`} ` · ${formatRuntime(pl.total_duration_seconds)}`}
</div> </div>
</div> </div>
</button> </button>
+17 -2
View File
@@ -32,6 +32,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type PlexCard } from "../lib/api"; import { api, type PlexCard } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { LoadingState, StateMessage } from "./QueryState"; import { LoadingState, StateMessage } from "./QueryState";
@@ -387,6 +388,12 @@ export default function PlexPlaylistView({
}, [q.data]); }, [q.data]);
const blocks = useMemo(() => buildBlocks(items), [items]); const blocks = useMemo(() => buildBlocks(items), [items]);
const order = useMemo(() => items.map((i) => i.id), [items]); const order = useMemo(() => items.map((i) => i.id), [items]);
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
// an unknown duration count as 0, and the runtime is dropped from the meta line when nothing sums.
const totalSeconds = useMemo(
() => items.reduce((acc, it) => acc + (it.duration_seconds ?? 0), 0),
[items],
);
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) }); qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
@@ -517,6 +524,7 @@ export default function PlexPlaylistView({
<div className="glass rounded-2xl p-4 sm:p-6"> <div className="glass rounded-2xl p-4 sm:p-6">
<div className="mb-4 flex flex-wrap items-center gap-3"> <div className="mb-4 flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
{renaming ? ( {renaming ? (
<input <input
autoFocus autoFocus
@@ -524,11 +532,18 @@ export default function PlexPlaylistView({
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && rename()} onKeyDown={(e) => e.key === "Enter" && rename()}
onBlur={rename} onBlur={rename}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40" className="glass-card w-full rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/> />
) : ( ) : (
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1> <h1 className="truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
)} )}
{items.length > 0 && (
<div className="mt-0.5 text-sm text-muted">
{t("plex.browse.items", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
</div>
)}
</div>
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */} {/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
<ViewSwitcher <ViewSwitcher
value={layout} value={layout}
+1 -1
View File
@@ -94,7 +94,7 @@ export type PageDef = {
export const PAGE_CONTENT: Record<Page, PageDef> = { export const PAGE_CONTENT: Record<Page, PageDef> = {
feed: { Component: FeedPage }, feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" }, channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, maxWidth: "64rem" }, playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "1600px" }, plex: { Component: PlexBrowse, maxWidth: "1600px" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" }, notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
messages: { Component: MessagesPage }, messages: { Component: MessagesPage },
+2 -1
View File
@@ -350,7 +350,8 @@
"browse": "Browse" "browse": "Browse"
}, },
"browse": { "browse": {
"items": "{{count}} items" "items_one": "{{count}} item",
"items_other": "{{count}} items"
}, },
"discover": { "discover": {
"tab": "Discover", "tab": "Discover",
+2 -1
View File
@@ -350,7 +350,8 @@
"browse": "Böngészés" "browse": "Böngészés"
}, },
"browse": { "browse": {
"items": "{{count}} elem" "items_one": "{{count}} elem",
"items_other": "{{count}} elem"
}, },
"discover": { "discover": {
"tab": "Felfedezés", "tab": "Felfedezés",
+35 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RELATIVE_UNITS, relativeTime, relativeFromMs } from "./format"; import { RELATIVE_UNITS, formatRuntime, relativeTime, relativeFromMs } from "./format";
import en from "../i18n/locales/en/time.json"; import en from "../i18n/locales/en/time.json";
import hu from "../i18n/locales/hu/time.json"; import hu from "../i18n/locales/hu/time.json";
@@ -108,3 +108,37 @@ describe("relativeFromMs", () => {
expect(hu).toHaveProperty("justNow"); expect(hu).toHaveProperty("justNow");
}); });
}); });
// formatRuntime went from "one Plex movie's length" to "a playlist's total playtime" (E9 12D), and a
// TOTAL is the only input that can legitimately be 1-59 seconds. That tier had no coverage and no
// implementation: 45s floored to "0m", so a playlist of Shorts reported no runtime while the rail
// beside it read "0:45". The table pins every tier boundary, so the sub-minute branch cannot be
// dropped again as "dead code" — it is the one branch a whole-playlist figure reaches.
describe("formatRuntime", () => {
const CASES: [number | null | undefined, string][] = [
[null, ""],
[undefined, ""],
[0, ""], // nothing to show — the call sites suppress their separator on the empty string
[1, "1s"],
[45, "45s"], // one Short: the regression case
[45.7, "45s"], // truncates, so the seconds tier cannot overstate either
[59, "59s"], // last second before the minute tier
[60, "1m"],
[90, "1m"], // floors, never rounds up — a total must not overstate
[90.9, "1m"],
[3599, "59m"],
[3600, "1h 0m"], // the zero minutes stay: "1h" alone reads as an approximation
[5025, "1h 23m"],
[360000, "100h 0m"], // a large aggregate keeps counting hours rather than rolling to days
];
it.each(CASES)("formats %o as %o", (sec, expected) => {
expect(formatRuntime(sec)).toBe(expected);
});
it("never reports a non-zero duration as zero", () => {
for (let sec = 1; sec < 3600; sec++) {
expect(formatRuntime(sec), `${sec}s`).not.toBe("0m");
}
});
});
+8 -2
View File
@@ -96,10 +96,16 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
} }
/** Compact human runtime ("1h 30m", "45m") from seconds — used for Plex movie/episode lengths. /** Compact human runtime ("1h 30m", "45m", "45s") from seconds. Formats BOTH a single item's
* Empty string for a missing/zero duration (nothing to show). */ * length (Plex movies/episodes) and an aggregate total (playlist playtime, on the YouTube and the
* Plex side alike) — it is the one renderer for a playlist total, so a total never appears in two
* shapes on one screen. Empty string for a missing/zero duration (nothing to show).
* The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land
* between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0m" made a
* non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
export function formatRuntime(sec?: number | null): string { export function formatRuntime(sec?: number | null): string {
if (!sec) return ""; if (!sec) return "";
if (sec < 60) return `${Math.floor(sec)}s`;
const h = Math.floor(sec / 3600); const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60); const m = Math.floor((sec % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`; return h ? `${h}h ${m}m` : `${m}m`;
+32
View File
@@ -14,6 +14,38 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.79.0",
date: "2026-09-03",
summary:
"A playlist now tells you how long it is, and its header takes half the room it used to — so you see more of the list and less of the frame around it.",
features: [
'Every playlist header shows the total playtime beside the video count: "32 videos · 2h 17m". It is the whole playlist, not whatever a filter is currently showing, so it answers the question you actually have — is there time for this tonight?',
"The header was three stacked rows next to a large cover; it is two tight ones next to a smaller cover. Nothing was hidden behind a menu to get there — the title, rename, the YouTube link, Play all, the sync and delete buttons, group-by-channel, the view controls and undo are all still on screen. That band never scrolls, so the height it gave back is list you can now see: about eighty pixels on a normal window.",
"Plex playlists show their total playtime in the same place, in the same words.",
],
fixes: [
"The column header no longer jolts upward the instant you start scrolling a playlist. It used to sit twelve pixels below the top of the list and snap flush on the first scroll, which read as the page flinching.",
'A playlist’s length is written one way everywhere now. The list on the left said "2:15:30" while the header beside it said "2h 15m" for the very same playlist — one number in two shapes, side by side.',
'A playlist shorter than a minute reported "0m". A single Short is 45 seconds long, and the header said it had no runtime at all; it now says "45s".',
'A Plex playlist holding exactly one thing said "1 items" in English.',
],
chores: [
'12D reads the durations already in hand rather than adding an endpoint: both headers reduce over their own items, and formatRuntime grew a sub-minute tier because an aggregate is the first input that can legitimately land between 1 and 59 seconds — the fix lives in the function, not behind a `>= 60` guard at each of its seven call sites. PlaylistsRail moved off formatDuration onto the same helper so a playlist total has one renderer. 12A keeps every control and its gating (`items.length > 1 || viewActive`, editable/canWrite/canEdit, linked && dirty) byte-for-byte; 12B drops the wrapper’s pt-3 and passes fadeTop:false for the playlists page, matching the channel table. formatRuntime had no unit tests across seven call sites and now has a tier table plus a sweep asserting no non-zero second ever renders as "0m". Epic-end deep review over five dimensions: eighteen raw findings, ten distinct, two dropped after 3-of-3 adversarial refutations (meta-line segment order; the null-duration lower bound, which matches the server’s own long-shipped SUM), six fixed, two logged as deferrals. Rounds converged 6 → 1 → 0.',
],
},
{
version: "0.78.8",
date: "2026-09-02",
summary:
"Links in Siftlode's emails are now real links in every mail app, not just the ones that guess.",
fixes: [
"Siftlode's messages were plain text, which left it to your mail app to decide whether the address in them was clickable. Gmail, Outlook and Apple Mail do that for you; lighter web-based mail readers show the address as plain text you have to select and copy by hand — in a message whose whole point is that one link, like confirming your email or resetting your password. Every message now carries a formatted version alongside the plain one, so the link is a link wherever you read it.",
],
chores: [
"_send() attaches an HTML alternative generated from the same plain body rather than per-template HTML that would drift from it: one converter, all eleven templates, text/plain still the primary part. Escaping runs before linkification — bodies interpolate untrusted values (a requester's address, the operator contact) — so no caller can inject markup, and a query string's & is already &amp; when it reaches the href. Sentence-final punctuation is trimmed out of the href while url-safe token characters (- and _) are left alone. Inline styles only: no images, no external CSS, no tracking.",
],
},
{ {
version: "0.78.7", version: "0.78.7",
date: "2026-09-02", date: "2026-09-02",