- Share: a link that expired is never copied (the dialog and one-click Share pick a live one, else make one), says it expired, and the copy-on-open claim clears on the next copy. - A password-protected file link asks to open a file, not to watch a video. - Upload: Cancel, the close button or the backdrop while naming a folder makes no folder; a failed refetch keeps the target; a folder being made says so. - Crop: an infinite number is dropped, not a 500; an unprobed clip's size follows a shown-size crop; the edit_spec docs name frame_w/frame_h; the join and rotated paths are tested. - Auto-skip: the wait gives way on a marker too short for it, so the skip still fires. - Tag chips count the followed channels (subscribed_count) and a toggled chip keeps the focus. - Continue watching's See all sorts by last watched when in progress was already on.
193 lines
9.3 KiB
Python
193 lines
9.3 KiB
Python
"""Server-rendered Open Graph tags for the public page of a share link (/watch/, /file/, /folder/).
|
||
|
||
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.orm import Session
|
||
|
||
from app.config import settings
|
||
from app.downloads import links as linksmod
|
||
from app.downloads import storage
|
||
from app.downloads.models import DownloadJob, DownloadLink, FileFolder, 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 _jpeg_size(path: Path) -> tuple[int, int] | None:
|
||
"""Read (width, height) from a JPEG's SOF marker without an image library (Pillow isn't a dep).
|
||
|
||
Facebook's scraper fetches og:image ASYNCHRONOUSLY on a URL's first scrape, so a card built then
|
||
has NO image unless og:image:width/height are declared up front — which is exactly why a freshly
|
||
shared /watch link unfurled title-only on desktop Messenger. We can't declare dimensions we don't
|
||
know, and the poster's aspect differs from the video's, so read them straight from the JPEG here
|
||
(cheap — only on a crawler hit). Returns None for a non-JPEG/truncated file, in which case the
|
||
dimensions are simply omitted (the prior behaviour)."""
|
||
try:
|
||
with path.open("rb") as f:
|
||
if f.read(2) != b"\xff\xd8": # SOI — not a JPEG
|
||
return None
|
||
while True:
|
||
b = f.read(1)
|
||
if not b:
|
||
return None
|
||
if b != b"\xff":
|
||
continue
|
||
marker = f.read(1)
|
||
while marker == b"\xff": # skip fill bytes
|
||
marker = f.read(1)
|
||
if not marker:
|
||
return None
|
||
m = marker[0]
|
||
if m == 0x01 or 0xD0 <= m <= 0xD9: # standalone markers, no length payload
|
||
continue
|
||
seg = f.read(2)
|
||
if len(seg) < 2:
|
||
return None
|
||
length = (seg[0] << 8) + seg[1]
|
||
# SOF0..SOF15 carry the frame dimensions (skip DHT/DAC/RST, which share the range).
|
||
if 0xC0 <= m <= 0xCF and m not in (0xC4, 0xC8, 0xCC):
|
||
data = f.read(5)
|
||
if len(data) < 5:
|
||
return None
|
||
height = (data[1] << 8) + data[2]
|
||
width = (data[3] << 8) + data[4]
|
||
return width, height
|
||
f.seek(length - 2, 1) # skip this segment's payload
|
||
# silent-ok: None omits width/height from the unfurl card, which is what the card does for
|
||
# every poster whose dimensions were never asked for. Nothing is claimed that is not true.
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
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 = linksmod.find(db, token)
|
||
if link is None or linksmod.is_expired(link):
|
||
return None
|
||
url = f"{settings.app_base}{linksmod.share_path(db, link, token)}"
|
||
# What the card calls it, as its address does.
|
||
kind = linksmod.opens(db, link)
|
||
folder, video = kind == "folder", kind == "video"
|
||
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 " + ("folder" if folder else "video" if video else "file"),
|
||
),
|
||
_tag("og:description", "Shared via Siftlode"),
|
||
_tag("og:type", "video.other" if video else "website"),
|
||
_tag("og:url", url),
|
||
_tag("og:site_name", "Siftlode"),
|
||
]
|
||
)
|
||
if folder:
|
||
return _folder_tags(db, link, url)
|
||
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" if video else "File")).strip()
|
||
channel = (job.display_uploader or asset.uploader or "").strip()
|
||
# Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the
|
||
# Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's
|
||
# decoder won't accept, even though browsers/iOS/Twitter render it fine. So for a YouTube source
|
||
# prefer its STABLE hqdefault thumbnail — a Google-encoded JPEG (480×360) at a non-expiring URL
|
||
# (unlike the stored `sqp`-signed variant) that FB decodes reliably. Non-YouTube sources fall back
|
||
# to the poster (fine everywhere but FB) then the raw remote thumbnail. This block only runs for
|
||
# non-password links, so the poster needs no grant.
|
||
yt_id_match = re.search(r"i\.ytimg\.com/vi/([\w-]+)/", asset.thumbnail_url or "")
|
||
image_dims: tuple[int, int] | None = None
|
||
if yt_id_match:
|
||
image: str | None = f"https://i.ytimg.com/vi/{yt_id_match.group(1)}/hqdefault.jpg"
|
||
image_dims = (480, 360)
|
||
elif asset.poster_path:
|
||
image = f"{settings.app_base}/api/public/watch/{token}/poster.jpg"
|
||
else:
|
||
image = asset.thumbnail_url
|
||
tags = [
|
||
f"<title>{html.escape(title)}</title>",
|
||
_tag("og:title", title),
|
||
_tag("og:description", channel or "Shared via Siftlode"),
|
||
_tag("og:type", "video.other" if video else "website"),
|
||
_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))
|
||
# Declare the image is a public HTTPS JPEG with known dimensions, so FB (which fetches
|
||
# og:image asynchronously on first scrape) includes it instead of unfurling title-only. For a
|
||
# poster we read the real dimensions from its JPEG header; the ytimg hqdefault is a fixed 480×360.
|
||
if image.startswith("https://"):
|
||
tags.append(_tag("og:image:secure_url", image))
|
||
tags.append(_tag("og:image:type", "image/jpeg"))
|
||
if image_dims is None and image.endswith("poster.jpg") and asset.poster_path:
|
||
# safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root —
|
||
# guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page.
|
||
poster_abs = storage.safe_abs_path(settings.files_root, asset.poster_path)
|
||
image_dims = _jpeg_size(poster_abs) if poster_abs else None
|
||
if image_dims:
|
||
tags.append(_tag("og:image:width", str(image_dims[0])))
|
||
tags.append(_tag("og:image:height", str(image_dims[1])))
|
||
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 _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
|
||
"""A folder link's card: the folder's name and how many files it holds — never their names,
|
||
which the page shows only to whoever opens it."""
|
||
folder = db.get(FileFolder, link.folder_id)
|
||
if folder is None or not folder.name:
|
||
return None
|
||
count = len(linksmod.folder_files(db, folder)) # the page's own list, so the two agree
|
||
title = folder.name.strip()
|
||
about = f"{count} file{'' if count == 1 else 's'} · Shared via Siftlode"
|
||
return "\n ".join(
|
||
[
|
||
f"<title>{html.escape(title)}</title>",
|
||
_tag("og:title", title),
|
||
_tag("og:description", about),
|
||
_tag("og:type", "website"),
|
||
_tag("og:url", url),
|
||
_tag("og:site_name", "Siftlode"),
|
||
_tag("twitter:title", title, attr="name"),
|
||
_tag("twitter:description", about, attr="name"),
|
||
_tag("twitter:card", "summary", attr="name"),
|
||
]
|
||
)
|
||
|
||
|
||
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
|
||
"""Return the watch page (watch.html) with the OG block replaced for this 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 = page_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)
|