Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-07 20:21:38 +02:00
commit ebe8d26620
26 changed files with 1146 additions and 190 deletions

View file

@ -1 +1 @@
0.30.0 0.31.0

View file

@ -0,0 +1,38 @@
"""per-download display metadata + auto channel URL
Revision ID: 0049_download_display_meta
Revises: 0048_plex_playlists
Create Date: 2026-07-07
Lets a user override a download's channel and attach extra reference links (beyond the source),
and captures yt-dlp's canonical channel/uploader URL so an auto-filled channel becomes clickable.
- media_assets.uploader_url: the channel page URL yt-dlp recorded (nullable; filled by the worker
on extraction older rows just stay plain text).
- download_jobs.display_uploader / display_uploader_url: per-user channel-name + channel-link
overrides (like display_name display metadata only, never touches the on-disk file).
- download_jobs.extra_links: optional list of extra reference URLs shown on the card + watch page.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0049_download_display_meta"
down_revision: Union[str, None] = "0048_plex_playlists"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("media_assets", sa.Column("uploader_url", sa.Text(), nullable=True))
op.add_column("download_jobs", sa.Column("display_uploader", sa.String(length=255), nullable=True))
op.add_column("download_jobs", sa.Column("display_uploader_url", sa.Text(), nullable=True))
op.add_column("download_jobs", sa.Column("extra_links", sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("download_jobs", "extra_links")
op.drop_column("download_jobs", "display_uploader_url")
op.drop_column("download_jobs", "display_uploader")
op.drop_column("media_assets", "uploader_url")

View file

@ -68,11 +68,26 @@ def owner_view(link: DownloadLink, base_url: str = "") -> dict:
} }
def public_meta(link: DownloadLink, asset, file_url: str) -> dict: def public_meta(link: DownloadLink, job, asset, file_url: str) -> dict:
"""Metadata for the public watch page (only ever returned once access is authorized).""" """Metadata for the public watch page (only ever returned once access is authorized).
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
from app.downloads import service
source_url = None
if job is not None and job.job_kind != "edit":
source_url = asset.source_webpage_url or (
service.source_url(job.source_kind, job.source_ref)
if job.source_kind in ("youtube", "url")
else None
)
return { return {
"title": asset.title, "title": (job and job.display_name) or asset.title,
"uploader": asset.uploader, "uploader": (job and job.display_uploader) or asset.uploader,
"uploader_url": (job and job.display_uploader_url) or asset.uploader_url,
"source_url": source_url,
"extra_links": (job and job.extra_links) or [],
"duration_s": asset.duration_s, "duration_s": asset.duration_s,
"width": asset.width, "width": asset.width,
"height": asset.height, "height": asset.height,

View file

@ -0,0 +1,85 @@
"""Server-rendered Open Graph tags for the public /watch/{token} page.
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, ) that
fetches /watch/{token} would otherwise only see the generic index.html a blank/greyed link card.
This injects per-video OG/Twitter tags into the served HTML so a shared link unfurls with the
video's title, channel and thumbnail. Real browsers ignore the extra tags and hydrate the SPA as
usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak).
"""
import html
import re
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import links as linksmod
from app.models import DownloadJob, DownloadLink, MediaAsset
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
def _tag(prop: str, content: str, attr: str = "property") -> str:
return f'<meta {attr}="{prop}" content="{html.escape(content, quote=True)}" />'
def _watch_tags(token: str, db: Session) -> str | None:
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
link = db.execute(
select(DownloadLink).where(DownloadLink.token == token)
).scalar_one_or_none()
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}/watch/{token}"
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
[
"<title>Siftlode</title>",
_tag("og:title", "Password-protected video"),
_tag("og:description", "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
]
)
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready":
return None
title = (job.display_name or asset.title or "Video").strip()
channel = (job.display_uploader or asset.uploader or "").strip()
image = asset.thumbnail_url # already an absolute (youtube/fb) URL, or None
tags = [
f"<title>{html.escape(title)}</title>",
_tag("og:title", title),
_tag("og:description", channel or "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
_tag("twitter:title", title, attr="name"),
]
if channel:
tags.append(_tag("twitter:description", channel, attr="name"))
if image:
# A real thumbnail → a large image card; without one, a plain (text) card is served.
tags.append(_tag("og:image", image))
tags.append(_tag("twitter:card", "summary_large_image", attr="name"))
tags.append(_tag("twitter:image", image, attr="name"))
else:
tags.append(_tag("twitter:card", "summary", attr="name"))
return "\n ".join(tags)
def render_watch_html(token: str, index_html: Path, db: Session) -> str | None:
"""Return index.html with the OG block replaced for this watch token, or None to serve the
generic page unchanged (invalid/expired/not-ready link, or a template without the markers)."""
tags = _watch_tags(token, db)
if tags is None:
return None
text = index_html.read_text(encoding="utf-8")
if "<!--OG:START-->" not in text:
return None
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
return _OG_BLOCK.sub(lambda _m: tags, text, count=1)

View file

@ -20,7 +20,7 @@ if not _siftlode_logger.handlers:
log = logging.getLogger("siftlode.app") log = logging.getLogger("siftlode.app")
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@ -178,6 +178,21 @@ async def index() -> FileResponse:
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
@app.get("/watch/{token}")
async def watch_page(token: str):
"""Serve the SPA for a public share link, but with per-video Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
ignores the extra tags and hydrates WatchPage as usual; invalid/expired/password links fall
back to the generic card."""
from app.downloads import og
with SessionLocal() as db:
rendered = og.render_watch_html(token, INDEX_HTML, db)
if rendered is None:
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
return HTMLResponse(rendered, headers=INDEX_HEADERS)
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def spa_fallback(full_path: str) -> FileResponse: async def spa_fallback(full_path: str) -> FileResponse:
# Client-side routes fall back to index.html; real API/asset paths are matched above. # Client-side routes fall back to index.html; real API/asset paths are matched above.

View file

@ -739,6 +739,9 @@ class MediaAsset(Base, TimestampMixin):
# Denormalized naming/metadata for the Plex tree + ad-hoc display. # Denormalized naming/metadata for the Plex tree + ad-hoc display.
title: Mapped[str | None] = mapped_column(Text) title: Mapped[str | None] = mapped_column(Text)
uploader: Mapped[str | None] = mapped_column(String(255)) uploader: Mapped[str | None] = mapped_column(String(255))
# yt-dlp's channel/uploader page URL (channel_url|uploader_url) — lets an auto-filled channel
# render as a clickable link. Null for sources without one (e.g. reddit) or older rows.
uploader_url: Mapped[str | None] = mapped_column(Text)
upload_date: Mapped[date | None] = mapped_column(Date) upload_date: Mapped[date | None] = mapped_column(Date)
thumbnail_url: Mapped[str | None] = mapped_column(Text) thumbnail_url: Mapped[str | None] = mapped_column(Text)
# yt-dlp's canonical page URL for the source (webpage_url) — the clean "downloaded from" # yt-dlp's canonical page URL for the source (webpage_url) — the clean "downloaded from"
@ -821,6 +824,12 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
) )
profile_snapshot: Mapped[dict] = mapped_column(JSON) profile_snapshot: Mapped[dict] = mapped_column(JSON)
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
# Per-user display overrides + extras (like display_name — metadata only, never the file).
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
display_uploader: Mapped[str | None] = mapped_column(String(255))
display_uploader_url: Mapped[str | None] = mapped_column(Text)
extra_links: Mapped[list | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(16), default="queued", server_default="queued", index=True String(16), default="queued", server_default="queued", index=True
) # queued | running | paused | done | error | canceled ) # queued | running | paused | done | error | canceled

View file

