Commit graph

1017 commits

Author SHA1 Message Date
e159d2f2bf fix: consistent section order across mobile, desktop, and cinema modes
Mobile previously placed product-right (price, download, Up Next)
as the 2nd section, right after images — a different reading flow
from desktop and cinema modes, which keep description + comments
above/alongside product-right.

Unified order for every mode and viewport:

  1. images         (sticky video/cover on mobile + desktop watch)
  2. description
  3. comments
  4. product-right  (price, download, Up Next)

Desktop normal: column 1 = images → description → comments (stacked),
column 2 = product-right (spans all rows on the right).

Desktop cinema: row 1 = images full-width, row 2 = content (description
+ comments stack) on the left, product-right on the right.

Mobile normal: all four stacked single-column in that order. Cinema
stays a no-op below 800px; the classes exist but match no rules and
the page falls through to the consistent mobile watch-mode layout.

CLAUDE.md mobile layout section updated to match.
2026-04-23 17:37:45 -04:00
795b845cf9 bump GIT_HASH to 7fa9c15 2026-04-23 13:58:28 -04:00
7fa9c15a03 fix: cinema mode is no-op on mobile; section order matches normal mode
Mobile (<800px) never really needed a different cinema layout — the
normal watch-mode mobile rules already stack images, purchase,
description, and comments in a single column with full-viewport-width
media. Cinema was re-ordering those sections (putting purchase AFTER
description/comments) and creating an inconsistency between cinema
and normal modes on the same device.

Fix: gate every cinema layout rule on @media (min-width: 800px). Below
that the .cinema-mode.cinema-wide classes exist but match no layout
rules, and the page falls through to normal mobile watch-mode.

JS: isViewportWideEnoughForCinema() uses the same 800px boundary to
decide whether to relocate the hamburger+Edit taskbar into the
sidebar. A debounced resize listener re-runs applyCinemaMode() so
the layout flips cleanly when the viewport crosses the boundary.
2026-04-23 13:58:21 -04:00
ababe32a0a bump GIT_HASH to f57abd5 2026-04-22 17:41:48 -04:00
f57abd5193 fix: cinema 2-col at 800px, comment section breathing room, button gap
Three polish tweaks for cinema mode in narrow desktop panels:

1. 2-column layout kicks in at >=800px instead of >=960px. Cinema's
   video already fills the full width, so the content | purchase
   split below it doesn't need a typical desktop-wide viewport —
   it works fine on smaller panels. This removes the 800-960px dead
   zone where cinema-wide was still single-column stacking.

2. Comments get 24px margin-top + 20px padding-top + a top border so
   they read as their own section instead of running continuous into
   the description above and the sidebar controls below.

3. Stacked sidebar buttons (hamburger, Edit, Download) now have an
   8px margin-bottom between each so they don't look shoulder-to-
   shoulder. Inherits evenly from the task-bar grid gap.
2026-04-22 17:41:41 -04:00
3c6c579b73 bump GIT_HASH to ba424d5 2026-04-22 17:24:40 -04:00
ba424d537a fix: uniform button shape in cinema sidebar
Hamburger, Edit, and Download now all render as the same component
in the cinema sidebar — full column width, same padding (14px 16px),
same font size, same line height. mps-button-small's narrow min-width
was making Edit look like a leftover chip next to the full-width
hamburger and Download buttons; overridden in the cinema sidebar
scope only.

Task-bar grid gap bumped to 8px so hamburger + Edit don't touch
each other, and their nested padding zeroed so spacing lives in
the outer grid gap rather than in mixed inline padding.
2026-04-22 17:24:35 -04:00
d6487f8861 bump GIT_HASH to 92a3050 2026-04-22 15:46:40 -04:00
92a305072e feat: cinema only activates for landscape media (aspect > 1)
Portrait and square media stay in the normal watch layout even when
the Cinema toggle is on — tall phone videos no longer get stretched
into a skinny column on a wide screen. Only wider-than-square media
triggers the layout adjustment that makes wide/ultrawide content
fill the horizontal viewport.

Two-class gate:
  .cinema-mode       — user intent (from the toggle)
  .cinema-wide       — runtime state (current media aspect > 1)

CSS layout rules now require BOTH classes. When cinema is on but
media is portrait, .cinema-mode is on and .cinema-wide is off, and
the selectors don't match — normal watch layout applies.

