Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-09 15:20:48 +02:00
commit 6140be18fc
5 changed files with 52 additions and 19 deletions

View file

@ -1 +1 @@
0.34.0 0.34.1

View file

@ -16,6 +16,8 @@ history + onDeck, last-write-wins) build on this and land in later ships.
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import func
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import sysconfig
@ -67,13 +69,12 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
item_id_by_rk: dict[str, int] = { item_id_by_rk: dict[str, int] = {
rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id) rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)
} }
# This user's existing states, by item — so we upsert rather than duplicate.
existing: dict[int, PlexState] = {
st.item_id: st
for st in db.query(PlexState).filter_by(user_id=link.user_id)
}
stats = {"watched": 0, "in_progress": 0, "scanned": 0} stats = {"watched": 0, "in_progress": 0, "scanned": 0}
wanted = _enabled_section_keys(db) wanted = _enabled_section_keys(db)
# Accumulate one row per item (keyed by item_id → inherently de-duped) then bulk-UPSERT. An upsert
# is idempotent AND concurrency-safe: a repeated/overlapping enable (the toggle firing twice while
# the multi-second import runs) can't raise a duplicate-key error — the conflict just re-updates.
rows_by_item: dict[int, dict] = {}
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
@ -98,18 +99,35 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
if res is None: if res is None:
continue continue
status, pos, watched_at, prog_at = res status, pos, watched_at, prog_at = res
st = existing.get(item_id) rows_by_item[item_id] = {
if st is None: "user_id": link.user_id,
st = PlexState(user_id=link.user_id, item_id=item_id) "item_id": item_id,
db.add(st) "status": status,
existing[item_id] = st "position_seconds": pos,
st.status = status "watched_at": watched_at,
st.position_seconds = pos "progress_updated_at": prog_at,
st.watched_at = watched_at # Mirrors Plex now — so Phase B's push doesn't bounce it straight back.
st.progress_updated_at = prog_at "synced_to_plex": True,
# This state now mirrors Plex — mark it so Phase B's push doesn't bounce it back. }
st.synced_to_plex = True
stats["watched" if status == "watched" else "in_progress"] += 1 stats["watched" if status == "watched" else "in_progress"] += 1
# Bulk upsert in chunks. Plex wins on the intersection; Siftlode-only states (item_ids not
# in rows_by_item) are never referenced, so they're preserved untouched (union).
rows = list(rows_by_item.values())
for i in range(0, len(rows), 1000):
stmt = pg_insert(PlexState).values(rows[i : i + 1000])
stmt = stmt.on_conflict_do_update(
index_elements=[PlexState.user_id, PlexState.item_id],
set_={
"status": stmt.excluded.status,
"position_seconds": stmt.excluded.position_seconds,
"watched_at": stmt.excluded.watched_at,
"progress_updated_at": stmt.excluded.progress_updated_at,
"synced_to_plex": stmt.excluded.synced_to_plex,
"updated_at": func.now(),
},
)
db.execute(stmt)
link.initial_import_done = True link.initial_import_done = True
link.last_watch_sync_at = datetime.now(timezone.utc) link.last_watch_sync_at = datetime.now(timezone.utc)
db.commit() db.commit()

View file

@ -469,7 +469,11 @@ function PlexWatchSync() {
<Section title={t("settings.plexSync.title")}> <Section title={t("settings.plexSync.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p> <p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
<SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}> <SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}>
<Switch checked={enabled} onChange={(v) => toggle.mutate(v)} /> <Switch
checked={enabled}
disabled={toggle.isPending || reimport.isPending}
onChange={(v) => !toggle.isPending && toggle.mutate(v)}
/>
</SettingRow> </SettingRow>
{enabled && ( {enabled && (
<div className="mt-2 flex items-center justify-between gap-3"> <div className="mt-2 flex items-center justify-between gap-3">

View file

@ -10,10 +10,12 @@ export function Switch({
checked, checked,
onChange, onChange,
label, label,
disabled,
}: { }: {
checked: boolean; checked: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
label?: string; label?: string;
disabled?: boolean;
}) { }) {
return ( return (
<button <button
@ -21,8 +23,9 @@ export function Switch({
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
aria-label={label} aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`} className={`w-9 h-5 rounded-full transition relative shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${checked ? "bg-accent" : "bg-border"}`}
> >
<span <span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${ className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${

View file

@ -14,6 +14,14 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.34.1",
date: "2026-07-09",
summary: "Fix: turning on Plex watch sync could show a server error.",
fixes: [
"Enabling Plex watch sync could briefly show a \"Server error\" if the switch was toggled again while the first import was still running. The import is now safe to run more than once, and the switch is disabled while it works. (If you saw the error, your Plex history still imported correctly.)",
],
},
{ {
version: "0.34.0", version: "0.34.0",
date: "2026-07-09", date: "2026-07-09",