@ -100,6 +100,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"profile_id": job.profile_id, "profile_id": job.profile_id,
"spec": job.profile_snapshot, "spec": job.profile_snapshot,
"display_name": job.display_name, "display_name": job.display_name,
"display_uploader": job.display_uploader,
"display_uploader_url": job.display_uploader_url,
"extra_links": job.extra_links or [],
"job_kind": job.job_kind, "job_kind": job.job_kind,
"source_job_id": job.source_job_id, "source_job_id": job.source_job_id,
"edit_spec": job.edit_spec, "edit_spec": job.edit_spec,
@ -118,6 +121,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"width": asset.width, "width": asset.width,
"height": asset.height, "height": asset.height,
"container": asset.container, "container": asset.container,
"uploader_url": asset.uploader_url,
"thumbnail_url": asset.thumbnail_url, "thumbnail_url": asset.thumbnail_url,
"expires_at": asset.expires_at.isoformat() if asset.expires_at else None, "expires_at": asset.expires_at.isoformat() if asset.expires_at else None,
}, },
@ -380,17 +384,47 @@ def shared_with_me(
return out return out
_MAX_EXTRA_LINKS = 8
def _clean_url(raw) -> str | None:
"""Accept only a plausible http(s) URL (these get rendered as clickable links + embedded in a
public watch page); anything else is dropped rather than trusted."""
if not isinstance(raw, str):
return None
url = raw.strip()
if not url:
return None
if not url.startswith(("http://", "https://")):
return None
parsed = urlparse(url)
if not parsed.netloc:
return None
return url[:2048]
@router.patch("/{job_id}") @router.patch("/{job_id}")
def rename_download( def update_download_meta(
job_id: int, job_id: int,
payload: dict, payload: dict,
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Update a download's display metadata: title, channel name + link, and extra reference URLs.
All are display-only overrides the on-disk file keeps its system-decided, id-bound name."""
job = _own_job(db, user, job_id) job = _own_job(db, user, job_id)
name = (payload.get("display_name") or "").strip() if "display_name" in payload:
# Display name only — the on-disk file keeps its system-decided, id-bound name. job.display_name = ((payload.get("display_name") or "").strip())[:255] or None
job.display_name = name[:255] or None if "display_uploader" in payload:
job.display_uploader = ((payload.get("display_uploader") or "").strip())[:255] or None
if "display_uploader_url" in payload:
job.display_uploader_url = _clean_url(payload.get("display_uploader_url"))
if "extra_links" in payload:
raw = payload.get("extra_links") or []
if not isinstance(raw, list):
raise HTTPException(status_code=400, detail="extra_links must be a list")
cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS]
job.extra_links = cleaned or None
db.commit() db.commit()
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
return _serialize(job, asset) return _serialize(job, asset)

View file

@ -186,11 +186,15 @@ def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
} }
def _leaf_card(it: PlexItem, st: PlexState | None, show_title: str | None = None) -> dict: def _leaf_card(
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.""" it: PlexItem, st: PlexState | None, show_title: str | None = None, show_rk: str | None = None
) -> dict:
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.
`show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season."""
if it.kind == "episode": if it.kind == "episode":
card = _episode_card(it, st) card = _episode_card(it, st)
card["show_title"] = show_title card["show_title"] = show_title
card["show_id"] = show_rk
return card return card
return _movie_card(it, st) return _movie_card(it, st)
@ -639,15 +643,23 @@ def _playlist_item_query(db: Session, pid: int):
@router.get("/playlists") @router.get("/playlists")
def playlists( def playlists(
contains: str | None = None, contains: str | None = None,
contains_group: str | None = None,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""The current user's playlists (newest-touched first), each with an item count + a cover thumb. """The current user's playlists (newest-touched first), each with an item count + a cover thumb.
`contains`=an item rating_key adds `has_item` per playlist (for the 'add to playlist' dialog).""" `contains`=an item rating_key adds `has_item` per playlist (for the single 'add to playlist' dialog).
`contains_group`=a comma-separated list of rating_keys adds `group_in` per playlist (how many of the
group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog."""
contains_id = None contains_id = None
if contains: if contains:
row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first() row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first()
contains_id = row[0] if row else None contains_id = row[0] if row else None
group_ids: set[int] | None = None
if contains_group is not None:
rks = [s for s in (contains_group or "").split(",") if s]
rows = db.query(PlexItem.id).filter(PlexItem.rating_key.in_(rks or [""])).all()
group_ids = {r[0] for r in rows}
out = [] out = []
for pl in db.query(PlexPlaylist).filter_by(user_id=user.id).order_by(PlexPlaylist.updated_at.desc()): for pl in db.query(PlexPlaylist).filter_by(user_id=user.id).order_by(PlexPlaylist.updated_at.desc()):
count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0 count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0
@ -658,8 +670,18 @@ def playlists(
db.query(PlexPlaylistItem.id).filter_by(playlist_id=pl.id, item_id=contains_id).first() db.query(PlexPlaylistItem.id).filter_by(playlist_id=pl.id, item_id=contains_id).first()
is not None is not None
) )
if group_ids is not None:
card["group_in"] = (
db.query(func.count(PlexPlaylistItem.id))
.filter(PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id.in_(group_ids or [0]))
.scalar()
or 0
)
out.append(card) out.append(card)
return {"playlists": out} resp: dict = {"playlists": out}
if group_ids is not None:
resp["group_size"] = len(group_ids)
return resp
@router.post("/playlists") @router.post("/playlists")
@ -694,10 +716,18 @@ def playlist_detail(pid: int, user: User = Depends(current_user), db: Session =
) )
} }
shows = { shows = {
sh.id: sh.title sh.id: sh
for sh in db.query(PlexShow).filter(PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0])) for sh in db.query(PlexShow).filter(PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]))
} }
cards = [_leaf_card(it, states.get(it.id), shows.get(it.show_id)) for it in items] cards = [
_leaf_card(
it,
states.get(it.id),
shows[it.show_id].title if it.show_id in shows else None,
shows[it.show_id].rating_key if it.show_id in shows else None,
)
for it in items
]
return {"id": pl.id, "title": pl.title, "items": cards} return {"id": pl.id, "title": pl.title, "items": cards}
@ -748,6 +778,65 @@ def playlist_remove_item(pid: int, item_rating_key: str, user: User = Depends(cu
return {"ok": True} return {"ok": True}
def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
"""Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys)."""
order = [str(k) for k in rks if str(k)]
if not order:
return []
by_rk = {it.rating_key: it for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order))}
seen: set[str] = set()
out: list[PlexItem] = []
for rk in order:
it = by_rk.get(rk)
if it is not None and rk not in seen:
seen.add(rk)
out.append(it)
return out
@router.post("/playlists/{pid}/items/bulk")
def playlist_add_bulk(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
order is preserved for the newly-added ones. Returns how many were added + the new total."""
pl = _own_playlist_or_404(db, pid, user)
items = _items_by_rks(db, payload.get("item_rating_keys") or [])
existing = {row[0] for row in db.query(PlexPlaylistItem.item_id).filter_by(playlist_id=pid)}
pos = int(db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar())
added = 0
for it in items:
if it.id in existing:
continue
pos += 1
db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=pos))
existing.add(it.id)
added += 1
if added:
pl.updated_at = datetime.now(timezone.utc)
db.commit()
total = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() or 0
return {"added": added, "item_count": total}
@router.post("/playlists/{pid}/items/remove-bulk")
def playlist_remove_bulk(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
removed + the new total."""
pl = _own_playlist_or_404(db, pid, user)
ids = [it.id for it in _items_by_rks(db, payload.get("item_rating_keys") or [])]
removed = 0
if ids:
removed = (
db.query(PlexPlaylistItem)
.filter(PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids))
.delete(synchronize_session=False)
)
if removed:
pl.updated_at = datetime.now(timezone.utc)
db.commit()
total = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() or 0
return {"removed": removed, "item_count": total}
@router.put("/playlists/{pid}/order") @router.put("/playlists/{pid}/order")
def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""Set the playlist order from an explicit list of item rating_keys (0-based positions).""" """Set the playlist order from an explicit list of item rating_keys (0-based positions)."""

View file

@ -35,10 +35,10 @@ def _link_or_404(db: Session, token: str) -> DownloadLink:
return link return link
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset: def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
job = db.get(DownloadJob, link.job_id) job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path: if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
# Verify the file is actually on disk here (not just flagged ready), so the watch page never # Verify the file is actually on disk here (not just flagged ready), so the watch page never
# loads a player for a missing file — meta and file agree. # loads a player for a missing file — meta and file agree.
@ -46,7 +46,11 @@ def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
root = Path(settings.download_root).resolve() root = Path(settings.download_root).resolve()
if root not in path.parents or not path.exists(): if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
return asset return job, asset
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
return _ready_target(db, link)[1]
def _file_url(token: str, grant: str | None = None) -> str: def _file_url(token: str, grant: str | None = None) -> str:
@ -61,10 +65,10 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
link = _link_or_404(db, token) link = _link_or_404(db, token)
if link.password_hash: if link.password_hash:
return {"needs_password": True} return {"needs_password": True}
asset = _ready_asset(db, link) job, asset = _ready_target(db, link)
link.view_count += 1 link.view_count += 1
db.commit() db.commit()
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token))} return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token))}
@router.post("/watch/{token}/unlock") @router.post("/watch/{token}/unlock")
@ -75,15 +79,15 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di
link = _link_or_404(db, token) link = _link_or_404(db, token)
if not link.password_hash: if not link.password_hash:
# Not a password link — nothing to unlock; just hand back the plain meta. # Not a password link — nothing to unlock; just hand back the plain meta.
asset = _ready_asset(db, link) job, asset = _ready_target(db, link)
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token))} return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token))}
if not verify_password((payload.get("password") or ""), link.password_hash): if not verify_password((payload.get("password") or ""), link.password_hash):
raise HTTPException(status_code=403, detail="Wrong password.") raise HTTPException(status_code=403, detail="Wrong password.")
asset = _ready_asset(db, link) job, asset = _ready_target(db, link)
link.view_count += 1 link.view_count += 1
db.commit() db.commit()
grant = linksmod.make_grant(token) grant = linksmod.make_grant(token)
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token, grant))} return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token, grant))}
@router.get("/watch/{token}/file") @router.get("/watch/{token}/file")

View file

@ -218,6 +218,7 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
return return
asset.title = normalize_title(info.get("title")) asset.title = normalize_title(info.get("title"))
asset.uploader = info.get("uploader") or info.get("channel") asset.uploader = info.get("uploader") or info.get("channel")
asset.uploader_url = info.get("channel_url") or info.get("uploader_url")
asset.thumbnail_url = info.get("thumbnail") asset.thumbnail_url = info.get("thumbnail")
asset.duration_s = int(info["duration"]) if info.get("duration") else None asset.duration_s = int(info["duration"]) if info.get("duration") else None
asset.upload_date = _parse_upload_date(info.get("upload_date")) asset.upload_date = _parse_upload_date(info.get("upload_date"))
@ -408,6 +409,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
asset.title = meta.title asset.title = meta.title
asset.uploader = meta.uploader asset.uploader = meta.uploader
asset.uploader_url = info.get("channel_url") or info.get("uploader_url")
asset.upload_date = meta.upload_date asset.upload_date = meta.upload_date
asset.thumbnail_url = meta.thumbnail_url asset.thumbnail_url = meta.thumbnail_url
asset.source_webpage_url = info.get("webpage_url") asset.source_webpage_url = info.get("webpage_url")

View file

@ -4,17 +4,21 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- OG:START — the server replaces this block for /watch/{token} so shared links unfurl with
the video's title/channel/thumbnail; every other route keeps these generic defaults. -->
<!--OG:START-->
<title>Siftlode</title> <title>Siftlode</title>
<meta <meta
name="description" name="description"
content="Siftlode is a self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search your feed, build playlists that sync both ways, and watch in a clean, ad-free interface." content="A self-hosted, multi-user reader, subscription organizer and media center with download capabilities."
/> />
<meta property="og:title" content="Siftlode" /> <meta property="og:title" content="Siftlode" />
<meta <meta
property="og:description" property="og:description"
content="A self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search, build two-way-syncing playlists, and watch ad-free." content="A self-hosted, multi-user reader, subscription organizer and media center with download capabilities."
/> />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<!--OG:END-->
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -78,6 +78,15 @@ function jobTitle(job: DownloadJob): string {
return job.display_name || job.asset?.title || job.source_ref; return job.display_name || job.asset?.title || job.source_ref;
} }
// The channel to show under the title: a user override wins over the auto-extracted asset value.
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
return {
name: job.display_uploader || job.asset?.uploader || job.source_ref,
url: job.display_uploader_url || job.asset?.uploader_url || null,
};
}
function IconBtn({ function IconBtn({
onClick, onClick,
title, title,
@ -148,6 +157,27 @@ function SourceRef({ url }: { url: string }) {
); );
} }
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
function ChannelLine({ job }: { job: DownloadJob }) {
const { name, url } = jobChannel(job);
if (url) {
return (
<div className="text-xs mt-0.5 truncate">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="text-muted hover:text-accent hover:underline"
>
{name}
</a>
</div>
);
}
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
}
function DownloadRow({ function DownloadRow({
job, job,
children, children,
@ -169,9 +199,7 @@ function DownloadRow({
)} )}
<span className="truncate">{jobTitle(job)}</span> <span className="truncate">{jobTitle(job)}</span>
</div> </div>
<div className="text-xs text-muted truncate mt-0.5"> <ChannelLine job={job} />
{job.asset?.uploader || job.source_ref}
</div>
<div className="flex items-center gap-2 mt-1 text-xs"> <div className="flex items-center gap-2 mt-1 text-xs">
<StatusPill job={job} /> <StatusPill job={job} />
{job.asset?.size_bytes ? <span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span> : null} {job.asset?.size_bytes ? <span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span> : null}
@ -179,6 +207,7 @@ function DownloadRow({
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null} {job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
</div> </div>
{job.source_url && <SourceRef url={job.source_url} />} {job.source_url && <SourceRef url={job.source_url} />}
{job.extra_links?.map((url) => <SourceRef key={url} url={url} />)}
{running && ( {running && (
<div className="mt-1.5"> <div className="mt-1.5">
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( {job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
@ -208,30 +237,89 @@ function DownloadRow({
// --- Rename + Share small modals ------------------------------------------------------------ // --- Rename + Share small modals ------------------------------------------------------------
function RenameModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) { // Edit a download's display metadata: title, channel (name + link), and extra reference links.
// All are display-only overrides — the on-disk file is never renamed.
function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState(jobTitle(job)); const [name, setName] = useState(job.display_name || job.asset?.title || "");
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
const [channelUrl, setChannelUrl] = useState(job.display_uploader_url || job.asset?.uploader_url || "");
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
const addLink = () => setLinks((ls) => [...ls, ""]);
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
const save = useMutation({ const save = useMutation({
mutationFn: () => api.renameDownload(job.id, name.trim()), mutationFn: () =>
api.updateDownloadMeta(job.id, {
display_name: name.trim(),
display_uploader: channel.trim(),
display_uploader_url: channelUrl.trim(),
extra_links: links.map((l) => l.trim()).filter(Boolean),
}),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] }); qc.invalidateQueries({ queryKey: ["downloads"] });
onClose(); onClose();
}, },
}); });
return ( return (
<Modal title={t("downloads.rename.title")} onClose={onClose}> <Modal title={t("downloads.edit.title")} onClose={onClose}>
<label className="block text-sm font-medium mb-1">{t("downloads.rename.label")}</label> <div className="space-y-3">
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} autoFocus /> <label className="block">
<p className="text-xs text-muted mt-2 mb-4">{t("downloads.rename.hint")}</p> <span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
<div className="flex justify-end"> <input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} autoFocus />
<button </label>
onClick={() => save.mutate()} <label className="block">
disabled={!name.trim() || save.isPending} <span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition" <input value={channel} onChange={(e) => setChannel(e.target.value)} className={inputCls} />
> </label>
{t("downloads.rename.save")} <label className="block">
</button> <span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
<input
value={channelUrl}
onChange={(e) => setChannelUrl(e.target.value)}
placeholder="https://…"
className={inputCls}
/>
</label>
<div>
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
<div className="space-y-2">
{links.map((l, i) => (
<div key={i} className="flex gap-2">
<input
value={l}
onChange={(e) => setLink(i, e.target.value)}
placeholder="https://…"
className={inputCls}
/>
<IconBtn onClick={() => removeLink(i)} title={t("downloads.edit.removeLink")} danger>
<Trash2 className="w-4 h-4" />
</IconBtn>
</div>
))}
</div>
<button
onClick={addLink}
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
>
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
</button>
</div>
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
<div className="flex justify-end">
<button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{t("downloads.edit.save")}
</button>
</div>
</div> </div>
</Modal> </Modal>
); );
@ -579,7 +667,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
<Scissors className="w-4 h-4" /> <Scissors className="w-4 h-4" />
</IconBtn> </IconBtn>
)} )}
<IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.rename")}> <IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.editDetails")}>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</IconBtn> </IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}> <IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
@ -643,7 +731,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
/> />
)} )}
{manage && <ProfileEditor onClose={() => setManage(false)} />} {manage && <ProfileEditor onClose={() => setManage(false)} />}
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />} {renameJob && <MetaEditModal job={renameJob} onClose={() => setRenameJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />} {shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />} {editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense> </Suspense>

View file

@ -1,10 +1,11 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react"; import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react"; import { ArrowLeft, CheckCircle2, Info, ListPlus, Play } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexPerson } from "../lib/api"; import { api, type PlexCard, type PlexFilters, type PlexPerson } from "../lib/api";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history"; import { useHistorySubview } from "../lib/history";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played. // Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer")); const PlexPlayer = lazy(() => import("./PlexPlayer"));
@ -461,6 +462,9 @@ function PlexShowView({
const { t } = useTranslation(); const { t } = useTranslation();
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) }); const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
const d = q.data; const d = q.data;
// "Add to playlist" dialog target (single episode / whole season / whole show); null = closed.
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const allEpisodeKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
return ( return (
<div className="p-4 max-w-[1200px] mx-auto"> <div className="p-4 max-w-[1200px] mx-auto">
@ -488,41 +492,74 @@ function PlexShowView({
{d.show.summary && ( {d.show.summary && (
<p className="text-sm text-muted mt-2 line-clamp-6 leading-relaxed">{d.show.summary}</p> <p className="text-sm text-muted mt-2 line-clamp-6 leading-relaxed">{d.show.summary}</p>
)} )}
{allEpisodeKeys.length > 0 && (
<button
onClick={() => setAddTarget({ kind: "group", ratingKeys: allEpisodeKeys, title: d.show.title })}
className="glass-card glass-hover mt-3 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
>
<ListPlus className="w-4 h-4" />
{t("plex.playlist.addShow")}
</button>
)}
</div> </div>
</div> </div>
{d.seasons.map((se) => ( {d.seasons.map((se) => (
<div key={se.id} className="mb-6"> <div key={se.id} className="mb-6">
<h2 className="text-sm font-semibold mb-2">{se.title}</h2> <div className="mb-2 flex items-center gap-2">
<h2 className="text-sm font-semibold">{se.title}</h2>
{se.episodes.length > 0 && (
<button
onClick={() =>
setAddTarget({
kind: "group",
ratingKeys: se.episodes.map((e) => e.id),
title: `${d.show.title}${se.title}`,
})
}
title={t("plex.playlist.addSeason")}
aria-label={t("plex.playlist.addSeason")}
className="rounded p-1 text-muted hover:text-accent"
>
<ListPlus className="w-4 h-4" />
</button>
)}
</div>
<div className="flex flex-col divide-y divide-border/60"> <div className="flex flex-col divide-y divide-border/60">
{se.episodes.map((ep) => { {se.episodes.map((ep) => {
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
return ( return (
<button <div key={ep.id} className="flex items-center gap-3 py-2 group">
key={ep.id} <button onClick={() => onPlay(ep)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
onClick={() => onPlay(ep)} <div className="relative w-28 aspect-video rounded-lg overflow-hidden bg-card border border-border shrink-0">
className="flex items-center gap-3 py-2 text-left group" <img src={ep.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
> {ep.status === "watched" && (
<div className="relative w-28 aspect-video rounded-lg overflow-hidden bg-card border border-border shrink-0"> <span className="absolute top-1 right-1 text-[9px] bg-black/70 text-white px-1 rounded">
<img src={ep.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
{ep.status === "watched" && ( </span>
<span className="absolute top-1 right-1 text-[9px] bg-black/70 text-white px-1 rounded"> )}
{inProgress && <div className="absolute bottom-0 inset-x-0 h-0.5 bg-accent" />}
</span>
)}
{inProgress && <div className="absolute bottom-0 inset-x-0 h-0.5 bg-accent" />}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
<span className="text-muted mr-1.5">{ep.episode_number}.</span>
{ep.title}
</div> </div>
{ep.summary && ( <div className="min-w-0 flex-1">
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div> <div className="text-sm font-medium truncate">
)} <span className="text-muted mr-1.5">{ep.episode_number}.</span>
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div> {ep.title}
</div> </div>
</button> {ep.summary && (
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
)}
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
</div>
</button>
<button
onClick={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded p-1.5 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
>
<ListPlus className="w-4 h-4" />
</button>
</div>
); );
})} })}
</div> </div>
@ -530,6 +567,8 @@ function PlexShowView({
))} ))}
</> </>
)} )}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
</div> </div>
); );
} }

View file

@ -526,7 +526,10 @@ export default function PlexInfo({
/> />
)} )}
{playlistOpen && ( {playlistOpen && (
<PlexPlaylistAdd item={{ id: detail.id, title: detail.title }} onClose={() => setPlaylistOpen(false)} /> <PlexPlaylistAdd
target={{ kind: "single", ratingKey: detail.id, title: detail.title }}
onClose={() => setPlaylistOpen(false)}
/>
)} )}
</div> </div>
); );

View file

@ -1,42 +1,60 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react"; import { Check, Minus, Plus } from "lucide-react";
import { api, type PlexPlaylist } from "../lib/api"; import { api, type PlexPlaylist } from "../lib/api";
import Modal from "./Modal"; import Modal from "./Modal";
// "Add this title to a playlist" dialog — per-user (every user has their own playlists), opened from // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// the movie info page. Lists my playlists with an in/out toggle (seeded from the backend's has_item) // set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
// and a field to create a new one seeded with this title. Playlists are personal (no admin gate). export type PlexAddTarget =
export default function PlexPlaylistAdd({ | { kind: "single"; ratingKey: string; title: string }
item, | { kind: "group"; ratingKeys: string[]; title: string };
onClose,
}: { // "Add to playlist" dialog — per-user (every user has their own playlists). Opened from the movie info
item: { id: string; title: string }; // page (single) and the show page (single episode / whole season / whole show). Lists my playlists with
onClose: () => void; // an in/out (or partial) state and a field to create a new one seeded with the target.
}) { export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTarget; onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [override, setOverride] = useState<Map<number, boolean>>(new Map()); // optimistic toggle state // Optimistic per-playlist in-count override (0..size), so a toggle reflects instantly.
const [override, setOverride] = useState<Map<number, number>>(new Map());
const isGroup = target.kind === "group";
const size = isGroup ? new Set(target.ratingKeys).size : 1;
const listQ = useQuery({ const listQ = useQuery({
queryKey: ["plex-playlists", "contains", item.id], queryKey: isGroup
queryFn: () => api.plexPlaylists(item.id), ? ["plex-playlists", "group", target.ratingKeys]
: ["plex-playlists", "contains", target.ratingKey],
queryFn: () =>
isGroup ? api.plexPlaylists(undefined, target.ratingKeys) : api.plexPlaylists(target.ratingKey),
}); });
const playlists = listQ.data?.playlists ?? []; const playlists = listQ.data?.playlists ?? [];
const isIn = (p: PlexPlaylist) => (override.has(p.id) ? override.get(p.id)! : !!p.has_item);
const refresh = () => qc.invalidateQueries({ queryKey: ["plex-playlists"] }); const refresh = () => qc.invalidateQueries({ queryKey: ["plex-playlists"] });
// How many of the target a playlist currently holds (0..size).
function inCount(p: PlexPlaylist): number {
const o = override.get(p.id);
if (o !== undefined) return o;
return isGroup ? p.group_in ?? 0 : p.has_item ? 1 : 0;
}
async function toggle(p: PlexPlaylist) { async function toggle(p: PlexPlaylist) {
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
const cur = isIn(p); const allIn = inCount(p) >= size;
try { try {
if (cur) await api.plexPlaylistRemoveItem(p.id, item.id); if (isGroup) {
else await api.plexPlaylistAddItem(p.id, item.id); if (allIn) await api.plexPlaylistRemoveBulk(p.id, target.ratingKeys);
setOverride((m) => new Map(m).set(p.id, !cur)); else await api.plexPlaylistAddBulk(p.id, target.ratingKeys);
} else {
if (allIn) await api.plexPlaylistRemoveItem(p.id, target.ratingKey);
else await api.plexPlaylistAddItem(p.id, target.ratingKey);
}
setOverride((m) => new Map(m).set(p.id, allIn ? 0 : size));
refresh(); refresh();
} finally { } finally {
setBusy(false); setBusy(false);
@ -48,9 +66,15 @@ export default function PlexPlaylistAdd({
if (!title || busy) return; if (!title || busy) return;
setBusy(true); setBusy(true);
try { try {
const pl = await api.plexCreatePlaylist(title, item.id); if (isGroup) {
const pl = await api.plexCreatePlaylist(title);
await api.plexPlaylistAddBulk(pl.id, target.ratingKeys);
setOverride((m) => new Map(m).set(pl.id, size));
} else {
const pl = await api.plexCreatePlaylist(title, target.ratingKey);
setOverride((m) => new Map(m).set(pl.id, 1));
}
setNewName(""); setNewName("");
setOverride((m) => new Map(m).set(pl.id, true));
refresh(); refresh();
} finally { } finally {
setBusy(false); setBusy(false);
@ -58,7 +82,7 @@ export default function PlexPlaylistAdd({
} }
return ( return (
<Modal title={t("plex.playlistAdd.title", { title: item.title })} onClose={onClose}> <Modal title={t("plex.playlistAdd.title", { title: target.title })} onClose={onClose}>
<div className="mb-3 flex gap-2"> <div className="mb-3 flex gap-2">
<input <input
value={newName} value={newName}
@ -84,27 +108,35 @@ export default function PlexPlaylistAdd({
<p className="py-4 text-center text-sm text-muted">{t("plex.playlistAdd.none")}</p> <p className="py-4 text-center text-sm text-muted">{t("plex.playlistAdd.none")}</p>
) : ( ) : (
playlists.map((p) => { playlists.map((p) => {
const inIt = isIn(p); const n = inCount(p);
const all = n >= size;
const partial = n > 0 && n < size;
return ( return (
<button <button
key={p.id} key={p.id}
onClick={() => toggle(p)} onClick={() => toggle(p)}
disabled={busy} disabled={busy}
className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm disabled:opacity-50 ${ className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm disabled:opacity-50 ${
inIt ? "text-accent" : "" all ? "text-accent" : ""
}`} }`}
> >
<span <span
className={`grid h-5 w-5 shrink-0 place-items-center rounded border ${ className={`grid h-5 w-5 shrink-0 place-items-center rounded border ${
inIt ? "border-accent bg-accent/20" : "border-border" all || partial ? "border-accent bg-accent/20" : "border-border"
}`} }`}
> >
{inIt && <Check className="h-3.5 w-3.5" />} {all ? <Check className="h-3.5 w-3.5" /> : partial ? <Minus className="h-3.5 w-3.5" /> : null}
</span> </span>
<span className="min-w-0 flex-1 truncate">{p.title}</span> <span className="min-w-0 flex-1 truncate">{p.title}</span>
<span className="shrink-0 text-xs text-muted"> {isGroup ? (
{t("plex.playlistAdd.count", { count: p.item_count })} <span className="shrink-0 text-xs text-muted tabular-nums">
</span> {t("plex.playlistAdd.groupProgress", { in: n, size })}
</span>
) : (
<span className="shrink-0 text-xs text-muted">
{t("plex.playlistAdd.count", { count: p.item_count })}
</span>
)}
</button> </button>
); );
}) })

View file

@ -1,12 +1,300 @@
import { useState } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ChevronDown, ChevronUp, Pencil, Play, Trash2, X } from "lucide-react"; import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
GripVertical,
ListTree,
Pencil,
Play,
Rows3,
Trash2,
X,
} from "lucide-react";
import { api, type PlexCard } from "../lib/api"; import { api, type PlexCard } from "../lib/api";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// A playlist's ordered items — reorder (up/down), remove, rename, delete, and "Play all" (which opens // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// the player with the whole ordered list as its queue). Rendered as a PlexBrowse subview. // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
// list. Reorder is drag & drop (a whole show-group moves as one block; episodes reorder within their
// show). Two layouts — Accordion (A) and Tree (C) — are a per-account preference. Rendered as a
// PlexBrowse subview; "Play all" opens the player with the whole ordered list as its queue.
type Layout = "accordion" | "tree";
type Season = { key: string; number: number | null; items: PlexCard[] };
type Block =
| { kind: "movie"; id: string; item: PlexCard }
| { kind: "show"; id: string; showKey: string; title: string; items: PlexCard[]; seasons: Season[] };
function buildSeasons(items: PlexCard[]): Season[] {
const map = new Map<string, Season>();
for (const it of items) {
const num = it.season_number ?? null;
const key = String(num ?? "?");
let s = map.get(key);
if (!s) {
s = { key, number: num, items: [] };
map.set(key, s);
}
s.items.push(it);
}
return [...map.values()];
}
// Turn the flat ordered list into blocks: contiguous episodes of one show group together (then split
// into seasons for display); everything else (movies) is its own block.
function buildBlocks(items: PlexCard[]): Block[] {
const blocks: Block[] = [];
for (const it of items) {
if (it.type === "episode") {
const showKey = it.show_id || it.show_title || "?";
const last = blocks[blocks.length - 1];
if (last && last.kind === "show" && last.showKey === showKey) {
last.items.push(it);
} else {
blocks.push({
kind: "show",
id: `show:${showKey}`,
showKey,
title: it.show_title || it.title,
items: [it],
seasons: [],
});
}
} else {
blocks.push({ kind: "movie", id: it.id, item: it });
}
}
for (const b of blocks) if (b.kind === "show") b.seasons = buildSeasons(b.items);
return blocks;
}
const flatten = (blocks: Block[]): PlexCard[] =>
blocks.flatMap((b) => (b.kind === "movie" ? [b.item] : b.items));
// Callbacks + shared state passed down to the module-scope block/row components.
type Ctx = {
layout: Layout;
busy: boolean;
expandedShows: Set<string>;
collapsedSeasons: Set<string>;
toggleShow: (k: string) => void;
toggleSeason: (k: string) => void;
onPlay: (rk: string) => void;
onRemove: (keys: string[]) => void;
};
function Poster({ src, className }: { src: string; className: string }) {
return (
<div className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${className}`}>
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
</div>
);
}
function DragHandle(props: Record<string, unknown>) {
return (
<button
{...props}
className="shrink-0 cursor-grab text-muted hover:text-fg active:cursor-grabbing"
aria-label="reorder"
>
<GripVertical className="h-4 w-4" />
</button>
);
}
function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: ep.id,
disabled: ctx.busy,
});
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 };
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`}
>
<DragHandle {...attributes} {...listeners} />
<span className="w-5 shrink-0 text-center text-[11px] text-muted tabular-nums">{ep.episode_number}</span>
<button onClick={() => ctx.onPlay(ep.id)} className="group flex min-w-0 flex-1 items-center gap-2.5 text-left">
<div className={`relative shrink-0 overflow-hidden rounded border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}>
<img src={ep.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
{ep.status === "watched" && (
<span className="absolute right-0.5 top-0.5 rounded bg-black/70 px-1 text-[9px] text-white"></span>
)}
{inProgress && <div className="absolute inset-x-0 bottom-0 h-0.5 bg-accent" />}
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover:opacity-100">
<Play className="h-4 w-4 text-white" fill="currentColor" />
</div>
</div>
<div className="min-w-0 truncate text-[13px]">{ep.title}</div>
</button>
<button
onClick={() => ctx.onRemove([ep.id])}
disabled={ctx.busy}
className="shrink-0 rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
aria-label="remove"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
disabled: ctx.busy,
});
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 };
const dense = ctx.layout === "tree";
return (
<div ref={setNodeRef} style={style} className="glass-card flex items-center gap-3 rounded-xl p-2">
<DragHandle {...attributes} {...listeners} />
<button onClick={() => ctx.onPlay(item.id)} className="group flex min-w-0 flex-1 items-center gap-3 text-left">
<Poster src={item.thumb} className={dense ? "aspect-[2/3] w-10" : "aspect-[2/3] w-14"} />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.title}</div>
<div className="text-xs text-muted">{item.year}</div>
</div>
<div className="pointer-events-none ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent/0 text-transparent transition group-hover:bg-accent/15 group-hover:text-accent">
<Play className="h-4 w-4" fill="currentColor" />
</div>
</button>
<button
onClick={() => ctx.onRemove([item.id])}
disabled={ctx.busy}
className="shrink-0 rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
aria-label="remove"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ctx: Ctx }) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: block.id,
disabled: ctx.busy,
});
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 };
const open = ctx.expandedShows.has(block.showKey);
const dense = ctx.layout === "tree";
const seasonLabel = (s: Season) =>
s.number != null ? t("plex.playlist.season", { n: s.number }) : t("plex.playlist.seasonUnknown");
return (
<div ref={setNodeRef} style={style} className="glass-card overflow-hidden rounded-xl">
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
<div className="flex items-center gap-2.5 p-2">
<DragHandle {...attributes} {...listeners} />
<button
onClick={() => ctx.toggleShow(block.showKey)}
className="grid h-6 w-6 shrink-0 place-items-center rounded text-muted hover:text-fg"
aria-expanded={open}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{!dense && (
<button onClick={() => ctx.toggleShow(block.showKey)} className="flex shrink-0">
{block.items.slice(0, 3).map((ep, i) => (
<img
key={ep.id}
src={ep.thumb}
alt=""
loading="lazy"
className="aspect-[2/3] w-8 rounded border-2 border-surface object-cover"
style={{ marginLeft: i ? -14 : 0 }}
/>
))}
</button>
)}
<button onClick={() => ctx.toggleShow(block.showKey)} className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-semibold">{block.title}</div>
<div className="text-[11px] text-muted">{t("plex.playlist.episodes", { count: block.items.length })}</div>
</button>
<button
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
disabled={ctx.busy}
title={t("plex.playlist.removeShow")}
className="shrink-0 rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
>
<X className="h-4 w-4" />
</button>
</div>
{open && (
<div className={`border-t border-border/50 ${dense ? "px-2 pb-1.5 pt-1" : "px-2 pb-2"}`}>
<SortableContext items={block.items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
{block.seasons.map((s) => {
const sKey = `${block.showKey}:${s.key}`;
const sOpen = !dense || !ctx.collapsedSeasons.has(sKey);
return (
<div key={s.key} className={dense ? "" : "mt-1.5 first:mt-0.5"}>
<div className="flex items-center gap-1.5 py-1 pl-1">
{dense && (
<button
onClick={() => ctx.toggleSeason(sKey)}
className="grid h-5 w-5 shrink-0 place-items-center rounded text-muted hover:text-fg"
aria-expanded={sOpen}
>
{sOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
<span className="text-[11px] font-medium uppercase tracking-wide text-muted">
{seasonLabel(s)} · {s.items.length}
</span>
<button
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
disabled={ctx.busy}
title={t("plex.playlist.removeSeason")}
className="ml-1 rounded p-0.5 text-muted hover:text-red-400 disabled:opacity-30"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{sOpen && (
<div className={dense ? "ml-2 space-y-0.5 border-l border-border/50 pl-1.5" : "space-y-1"}>
{s.items.map((ep) => (
<EpisodeRow key={ep.id} ep={ep} ctx={ctx} dense={dense} />
))}
</div>
)}
</div>
);
})}
</SortableContext>
</div>
)}
</div>
);
}
export default function PlexPlaylistView({ export default function PlexPlaylistView({
playlistId, playlistId,
onBack, onBack,
@ -22,38 +310,52 @@ export default function PlexPlaylistView({
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [layout, setLayout] = useState<Layout>(() =>
getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion",
);
useEffect(() => {
setAccountRaw(LS.plexPlaylistLayout, layout);
}, [layout]);
// Show groups start collapsed (compact headers) so a long series doesn't force endless scrolling;
// seasons start expanded once their show is opened. Both are just local view state.
const [expandedShows, setExpandedShows] = useState<Set<string>>(new Set());
const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set());
const q = useQuery({ queryKey: ["plex-playlist", playlistId], queryFn: () => api.plexPlaylist(playlistId) }); const q = useQuery({ queryKey: ["plex-playlist", playlistId], queryFn: () => api.plexPlaylist(playlistId) });
const items: PlexCard[] = q.data?.items ?? []; // Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
const order = items.map((i) => i.id); const [items, setItems] = useState<PlexCard[]>([]);
useEffect(() => {
if (q.data) setItems(q.data.items);
}, [q.data]);
const blocks = useMemo(() => buildBlocks(items), [items]);
const order = useMemo(() => items.map((i) => i.id), [items]);
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] }); qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] });
qc.invalidateQueries({ queryKey: ["plex-playlists"] }); qc.invalidateQueries({ queryKey: ["plex-playlists"] });
}; };
async function move(idx: number, dir: -1 | 1) { function commit(next: PlexCard[]) {
const to = idx + dir; setItems(next);
if (busy || to < 0 || to >= order.length) return; api
.plexReorderPlaylist(playlistId, next.map((i) => i.id))
.then(() => qc.invalidateQueries({ queryKey: ["plex-playlists"] }));
}
async function onRemove(keys: string[]) {
if (busy || !keys.length) return;
setBusy(true); setBusy(true);
const next = [...order]; const set = new Set(keys);
[next[idx], next[to]] = [next[to], next[idx]]; setItems((prev) => prev.filter((i) => !set.has(i.id)));
try { try {
await api.plexReorderPlaylist(playlistId, next); if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]);
invalidate(); else await api.plexPlaylistRemoveBulk(playlistId, keys);
} finally {
setBusy(false);
}
}
async function remove(itemRk: string) {
if (busy) return;
setBusy(true);
try {
await api.plexPlaylistRemoveItem(playlistId, itemRk);
invalidate(); invalidate();
} finally { } finally {
setBusy(false); setBusy(false);
} }
} }
async function rename() { async function rename() {
const title = name.trim(); const title = name.trim();
if (!title) return; if (!title) return;
@ -69,8 +371,79 @@ export default function PlexPlaylistView({
onBack(); onBack();
} }
const toggleShow = (k: string) =>
setExpandedShows((s) => {
const n = new Set(s);
n.has(k) ? n.delete(k) : n.add(k);
return n;
});
const toggleSeason = (k: string) =>
setCollapsedSeasons((s) => {
const n = new Set(s);
n.has(k) ? n.delete(k) : n.add(k);
return n;
});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const aId = String(active.id);
const oId = String(over.id);
const blockIds = new Set(blocks.map((b) => b.id));
if (blockIds.has(aId)) {
// Moving a whole block (movie or show). Resolve the drop target to a block.
let oBlockId = oId;
if (!blockIds.has(oId)) {
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === oId));
if (!b) return;
oBlockId = b.id;
}
const from = blocks.findIndex((b) => b.id === aId);
const to = blocks.findIndex((b) => b.id === oBlockId);
if (from < 0 || to < 0) return;
commit(flatten(arrayMove(blocks, from, to)));
} else {
// Moving an episode — only within its own show block.
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId));
if (!b || b.kind !== "show" || !b.items.some((it) => it.id === oId)) return;
const from = b.items.findIndex((it) => it.id === aId);
const to = b.items.findIndex((it) => it.id === oId);
commit(flatten(blocks.map((bl) => (bl.id === b.id ? { ...bl, items: arrayMove(b.items, from, to) } : bl))));
}
}
const ctx: Ctx = {
layout,
busy,
expandedShows,
collapsedSeasons,
toggleShow,
toggleSeason,
onPlay: (rk) => onPlay(rk, order),
onRemove,
};
const LayoutBtn = ({ v, icon, label }: { v: Layout; icon: ReactNode; label: string }) => (
<button
onClick={() => setLayout(v)}
title={label}
aria-label={label}
aria-pressed={layout === v}
className={`rounded-lg p-2 text-sm transition ${
layout === v ? "bg-accent/15 text-accent" : "glass-card glass-hover text-muted"
}`}
>
{icon}
</button>
);
return ( return (
<div className="w-[90%] max-w-[1100px] mx-auto p-4"> <div className="mx-auto w-[90%] max-w-[1100px] p-4">
<button <button
onClick={onBack} onClick={onBack}
className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs" className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"
@ -80,7 +453,6 @@ export default function PlexPlaylistView({
</button> </button>
<div className="glass rounded-2xl p-4 sm:p-6"> <div className="glass rounded-2xl p-4 sm:p-6">
{/* Header: title (rename) + Play all + delete */}
<div className="mb-4 flex flex-wrap items-center gap-3"> <div className="mb-4 flex flex-wrap items-center gap-3">
{renaming ? ( {renaming ? (
<input <input
@ -94,6 +466,11 @@ export default function PlexPlaylistView({
) : ( ) : (
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1> <h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
)} )}
{/* Layout preference: Accordion (A) or Tree (C). */}
<div className="flex items-center gap-1">
<LayoutBtn v="accordion" icon={<Rows3 className="h-4 w-4" />} label={t("plex.playlist.layoutAccordion")} />
<LayoutBtn v="tree" icon={<ListTree className="h-4 w-4" />} label={t("plex.playlist.layoutTree")} />
</div>
<button <button
onClick={() => { onClick={() => {
setName(q.data?.title ?? ""); setName(q.data?.title ?? "");
@ -127,56 +504,19 @@ export default function PlexPlaylistView({
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p> <p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
) : ( ) : (
<ol className="space-y-1.5"> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
{items.map((it, idx) => ( <SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
<li key={it.id} className="glass-card flex items-center gap-3 rounded-lg p-2"> <div className="space-y-1.5">
<span className="w-5 shrink-0 text-center text-xs text-muted">{idx + 1}</span> {blocks.map((b) =>
<button b.kind === "movie" ? (
onClick={() => onPlay(it.id, order)} <MovieRow key={b.id} item={b.item} ctx={ctx} />
className="group flex min-w-0 flex-1 items-center gap-3 text-left" ) : (
> <ShowGroup key={b.id} block={b} ctx={ctx} />
<div className="relative h-14 w-10 shrink-0 overflow-hidden rounded border border-border bg-surface"> ),
<img src={it.thumb} alt="" loading="lazy" className="h-full w-full object-cover" /> )}
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover:opacity-100"> </div>
<Play className="h-5 w-5 text-white" fill="currentColor" /> </SortableContext>
</div> </DndContext>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{it.type === "episode" && it.show_title ? `${it.show_title}${it.title}` : it.title}
</div>
<div className="text-xs text-muted">{it.year}</div>
</div>
</button>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => move(idx, -1)}
disabled={busy || idx === 0}
className="rounded p-1 text-muted hover:text-fg disabled:opacity-30"
title={t("plex.playlist.up")}
>
<ChevronUp className="h-4 w-4" />
</button>
<button
onClick={() => move(idx, 1)}
disabled={busy || idx === items.length - 1}
className="rounded p-1 text-muted hover:text-fg disabled:opacity-30"
title={t("plex.playlist.down")}
>
<ChevronDown className="h-4 w-4" />
</button>
<button
onClick={() => remove(it.id)}
disabled={busy}
className="rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
title={t("plex.playlist.remove")}
>
<X className="h-4 w-4" />
</button>
</div>
</li>
))}
</ol>
)} )}
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Download, Lock, PlayCircle } from "lucide-react"; import { Download, Link2, Lock, PlayCircle } from "lucide-react";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app // Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny // tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@ -9,11 +9,24 @@ type Meta = {
needs_password: boolean; needs_password: boolean;
title?: string | null; title?: string | null;
uploader?: string | null; uploader?: string | null;
uploader_url?: string | null;
source_url?: string | null;
extra_links?: string[];
duration_s?: number | null; duration_s?: number | null;
allow_download?: boolean; allow_download?: boolean;
file_url?: string; file_url?: string;
}; };
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
try {
const u = new URL(url);
return (u.host + u.pathname + u.search).replace(/\/$/, "");
} catch {
return url;
}
}
const STR = { const STR = {
en: { en: {
loading: "Loading…", loading: "Loading…",
@ -24,6 +37,7 @@ const STR = {
watch: "Watch", watch: "Watch",
wrong: "Wrong password.", wrong: "Wrong password.",
download: "Download", download: "Download",
links: "Links",
brand: "Shared via Siftlode", brand: "Shared via Siftlode",
}, },
hu: { hu: {
@ -35,6 +49,7 @@ const STR = {
watch: "Megnézem", watch: "Megnézem",
wrong: "Hibás jelszó.", wrong: "Hibás jelszó.",
download: "Letöltés", download: "Letöltés",
links: "Linkek",
brand: "Megosztva a Siftlode-dal", brand: "Megosztva a Siftlode-dal",
}, },
de: { de: {
@ -46,6 +61,7 @@ const STR = {
watch: "Ansehen", watch: "Ansehen",
wrong: "Falsches Passwort.", wrong: "Falsches Passwort.",
download: "Herunterladen", download: "Herunterladen",
links: "Links",
brand: "Geteilt über Siftlode", brand: "Geteilt über Siftlode",
}, },
} as const; } as const;
@ -165,7 +181,19 @@ export default function WatchPage() {
<div className="mt-3 flex items-start gap-3"> <div className="mt-3 flex items-start gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1> <h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
{meta.uploader && <div className="text-sm text-slate-400">{meta.uploader}</div>} {meta.uploader &&
(meta.uploader_url ? (
<a
href={meta.uploader_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-400 hover:text-teal-400 hover:underline"
>
{meta.uploader}
</a>
) : (
<div className="text-sm text-slate-400">{meta.uploader}</div>
))}
</div> </div>
{meta.allow_download && ( {meta.allow_download && (
<a <a
@ -176,6 +204,25 @@ export default function WatchPage() {
</a> </a>
)} )}
</div> </div>
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
<div className="mt-3 flex flex-col gap-1.5">
{[meta.source_url, ...(meta.extra_links ?? [])]
.filter((u): u is string => !!u)
.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-teal-400 hover:underline min-w-0"
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayUrl(url)}</span>
</a>
))}
</div>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -56,6 +56,7 @@
"retry": "Erneut", "retry": "Erneut",
"download": "Auf mein Gerät speichern", "download": "Auf mein Gerät speichern",
"rename": "Umbenennen", "rename": "Umbenennen",
"editDetails": "Details bearbeiten",
"share": "Teilen", "share": "Teilen",
"edit": "Bearbeiten / schneiden", "edit": "Bearbeiten / schneiden",
"removeShared": "Aus meiner Liste entfernen" "removeShared": "Aus meiner Liste entfernen"
@ -83,6 +84,17 @@
"hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.", "hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.",
"save": "Speichern" "save": "Speichern"
}, },
"edit": {
"title": "Details bearbeiten",
"name": "Titel",
"channel": "Kanal",
"channelUrl": "Kanal-Link",
"extraLinks": "Weitere Links",
"addLink": "Link hinzufügen",
"removeLink": "Link entfernen",
"hint": "Nur Anzeige — die gespeicherte Datei behält ihren Namen. Kanal und Links erscheinen auch auf der Karte und auf geteilten Wiedergabeseiten.",
"save": "Speichern"
},
"share": { "share": {
"title": "Download teilen", "title": "Download teilen",
"label": "E-Mail des Empfängers", "label": "E-Mail des Empfängers",

View file

@ -158,7 +158,8 @@
"newPlaceholder": "Name der neuen Playlist…", "newPlaceholder": "Name der neuen Playlist…",
"create": "Erstellen", "create": "Erstellen",
"none": "Noch keine Playlists. Erstelle oben eine.", "none": "Noch keine Playlists. Erstelle oben eine.",
"count": "{{count}} Titel" "count": "{{count}} Titel",
"groupProgress": "{{in}} / {{size}}"
}, },
"playlist": { "playlist": {
"section": "Playlists", "section": "Playlists",
@ -172,6 +173,16 @@
"empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.", "empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.",
"up": "Nach oben", "up": "Nach oben",
"down": "Nach unten", "down": "Nach unten",
"remove": "Entfernen" "remove": "Entfernen",
"layoutAccordion": "Akkordeon-Ansicht",
"layoutTree": "Baum-Ansicht",
"removeShow": "Ganze Serie entfernen",
"removeSeason": "Ganze Staffel entfernen",
"episodes": "{{count}} Folgen",
"season": "Staffel {{n}}",
"seasonUnknown": "Folgen",
"addShow": "Ganze Serie zu einer Playlist",
"addSeason": "Staffel zu einer Playlist",
"addEpisode": "Folge zu einer Playlist"
} }
} }

View file

@ -56,6 +56,7 @@
"retry": "Retry", "retry": "Retry",
"download": "Save to my device", "download": "Save to my device",
"rename": "Rename", "rename": "Rename",
"editDetails": "Edit details",
"share": "Share", "share": "Share",
"edit": "Edit / trim", "edit": "Edit / trim",
"removeShared": "Remove from my list" "removeShared": "Remove from my list"
@ -83,6 +84,17 @@
"hint": "Only changes the name you see and download with — the stored file keeps its own name.", "hint": "Only changes the name you see and download with — the stored file keeps its own name.",
"save": "Save" "save": "Save"
}, },
"edit": {
"title": "Edit details",
"name": "Title",
"channel": "Channel",
"channelUrl": "Channel link",
"extraLinks": "Extra links",
"addLink": "Add link",
"removeLink": "Remove link",
"hint": "Display only — the stored file keeps its own name. The channel and links also show on the card and on shared watch pages.",
"save": "Save"
},
"share": { "share": {
"title": "Share download", "title": "Share download",
"label": "Recipient email", "label": "Recipient email",

View file

@ -158,7 +158,8 @@
"newPlaceholder": "New playlist name…", "newPlaceholder": "New playlist name…",
"create": "Create", "create": "Create",
"none": "No playlists yet. Create one above.", "none": "No playlists yet. Create one above.",
"count": "{{count}} items" "count": "{{count}} items",
"groupProgress": "{{in}} / {{size}}"
}, },
"playlist": { "playlist": {
"section": "Playlists", "section": "Playlists",
@ -172,6 +173,16 @@
"empty": "This playlist is empty. Add titles from their info page.", "empty": "This playlist is empty. Add titles from their info page.",
"up": "Move up", "up": "Move up",
"down": "Move down", "down": "Move down",
"remove": "Remove" "remove": "Remove",
"layoutAccordion": "Accordion view",
"layoutTree": "Tree view",
"removeShow": "Remove whole show",
"removeSeason": "Remove whole season",
"episodes": "{{count}} episodes",
"season": "Season {{n}}",
"seasonUnknown": "Episodes",
"addShow": "Add whole show to a playlist",
"addSeason": "Add season to a playlist",
"addEpisode": "Add episode to a playlist"
} }
} }