Aspect detection reads video.videoWidth/videoHeight or
img.naturalWidth/naturalHeight. Unknown dimensions (metadata not
yet loaded) defaults to not wide; a loadedmetadata / img.load
listener re-invokes applyCinemaMode once real dimensions are known.

Taskbar relocation also gated on cinema-wide — portrait doesn't
steal the hamburger + Edit from their normal home.
2026-04-22 15:46:33 -04:00
aa72430323 bump GIT_HASH to 872a712 2026-04-22 15:43:18 -04:00
872a7121f8 feat: cinema mode — hamburger + Edit into sidebar above Download
Two cinema-mode polishes:

1. Top task bar (hamburger / shop name / Edit button) relocates into
   .product-right above the .well on cinema-on. Cached original parent
   + next sibling at init lets us put it back on cinema-off. Moved
   within the same DOM subtree that survives SPA nav so it persists
   across product changes. CSS stacks its children vertically inside
   the sidebar instead of the horizontal strip shape up top.

2. The 'click image to open in new window' wrapper link on static
   products (images, PDFs) is redundant in cinema mode since media
   already fills the viewport. pointer-events:none disables the
   click without removing the anchor from the DOM.
2026-04-22 15:43:12 -04:00
f6654ba7d2 bump GIT_HASH to 23dd740 2026-04-22 15:30:32 -04:00
23dd740b52 fix: Autoplay rightmost in toggle row (Fresh | Reverse | Cinema | Autoplay) 2026-04-22 15:30:26 -04:00
4b5422266d bump GIT_HASH to 1386fc6 2026-04-22 15:18:24 -04:00
1386fc660c fix: cinema — description+comments in one grid cell, no row stretch
Previously purchase (ring sidebar) spanned grid rows 2 and 3. When
Up Next was tall and description was short, grid distributed the
spanning column's height across both rows — description sat at top
of a stretched row 2 with hundreds of pixels of empty space before
comments.

New structure: wrap description + comments in .cinema-content-stack.
Outside cinema mode the wrapper is display:contents (transparent,
zero effect). In cinema mode it becomes a single grid cell containing
description + comments as an internal stack. Main grid is now just
two rows: images (full width) then content | purchase.

Row 2 height = max(content column, purchase column). If purchase is
taller, content stack still starts at top, and description + comments
stay glued together at the top of the column — comments is reachable
without scrolling past a dead zone.
2026-04-22 15:18:17 -04:00
eb9dcf6df4 bump GIT_HASH to ace7bc0 2026-04-22 14:18:36 -04:00
ace7bc0475 fix: cinema mode works at every viewport size (no 800-960px dead zone)
Cinema rules previously lived only inside @media (min-width: 960px).
Between 800px and 960px the rules silently vanished and section.two-column
fell back to natural block flow — description, comments, download,
and Up Next stacked chaotically while the video floated elsewhere.
Split-screen viewports and tablet widths hit this gap.

Base cinema rules now sit outside any media query:
  narrow: single column — video > description > comments > purchase
  >= 960px: 2fr 1fr — video full-width top, description+comments
             left column, product-right (ring+download) right column

Video sizing (width:100% + max-height:92vh + object-fit:contain)
applies at all sizes.
2026-04-22 14:18:31 -04:00
54d0c944d8 bump GIT_HASH to 8b9b411 2026-04-22 14:07:49 -04:00
8b9b411ec0 fix: all 4 toggles on one row, nav fills bottom row cleanly
Cinema toggle kept flowing onto its own row because ring-header-controls
was 3-col. Bumped to 4-col so row 1 fits Fresh / Reverse / Autoplay /
Cinema together.

Nav buttons (Prev / Random / Next) now use explicit grid-columns —
Next spans cols 3→end — so there's no empty fourth column on the
right edge. Karaoke button keeps its full-row span via grid-column: 1/-1.
2026-04-22 14:07:43 -04:00
2422e5feb1 bump GIT_HASH to 157134a 2026-04-22 13:29:45 -04:00
157134ab60 fix: cinema mode — description beside ring, comments under description
Cinema grid now mirrors the non-watch desktop layout below the video:
  row 1: video (full viewport width)
  row 2: description | product-right (price + download + Up Next)
  row 3: comments   | product-right (continues, spans 2 rows)