View file

@ -56,6 +56,7 @@
"retry": "Újra", "retry": "Újra",
"download": "Mentés a gépemre", "download": "Mentés a gépemre",
"rename": "Átnevezés", "rename": "Átnevezés",
"editDetails": "Adatok szerkesztése",
"share": "Megosztás", "share": "Megosztás",
"edit": "Szerkesztés / vágás", "edit": "Szerkesztés / vágás",
"removeShared": "Eltávolítás a listámból" "removeShared": "Eltávolítás a listámból"
@ -83,6 +84,17 @@
"hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.", "hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.",
"save": "Mentés" "save": "Mentés"
}, },
"edit": {
"title": "Adatok szerkesztése",
"name": "Cím",
"channel": "Csatorna",
"channelUrl": "Csatorna link",
"extraLinks": "További linkek",
"addLink": "Link hozzáadása",
"removeLink": "Link eltávolítása",
"hint": "Csak megjelenítés — a tárolt fájl neve nem változik. A csatorna és a linkek a kártyán és a megosztott lejátszó oldalon is megjelennek.",
"save": "Mentés"
},
"share": { "share": {
"title": "Letöltés megosztása", "title": "Letöltés megosztása",
"label": "Címzett e-mail címe", "label": "Címzett e-mail címe",

View file

@ -158,7 +158,8 @@
"newPlaceholder": "Új lista neve…", "newPlaceholder": "Új lista neve…",
"create": "Létrehozás", "create": "Létrehozás",
"none": "Még nincs lista. Hozz létre egyet fent.", "none": "Még nincs lista. Hozz létre egyet fent.",
"count": "{{count}} elem" "count": "{{count}} elem",
"groupProgress": "{{in}} / {{size}}"
}, },
"playlist": { "playlist": {
"section": "Lejátszási listák", "section": "Lejátszási listák",
@ -172,6 +173,16 @@
"empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.", "empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.",
"up": "Fel", "up": "Fel",
"down": "Le", "down": "Le",
"remove": "Eltávolítás" "remove": "Eltávolítás",
"layoutAccordion": "Harmonika nézet",
"layoutTree": "Fa nézet",
"removeShow": "Egész sorozat eltávolítása",
"removeSeason": "Egész évad eltávolítása",
"episodes": "{{count}} epizód",
"season": "{{n}}. évad",
"seasonUnknown": "Epizódok",
"addShow": "Egész sorozat listához adása",
"addSeason": "Évad listához adása",
"addEpisode": "Epizód listához adása"
} }
} }

View file

@ -653,6 +653,7 @@ export interface PlexCard {
season_number?: number | null; // episode season_number?: number | null; // episode
episode_number?: number | null; // episode episode_number?: number | null; // episode
show_title?: string | null; // episode (in a mixed playlist) show_title?: string | null; // episode (in a mixed playlist)
show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
summary?: string | null; // episode summary?: string | null; // episode
} }
export interface PlexBrowseResult { export interface PlexBrowseResult {
@ -737,6 +738,7 @@ export interface PlexPlaylist {
item_count: number; item_count: number;
thumb?: string | null; thumb?: string | null;
has_item?: boolean; // only when the list was fetched with ?contains=<rating_key> has_item?: boolean; // only when the list was fetched with ?contains=<rating_key>
group_in?: number; // only when fetched with ?contains_group=<rk,rk,…>: how many of the group it holds
} }
export interface PlexPlaylistDetail { export interface PlexPlaylistDetail {
id: number; id: number;
@ -849,6 +851,7 @@ export interface DownloadAsset {
status: string; status: string;
title: string | null; title: string | null;
uploader: string | null; uploader: string | null;
uploader_url: string | null; // yt-dlp channel/uploader page URL (auto; null for e.g. reddit)
upload_date: string | null; upload_date: string | null;
duration_s: number | null; duration_s: number | null;
size_bytes: number | null; size_bytes: number | null;
@ -901,6 +904,9 @@ export interface DownloadJob {
profile_id: number | null; profile_id: number | null;
spec: DownloadSpec; spec: DownloadSpec;
display_name: string | null; display_name: string | null;
display_uploader: string | null; // user override of the channel name
display_uploader_url: string | null; // user override of the channel link
extra_links: string[]; // extra reference URLs (beyond source_url)
job_kind?: string; // "download" (default) | "edit" job_kind?: string; // "download" (default) | "edit"
source_job_id?: number | null; // parent download an edit was cut from source_job_id?: number | null; // parent download an edit was cut from
edit_spec?: EditSpec | null; edit_spec?: EditSpec | null;
@ -1178,11 +1184,25 @@ export const api = {
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> => plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }), req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }),
// --- Playlists (Siftlode-native, per-user, ordered) --- // --- Playlists (Siftlode-native, per-user, ordered) ---
plexPlaylists: (contains?: string): Promise<{ playlists: PlexPlaylist[] }> => // `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole
req(`/api/plex/playlists${contains ? `?contains=${encodeURIComponent(contains)}` : ""}`), // season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog).
plexPlaylists: (
contains?: string,
containsGroup?: string[],
): Promise<{ playlists: PlexPlaylist[]; group_size?: number }> => {
const p = new URLSearchParams();
if (contains) p.set("contains", contains);
if (containsGroup) p.set("contains_group", containsGroup.join(","));
const qs = p.toString();
return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`);
},
plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`), plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> => plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }), req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }),
plexPlaylistAddBulk: (id: number, itemRks: string[]): Promise<{ added: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }),
plexPlaylistRemoveBulk: (id: number, itemRks: string[]): Promise<{ removed: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/remove-bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }),
plexRenamePlaylist: (id: number, title: string): Promise<PlexPlaylist> => plexRenamePlaylist: (id: number, title: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }), req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }),
plexDeletePlaylist: (id: number): Promise<{ deleted: number }> => plexDeletePlaylist: (id: number): Promise<{ deleted: number }> =>
@ -1394,8 +1414,17 @@ export const api = {
cancelDownload: (id: number): Promise<DownloadJob> => cancelDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }), req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }), deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
renameDownload: (id: number, display_name: string): Promise<DownloadJob> => // Update a download's display metadata (title / channel name+link / extra reference URLs).
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify({ display_name }) }), updateDownloadMeta: (
id: number,
meta: {
display_name?: string;
display_uploader?: string;
display_uploader_url?: string;
extra_links?: string[];
}
): Promise<DownloadJob> =>
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> => shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }), req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
unshareDownload: (id: number, email: string) => unshareDownload: (id: number, email: string) =>