Previously description took full width and purchase/comments
shared row 3. Fox wants the ring visible alongside the description
so viewers see Up Next while reading, and comments stacked below
description in the same column.
2026-04-22 13:29:38 -04:00
1ee956b95f bump GIT_HASH to 09f9de4 2026-04-21 18:58:35 -04:00
09f9de4636 feat: deploy + reforge bust client ring cache (HTML + localStorage)
Two paths to stale client state, both closed:

1. HTML cache — content.py and product.py now send Cache-Control:
   no-store, must-revalidate on responses. Browsers were holding
   onto rendered sidebar HTML from before the pocket fix deployed,
   producing phantom 'this didn't work' reports.

2. localStorage cache — shop.json_discovery_ring + GIT_HASH are
   hashed into a short cache_version token, stamped on every page
   (<meta name='mps-cache-version'>) and every watch_json response.
   watch.js stores it in localStorage; on page load and every SPA
   nav, mismatch triggers removal of watchRing, watchRingPosition,
   watchRingHistory, watchRingLoops, watchQueue before anything
   reads them.

watch_json error responses (404 no media, 403 not public) also
carry cache_version so clients can flush even when the target
product can't be played.

Tests:
  - test_models.py TestCacheVersion: 6 unit tests (stability, ring
    content change, ring order change, empty ring, none shop,
    GIT_HASH flip via patch).
  - test_functional.py: 3 functional tests (content page sends
    no-store + meta tag, watch_json error carries cache_version,
    cache_version shifts after reforge).

All 370 model+integration tests + 8 new functional tests green.
2026-04-21 18:58:30 -04:00
2a9e993d5d bump GIT_HASH to 690f945 2026-04-21 18:30:37 -04:00
690f945f70 feat: validate_discovery_ring health check + mod-only diagnostic endpoint
Adds validate_discovery_ring(shop) in models/shop.py that returns a
dict diagnosing four ring topology defects:

  - duplicates: IDs appearing more than once in ring (greedy-walk bug)
  - orphans: public products missing from ring (added after reforge)
  - stale: ring IDs no longer public/present (deleted or unlisted
    after reforge — the 'pocket' condition we just patched)
  - length_mismatch: ring_length != public_count

Wired into reforge_discovery_ring_async — anomalies log a warning
after each background reforge, making silent drift visible.

New route /s/{shop_id}/ring/health.json exposes the validator to
shop mods (403 for anon and non-editor users, 404 for missing shop).

Tests across all three layers:
  - Unit (test_models.py, 7 tests): mocked shop.products, each
    anomaly class verified in isolation.
  - Integration (test_integration.py, 4 tests): real shop + products
    + reforge, simulates visibility changes and late additions,
    confirms reforge heals the ring.
  - Functional (test_functional.py, 5 tests): auth required, mod
    ownership enforced, 404 on unknown shop, real-world stale
    detection through the HTTP endpoint.
2026-04-21 18:30:31 -04:00
ace97a1ef6 bump GIT_HASH to 81a5de4 2026-04-21 18:20:35 -04:00
81a5de4ce1 fix: ring 'pocket' — fill gaps from deleted/unlisted products
get_ring_related_products walked exactly backward+forward positions
in the ring and silently dropped entries whose IDs no longer resolved
to visible products. Result: sparse offsets like [-2, -1, 1, 5, 28]
visible in Up Next — a 'pocket' of live items in an otherwise stale
ring slice.

New behavior: fetch every ring product once (bulk query), keep only
visibility==1, then walk further along the ring to collect the
requested backward/forward VALID neighbors. Offsets are renumbered
contiguously (-N..-1, 1..N). Pocket is filled by skipping past
deleted/unlisted entries until we have the requested count or
exhaust the ring.

Ring traversal on the client (ringPosition + direction) uses ring
indices directly and is unaffected — only the rendered Up Next
sidebar slice is densified.
2026-04-21 18:20:28 -04:00
3470d747f3 bump GIT_HASH to 2464d87 2026-04-21 18:12:41 -04:00
2464d8722b fix: cinema mode actually fills viewport width (override media-sizing rule)
Previous cinema CSS used width:auto which pinned small-resolution videos
to their natural size, leaving huge side-margins on wide displays.