View file

@ -14,6 +14,19 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.31.0",
date: "2026-07-07",
summary: "Richer downloads — edit details, clickable channels & links, and prettier share-link previews.",
features: [
"Downloads: the edit (pencil) button now edits the full details — title, channel name and a channel link, plus any number of extra reference links. The channel and links appear on the download card as clickable links, and a source that carries a channel (like YouTube or Facebook) fills the channel link in automatically.",
"Downloads: shared watch pages now show the correct title, channel and links — including your own edits — with every link clickable. (Renaming a download is now reflected on its share link too.)",
"Shared links now unfurl richly in chat apps like Messenger, with the video's title, channel and thumbnail instead of a blank card. Videos without a thumbnail still show a clean text card.",
],
chores: [
"Shorter, clearer site description for search engines and link previews.",
],
},
{ {
version: "0.30.0", version: "0.30.0",
date: "2026-07-06", date: "2026-07-06",

View file

@ -27,6 +27,7 @@ export const LS = {
plexShow: "siftlode.plexShow", plexShow: "siftlode.plexShow",
plexSort: "siftlode.plexSort", plexSort: "siftlode.plexSort",
plexFilters: "siftlode.plexFilters", plexFilters: "siftlode.plexFilters",
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
notifHistory: "siftlode.notifications", notifHistory: "siftlode.notifications",
notifSettings: "siftlode.notifSettings", notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed", onboardingDismissed: "siftlode.onboarding.dismissed",