Cinema explicitly wants edge-to-edge video: width:100%, height:auto,
max-height:92vh, object-fit:contain. This trades a small letterbox on
extra-wide viewports for real full-width rendering — intentional
override of the CLAUDE.md 'never combine width:100% with max-height'
rule, which exists to prevent dead whitespace on images. For cinema
the tradeoff is reversed: fox wants big video, accepts edge letterbox.
2026-04-21 18:12:35 -04:00
c5b0637d50 bump GIT_HASH to ee1d642 2026-04-21 17:36:59 -04:00
ee1d642d92 feat: cinema mode — full-width video, Up Next + Download beside comments
New Cinema toggle alongside Fresh/Reverse/Autoplay. When on, adds
.cinema-mode class to section.two-column, restructuring the grid:

  row 1: video (full viewport width, max 85vh, preserved aspect)
  row 2: description (full width)
  row 3: product-right (price + download + Up Next) | comments

Uses display:contents on .watch-left so watch-mode children bubble
up as direct grid items. Video sizing follows CLAUDE.md media rule
(width:auto + max-width:100% + max-height:85vh) to avoid letterbox
whitespace.

Preference persisted in localStorage (watchCinemaMode). Toggle
handler attached in rebindToggles; class applied on init and on
every toggle change.
2026-04-21 17:36:53 -04:00
78378f78d6 bump GIT_HASH to a2ffcdf 2026-04-21 14:38:04 -04:00
a2ffcdfea2 fix: karaoke button on its own row, separated from Prev/Random/Next
Karaoke toggle now spans full width of ring-header-controls grid,
pushing nav buttons (Prev/Random/Next) to their own 3-column row.
When karaoke is display:none (non-eligible shops), nav buttons
flow naturally into row 2. No template changes.
2026-04-21 14:37:53 -04:00
e055619037 bump GIT_HASH to b09d1fc 2026-04-21 14:23:11 -04:00
b09d1fc739 fix: watch-mode Next/autoplay follow one true ring, honor Fresh toggle
Every forward path through the ring now uses a single selector:
  chooseNextInRing() = freshMode ? getNextUnwatchedItem() : getNextItem()

Next button, autoplay countdown, DJ crossfade target, preload, and
countdown-play-now all route through it. Previously, Next button
hardcoded skipToNextUnwatched() regardless of Fresh toggle — user
would see offset +1 in sidebar but land on a farther unwatched item.

navigateToNext and completeDjFadeout now sync ringPosition via
indexOf(target) instead of blindly advancing by +direction — necessary
when Fresh mode jumps past watched items.
2026-04-21 14:22:59 -04:00
206ff49e68 docs: never broad-grep config files — rule for operation voyeur 2026-04-16 19:31:52 -04:00
e8d4c9a0d9 bump GIT_HASH to 7d7d4a3 2026-04-16 19:26:00 -04:00
7d7d4a371a fix: pass request.app (not registry.settings) to capture_karaoke_config
KeyError 'bucket.secure_uploads.region' in the detached karaoke child
on prod. production.ini stores keys with the app. prefix
(app.bucket.secure_uploads.region) and a request hook strips that
prefix into a dict attached as request.app. request.registry.settings
still carries the raw, prefixed keys.

The upload-time and on-demand karaoke call sites were passing
request.registry.settings; capture_karaoke_config expected the
stripped dict. Dev .ini happens to match both layouts which masked
this — prod raised KeyError and the response 502'd.

Switch both call sites to request.app (matches the pattern used by
backfill_karaoke_async and backfill_mirror_async) and document the
expected shape on capture_karaoke_config.
2026-04-16 19:25:48 -04:00
a9345bb7ea bump GIT_HASH to 806a97b 2026-04-16 15:45:37 -04:00
806a97baca fix: 502 on .flv/unknown-extension upload — use guess_type + guard None
ParamValidationError on copy_object after upload — ContentType=None
hit S3. get_content_type("thumbnail2") returned None for a .flv file
because mimetypes.types_map only holds Python's built-in table (no
.flv, .mkv, .opus, .m4v, etc.).

Switch to mimetypes.guess_type, which initializes from the OS mime
database and covers every common format. Keep None as the return for
truly unknown extensions so downstream callers that chain `or
"video/mp4"` still pick the right context-aware fallback.

Guard the two copy_object / mirror_key_async sites in views/product.py
with `or "application/octet-stream"` so even a genuinely unknown
extension can't crash the upload handler again.
2026-04-16 15:45:20 -04:00
d527b57b04 bump GIT_HASH to 8fcb4da 2026-04-16 15:08:41 -04:00
8fcb4da92c fix: run karaoke in detached child so upload response returns
Upload handler called process_karaoke synchronously — for audio/video
products, vocal isolation (download, ffmpeg, voxsplit, reupload) can
take several minutes. The response hung until karaoke finished, so
users saw a black screen after a successful upload while Caddy/uwsgi
timed out. The file itself was already in S3+DB, which is why hitting
Back showed the edit page with the upload present.

Fix: extract the fork+detach logic the on-demand path already used
(views/watch.py:karaoke_process) into a reusable helper,
process_karaoke_detached, in lib/karaoke.py. Both the upload path and
the on-demand path now hand off to the helper and return immediately.

The helper takes a snapshot of shop+S3+mirror config in the parent
(capture_karaoke_config) so the grandchild only touches the DB at the
end for the final metadata write — by then the parent has long since
committed. Mirror writes run synchronously in the grandchild instead
of via mirror_keys_async daemon threads that were dying at os._exit.
2026-04-16 15:05:20 -04:00
d757cbecbe fix(cart): enforce Stripe $0.50 minimum; add public sales stats
Stripe rejects charges under $0.50 — a $0.33 item was silently freed
because requires_payment threshold (64¢) skipped all card processing.

Changes:
- Cart + Invoice: add requires_stripe_payment (>= 50¢)
- cart_checkout GET + cart_complete_checkout POST: block Stripe when
  total < $0.50 and only Stripe is available; message points to DOGE/XMR
- Invoice Stripe charge loop: guard on requires_stripe_payment (safety net)
- Shop model: add public_sales_stats boolean (default False)
- shop_settings: toggle for public_sales_stats
- shop() view: include sales_count + sales_revenue when flag is on
- shop.j2: conditional stats section (sales count + revenue)
- Migration: 1a2b3c4d5e6f add_public_sales_stats_to_shop
2026-04-10 14:37:07 -04:00
8945ee106b bump GIT_HASH to 0a8400e 2026-04-07 12:54:09 -04:00
0a8400ecb2 fix: wrap script DB sessions in transaction.manager to resolve NoTransaction error
digest_sender cron was crashing with transaction.interfaces.NoTransaction.
bootstrap() doesn't start a transaction — pyramid_tm handles that for web
requests but not console scripts. Wrap DB work in transaction.manager context
instead of manual transaction.commit() calls.

Same latent defect fixed in backfill_karaoke.py.
2026-04-07 12:53:55 -04:00
0203172dcd fix test: patch _torrent_backfill_async directly, not generate_torrent_async 2026-04-07 10:47:33 -04:00
932a0dde6e fix: backfill functions return immediately, work runs in daemon thread
Both _torrent_backfill_async and _checksum_backfill_async were blocking
the request thread by iterating all products before returning — causing
the settings form to hang on "Saving...".

Fix: snapshot shop_id, bucket, S3 creds, and work list while the DB
session is live, then hand off to a single daemon thread and return.
The request redirects instantly.
2026-04-07 10:32:42 -04:00
3faf55203b torrent: index.html links thumbnails and main file with relative paths 2026-04-06 19:36:45 -04:00
ad82b812aa checksums: compute MD5+SHA-256 for every uploaded file, display on content page
- lib/checksums.py: background thread streams from S3, computes both digests
- models/product.py: checksums property + set_checksum stored in file_metadata["checksums"]
- views/product.py: trigger compute_checksums_async after every file upload
- views/shop.py: checksum backfill function (skips files already computed),
  checksum_backfill POST view, checksum_backfill_status JSON view
- routes.py: /s/{shop_id}/checksum-backfill + /s/{shop_id}/checksum-backfill-status
- templates/content.j2: <details> block near date metadata — SHA-256 then MD5
- templates/shop_settings.j2: Backfill Checksums button + progress bar with 4s polling
2026-04-06 18:18:33 -04:00
3671133b96 torrent: multi-file bundles, content/product distinction enforced
Paid products seed: preview + thumbnails + index.md + index.html
Free content seeds: content file + thumbnails + index.md + index.html
The paid product file is never included in any torrent.

- lib/torrent.py: redesign generate_torrent for directory bundles (torf multi-file)
  - description written as index.md + rendered index.html
  - torrent stored as bundle.torrent (was product.torrent)
  - build_bundle_files() encapsulates what belongs in each bundle type
- views/product.py: trigger fires on preview upload for paid products,
  product file upload for free content
- views/shop.py: backfill eligibility mirrors same rule; backfill_status too
- tests: rewrite TestTorrentLib for new bundle interface, add bundle_files tests
2026-04-06 18:14:47 -04:00