Skip to content

About

French cinema showtime planner with visual timelines, time-window search, and direct booking links.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

882 Commits

Folders and files

Repository files navigation

MesSeances

MesSeances helps moviegoers compare nearby screenings and find a film that fits the time they actually have. Its visual schedule brings movies, cinemas, formats, languages, and booking links into one place instead of making users search across separate cinema websites.

The application interface is in French.

What you can do

  • Explore a visual daily timeline. Browse screenings by cinema or by movie across the 08:00–02:00 cinema day, then adjust date, language, format, and timeline zoom.
  • Find screenings within a strict time window. Set when a movie may start and must finish. MesSeances only returns screenings that fit completely, with an optional allowance for trailers and ads.
  • Browse the current movie catalog. See current films from the landing page, search the full schedule catalog, and open detailed pages with available screenings, artwork, synopsis, release information, and genres when metadata is available.
  • Choose favorite cinemas. Search cinemas by name or city and keep a local selection that drives the timeline, movie pages, and time-window search. Favorites stay in the current browser; no account is required.
  • Discover cinemas by city. Open public cinema pages for current and selected-date screenings, or browse exact-city pages for cinemas and films in the current schedule window.
  • Compare supported providers. Movie pages combine UGC, Kinepolis, Pathé, CGR, and Megarama showtimes when their listings have been matched as the same film.
  • Book with the cinema. Available booking actions open the provider's official booking page in a new tab.
  • Run schedule updates from the admin area. Authenticated administrators can start UGC, Kinepolis, Pathé, CGR, and Megarama synchronizations together or separately and follow current status. Megarama is also available as a scheduled target; no schedule is enabled automatically.

Typical flow

Account registration and private settings are implemented behind ACCOUNTS_ENABLED=false by default. Email/password verification uses SES; Google login uses verified OpenID Connect identity. Accounts do not synchronize favorite cinemas. Read account setup, security and rollout prerequisites before enabling the feature; Google, AWS, proxy and privacy configuration remain operator responsibilities.

  1. Select favorite cinemas.
  2. Scan today's timeline or enter a precise free-time window.
  3. Filter by language or screening format.
  4. Open a movie page to compare matched showtimes.
  5. Continue to the cinema's official website to book.

Local quick start

Requirements

  • Go 1.26.0
  • Node.js 22.23.1 and npm 10.9.8 (verified versions)
  • Python 3.12+ and Make for contributor validation
  • Docker with Docker Compose
  • PostgreSQL 18
  • A valid proxy file to enable provider synchronization from the admin area

Install dependencies from the repository root:

make install
cp deploy/.env.example deploy/.env

make install prepares application dependencies and installs gotestsum v1.13.0 and golangci-lint v2.13.1 into ignored api/bin/. This online setup may acquire a Go 1.26+ compiler for the linter; the application module remains Go 1.26.0. make install-tools prepares only these tools. Checks never download missing Go/npm tools or dependencies; rerun setup explicitly when preflight reports a missing prerequisite. Explicit make test-integration may pull its disposable postgres:18-alpine image if absent and requires a reachable local Docker daemon, not a host PostgreSQL client.

MesSeances can start after migrations without a complete schedule snapshot. In this pending state, /healthz returns 200, /readyz returns 503, and public schedule reads return 503 schedule_unavailable. Configure ADMIN_PASSWORD, an independently generated ADMIN_SESSION_SECRET, and PROXY_FILE, then trigger the first provider synchronization from the authenticated admin area. Its atomic snapshot publication becomes visible to the running API during the next five-second source poll; no restart is required.

Pathé ingestion uses only https://www.pathe.fr/api/* JSON endpoints. Like other provider ingestion, it requires configured proxies and the built-in Chrome-compatible TLS fingerprint transport, and always publishes a complete national Pathé snapshot.

CGR ingestion uses its public Gatsby cinema query and https://www.cgrcinemas.fr/api/gatsby-source-boxofficeapi/* JSON endpoints. Movie detail requests are capped at 50 IDs. It always publishes a complete national CGR snapshot. Missing CGR runtimes and unpublished room names are preserved as unknown values instead of dropping showtimes.

Megarama ingestion fetches https://ws.ticketingcine.com/config.js?site_id=CHN0042, extracts embedded gl_config JSON without evaluating JavaScript, then posts JSON-RPC get_prog to https://ws.ticketingcine.com/site once per cinema. Each request must carry that cinema's validated website as its own Referer; a generic chain referer is not valid. If both chain website fields are absent, acquisition recovers the root from that cinema's official config.js?site_id=EMSdddd, requiring matching cinema/program identities and an explicitly verified host. Populated invalid websites remain fatal. All requests, including bootstrap recovery and optional film-page artwork lookup, require the existing PROXY_FILE transport. Two workers build one complete national snapshot before atomic publication. An explicit empty cinema program is retained, including EMS1379 while its program remains empty; incomplete, malformed, conflicting, or all-empty ingestion preserves the previous snapshot. Diagnostics never include provider bodies, raw network errors, or proxy credentials.

Megarama cinema identities retain EMS codes and global film identities retain their five-character codes. Local emsx...HC... film identities are qualified with the cinema's config ID; session IDs are preserved. Published future sessions define the horizon, including distant events rather than a fixed seven-day window. Session wall times are interpreted in Europe/Paris; nonexistent or ambiguous DST times without source disambiguation are rejected. Sessions before 03:00 belong to the previous cinema day.

Megarama canonical end times use source runtime plus the persisted session first_part_duration. If source runtime is missing, public reads use confirmed cached TMDB runtime plus that same first-part duration, without another provider crawl or TMDB request. Missing first-part duration adds zero. This value is not the search ads buffer, and manually overridden display runtime does not change this canonical end. A usable canonical end always takes precedence over the generic estimate below, without a second advertising allowance. Source runtime stays unchanged in storage.

When canonical end_time equals start_time and the response movie has a positive, safe runtime, the API separately estimates the end as advertised start + advertising duration + movie runtime. Every showing response includes nullable estimated_end_time (UTC timestamp) and estimated_end_ads_minutes fields, populated together only for this fallback; source and canonical ends remain unchanged. Movie, cinema and timeline responses use 15 minutes of advertising; slot search uses its buffer_ads value (0..120, default 15). include_ads=false shifts attendance start by that buffer but does not add it twice to the estimated end. Estimates drive timeline durations, inclusive finish-before searches and selected-showing compatibility. UI estimates have a dotted underline and a hover/focus explanation using the returned advertising duration and runtime; structured data omits their endDate. Without a usable canonical end or runtime-based estimate, the end stays hidden and the showing remains ineligible for finish-before and compatibility checks.

Megarama session booking links use verified cinema-specific HTTPS hosts and #showsession?id=<session-id>. Missing links fall back to the validated official cinema website and are labeled as website links rather than reservations. Poster URLs are limited to verified images.monnaie-services.com paths: global /movie_poster/120/ images are resized to /600/, while local /ems_spectacle/120/ images retain their source size. Missing global posters can use og:image from https://www.ticketingcine.com/film/<CODE>.html; absent artwork remains absent.

Film schedule cinema entries expose nullable movie_url. On /film/[slug], the cinema name links to its MesSeances page and the chain logo independently links to the source film page when available. URLs use the actual screening's provider movie identity, so merged films and edited titles retain the correct chain destination. Supported routes are UGC's numeric film route, Pathé, Cinéode, Ociné, Cinéville, Cine Office, and global TicketingCine film codes for Megarama, Majestic, Cinewest and Véo. Cine Office links use each cinema's approved website and /film.html#/media/<source-id>, matching the shared application's /media/:id route. All nine Cine Office venues are supported; their existing synchronized IDs are sufficient, without a new sync, title slug, or additional provider request. MK2 synchronization retains its supplied film slug. Kinepolis retains the native /movies/detail/<corporateId>/<HO-id>/<event-code> route from its existing schedule metadata, using the published getMovieDetailUrl identity order and active-event precedence. The verified route works without its optional title suffix, so edited titles do not generate or change Kinepolis URLs. No extra film-page crawl is needed during synchronization. CGR, Grand Ecran, Noé Cinémas, Ciné-Movida and Véo additionally capture exact film/event URLs from their published sitemap-0.xml during synchronization, using the existing proxy transport and request counters. Sitemap URLs must match the provider host, film route and exact source ID; absent or ambiguous pages stay null. Fetch/parse failures abort the new publication, preserving the previous snapshot. No provider request is made during public reads.

Cinéville synchronization verifies the film metadata's visa matches the exact source identity and captures /programmes/<cinema-route>#<visa>. The fragment targets the film section and its sessions on the native programme page. All 24 catalog cinema routes are mapped; the API selects the screening's cinema route instead of another cinema's captured page. Negative event visas are preserved. A shared movie stays unlinked if any cinema occurrence lacks a matching anchor in its default prog list; events present only in the alternate progWithEvents filter remain unlinked. Unknown or changed cinema routes also remain unlinked. No source request is made during public reads.

Migration 076 adds nullable movies.source_page_url. Migration 077 permits only Cinéville programme fragments bound to the exact source movie ID; other provider fragments remain forbidden. Captured URLs survive API reloads and copying untouched providers into another schedule generation. Existing rows are not backfilled; the next successful sync of each provider supplies its URLs. Titles and manually edited metadata never generate source slugs. Unsupported local TicketingCine and historical Webedia identities remain unlinked.

Proxy verification on 2026-11-06 confirmed film pages for CGR, Grand Ecran, Noé, Ciné-Movida, Véo, MK2, and Kinepolis. Kinepolis verification uses the project crawler's fingerprinted HTTP/2 transport, Chrome 142 headers and pacing. Cine Office was rechecked with the project crawler’s proxy transport, headers and retry policy: all nine film.html shells returned HTTP 200, loaded the same native application, and matched their own cinema catalog context. The published JavaScript builds the /film.html#/media/<source-id> route; for example, Dune at Le Lido. Earlier generic HTTP checks returning 403 did not establish whether the crawler could access these sites. Cinéville was checked through its crawler transport, French headers, two-second pacing and retry policy. Its actual cinema-page scripts render film-card IDs from metadata visas; the film modal itself has no dedicated route. A headless Chrome check, relaying the page and its first-party scripts through that same crawler and configured proxies, confirmed Scrooge in Bruz scrolls to the matching film section.

Trailer fallback

Movies keep their existing VF/VO YouTube controls. When both selected TMDB trailers are absent after a successful metadata fetch, the public trailer object can select provider evidence. Admin trailer overrides, including an explicit empty value, take precedence; a later TMDB trailer automatically replaces the fallback. Source-only/unmatched films are not treated as having a confirmed TMDB gap.

CGR HD/SD MP4 and Cinéville YouTube references are captured from metadata already fetched by their normal synchronization, with no additional provider requests. Valid observations publish atomically with schedules; malformed optional trailer fields never reject showtimes. Candidates follow current public movie source mappings across corrections and merges. Migration 078 stores candidates and lookup outcomes separately from TMDB metadata.

The API background worker checks committed data at startup and every minute, under a PostgreSQL session lease across replicas. A cycle has a two-minute budget and at most 20 sequential UGC lookups, each bounded to 30 seconds. Lookups are deduplicated by current public movie identity. Only films with future screenings, known UGC identities, no usable TMDB trailer, no admin trailer override and no available CGR/Cinéville candidate are fetched. PROXY_FILE is required for UGC requests, with the existing request timeout, bounded proxy retries, strict HTTPS endpoint and no direct fallback. Advertisements and other films' video entries are ignored. A challenge stops the cycle; failures preserve previous evidence and never block schedule publication.

Found/missing/failed lookups retry after seven days/one day/one hour respectively. Evidence unseen for 30 days expires. Provider observation changes become visible through schedule publication; independent lookups and expiry advance enrichment revision and appear through the existing five-second poll. Logs contain only bounded outcome counters and generic failure events. The resolved trailer object contains type, url, source and nullable language; legacy VF/VO fields remain unchanged. The public player loads YouTube or MP4 only after a click and destroys it on close. Unknown provider trailer language is not presented as VF or VO. Media is streamed directly from the validated host, without server download or rehosting.

Metacritic movie links

Movie pages add a Metacritic external link when TMDB provides a valid Wikidata association and that item has one unambiguous, non-deprecated P1712 movie path. Paths are cached with movie metadata; missing mappings remain linkless. Wikidata acquisition uses no extra credentials and never runs during public reads. Failed or unresolved Wikidata observations preserve a previously cached link without blocking successful TMDB metadata; a successful observation with no eligible path clears it.

Ordinary metadata cache reuse remains 30 days. To enrich already published films sooner, use the existing metadata-refresh control on /admin/tmdb-matches or an explicitly configured tmdb_metadata_refresh schedule. Refresh covers matched, retained upcoming and imported TMDB identities regardless of cache freshness. Migration 057 starts existing links as NULL and neither triggers a refresh nor creates or enables a schedule. Deployment and real refresh remain operator-controlled. After applying 057, rollback requires a compatible corrective binary, not dropping columns or editing migration history.

Upcoming French theatrical releases

The configurable scheduler target tmdb_upcoming_movies imports first French theatrical releases for (today in Europe/Paris, the same calendar date next year], clamping February 29 to February 28. Eligibility uses the earliest FR type-2/3 release across TMDB's full release history, not nationality or the worldwide primary date. Historical French releases exclude re-releases. Preview screenings remain available without removing an upcoming film.

Configure TMDB_API_READ_ACCESS_TOKEN and admin access to make this target available through the existing authenticated schedule CRUD. It supports daily, weekly, and cron schedules without PROXY_FILE. No schedule or initial import is created automatically; choose a cadence explicitly. Import shares the in-process TMDB gate with metadata jobs and holds a dedicated PostgreSQL session lease before claiming an occurrence. Contention, two scheduled retries, and shutdown use the existing scheduler lifecycle. This is not a provider synchronization and creates no sync_runs rows.

Administrators can also manually launch this same import with an empty-body POST /api/v1/admin/tmdb-upcoming-movies/sync and poll GET on the same path. Both require the admin session; POST also requires the configured web origin. All responses use Cache-Control: no-store. Acceptance returns 202 {"job":{"state":"running","started_at":"<UTC timestamp>","finished_at":null}}; GET returns 200 {"job":null} before any accepted run, otherwise the latest local manual or scheduled job. Terminal states are succeeded or failed, with finished_at populated and only failures adding error_code: "sync_failed". Status is bounded to one in-memory job, resets on API restart, and is not shared between replicas. Gate or lease contention returns 409 tmdb_upcoming_sync_in_progress; missing configuration or a closed manager returns 503 tmdb_upcoming_sync_unavailable on admission; unexpected admission failures return 502 tmdb_upcoming_sync_failed. GET returns 503 when the manager is not configured. Manual runs claim no occurrence, create or enable no schedule, and do not retry automatically. Accepted work survives request cancellation and uses the same publication, lease cleanup, and shutdown cancellation as scheduled work.

All discovery pages and every retained TMDB ID, including inactive and excluded records, are verified before a single atomic publication. Invalid pagination, malformed release evidence, provider failures, cancellation, and commit failures preserve the previous publication. Explicit empty evidence or movie HTTP 404 withdraws membership without deleting identities or URLs. Valid non-theatrical-only evidence remains stored without a theatrical date. Eligible movies reuse metadata for 30 days; inactive verification requests no details and creates no new public identity. The existing metadata-refresh target also includes retained TMDB-only movies and leaves review assessment untouched. Provider matching later attaches showtimes to the durable public identity. Manual metadata overrides and general release_date remain distinct from verified french_release_date.

GET /api/v1/movies/upcoming accepts only optional positive page (default 1). Each page contains four complete nonempty Wednesday-Tuesday release weeks, skipping gaps without splitting a week or capping its film count; the final page may contain fewer weeks. Public display starts at the next strictly future Wednesday in Europe/Paris, hiding the entire current Wednesday-Tuesday week even when some releases are still future, and retains the import window's inclusive calendar-anniversary upper bound. Eligibility and explicit admin exclusions apply before week counting; no dates beyond that horizon are added to complete its final week. Results sort by French date, normalized title, and numeric public ID. Response contains last successful generated_at, catalog_revision, timezone, window, flat items, page, global eligible film total, distinct eligible total_weeks, and total_pages (ceiling of weeks divided by four). Out-of-range positive pages return items: [] with actual totals. Removed month, genres, and page_size parameters, as well as unknown, duplicate, or malformed parameters, return 400 invalid_query; no facets or page_size are returned. An existing catalog without a successful import returns 503 upcoming_unavailable with Cache-Control: no-store; a successful empty import returns 200, items: [], and all three totals zero. Display eligibility advances at Paris Wednesday midnight without another import; stored membership, exact release dates, detail status and real showtimes remain unchanged.

Movie catalog responses add required nullable french_release_date; detail adds release_status (upcoming, showing, ended, or unavailable). A future verified French date takes precedence even beyond the list horizon; actual current screenings come next, then withdrawn evidence, then the legacy ended state. The existing required detail date parameter remains required. A successful TMDB-only publication enables list and detail reads before the first provider snapshot, including empty-session details with city=Paris. Default /movies remains empty without screenings; include_ended=true includes the durable inventory. Timeline routes and /readyz remain unavailable until a real schedule exists. Catalog and later provider publications become visible through the existing five-second poll, without restart.

Upcoming review API

Review signals never hide a movie. Only an explicit excluded decision removes it from the upcoming list and film/week/page totals; detail URLs, release status, generic inventory, sitemap inventory and real showtimes remain available. approved preserves normal eligibility, including after changed signals; unreviewed resets a decision without clearing evidence. Decisions survive withdrawal, reappearance, restart and public-owner reconciliation because they belong to the TMDB ID. Visibility changes use the existing enrichment revision and snapshot poll; generated_at remains the last successful sync time.

Migration 033 leaves existing movies eligible and pending assessment until the next successful import. Each assessment stores only normalized FR {type,date,note} rows: types 1..6, written calendar date, plain-text whitespace-normalized note, sorted and deduplicated. Type 1 is a premiere, not television. Malformed FR arrays/types/dates/notes, invalid UTF-8, NUL, more than 64 input rows or more than 1,024 note code points reject the entire publication; bounds are checked before deduplication/normalization. Non-FR rows are ignored. No provider bodies, HTML envelopes or credentials are retained.

Four ordered hints are computed only when a first French type-2/3 date exists: limited_only (type 2 without type 3), non_theatrical_before_or_same_day (type 4/5/6 on or before that date), broadcaster_theatrical_note (bounded broadcaster names in theatrical notes), and single_screening_note (bounded French single/special-screening phrases in theatrical notes). They are not classifications or exclusion proof. No genre/runtime/title/ID exceptions or absence-of-press predicate exists. Portable fixture api/internal/enrichment/testdata/upcoming_review_2026-09-13.json preserves 312 public parsed records from the 2026-09-13 evaluation: hint counts 21/3/1/1, union 23, including two earlier-or-equal television rows. Tests assert first-date agreement and legitimate counterexamples without live requests.

GET /api/v1/admin/tmdb-upcoming-movies is DB-only and available with configured admin access even without TMDB credentials or a successful import. Query keys are only filter, search, limit (default 50, 1..100), and offset (default 0, nonnegative int32). Filter defaults to needs_review (assessed, flagged, unreviewed); alternatives are pending_assessment (regardless of decision), approved, excluded, and all. Filters do not implicitly remove inactive/out-of-window records. Search is trimmed, nonempty when supplied, valid UTF-8 up to 1,024 code points, literal case-insensitive title substring or exact canonical decimal TMDB ID; %, _ and backslash are literal. Unknown, duplicate, blank, malformed or overflowing query values fail. Results sort by the non-theatrical timing hint first, then French date/null last, case-folded title and numeric TMDB ID. Count/page share a consistent DB view and captured Paris clock.

List shape is { "items": [], "total": 0, "limit": 50, "offset": 0 }. Each item has tmdb_id (number), public_movie_id (decimal string), canonical slug, title, nullable french_release_date, active, in_window, publicly_visible, assessment_status (pending/assessed), nullable UTC RFC3339 assessed_at, nonnull french_releases and reason_codes arrays, decision (unreviewed/approved/excluded), and positive integer revision. in_window uses the same upcoming display window as the public list; publicly_visible means active, in that window and not excluded. No review fields enter public catalog DTOs.

PATCH /api/v1/admin/tmdb-upcoming-movies/{tmdbID}/decision accepts exactly {"decision":"excluded","expected_revision":1} with Content-Type: application/json, a 4,096-byte body limit and no query parameters. TMDB ID is canonical positive decimal; ID and revision must not exceed 9,007,199,254,740,991. Missing/null/wrong-type/unknown/duplicate fields or trailing JSON fail. Success returns the updated item. Mutation compares revision before no-op detection: a current no-op changes neither item nor publication revision; a stale no-op conflicts. Evidence/date/membership or pending-to-assessed changes increment item revision; assessment timestamp-only refresh does not. Sync never writes decisions.

Both review routes require the existing admin cookie and return Cache-Control: no-store; PATCH also requires the configured Origin. Errors use the existing JSON envelope: 401 unauthorized, 403 origin_forbidden, 503 admin_unavailable, 400 invalid_upcoming_review_query / invalid_upcoming_review_id / invalid_upcoming_review_update, 404 upcoming_review_not_found, 409 upcoming_review_conflict, and safe 500 upcoming_review_list_failed / upcoming_review_update_failed. On conflict reload before choosing again; never automatically replay a decision. Review requests never call TMDB or trigger sync. Existing manual sync routes and lifecycle remain unchanged.

Admin cinema photos

/admin/cinemas manages one photo per current cinema, independent of account avatars. Local uploads and direct HTTPS links accept JPEG, PNG and static WebP up to 5 MiB. The API strips metadata, preserves oriented aspect ratio without enlargement or cropping, and stores lossy WebP with longest edge 1600 pixels and maximum size 1 MiB. Failed edits keep the previous photo; revision conflicts require reloading before another submission. Previews require the admin cookie and are never served publicly. Public cinema pages and schedule projections remain unchanged.

Set optional API-only CINEMA_IMAGE_DIR to an existing durable absolute directory outside this repository, owned by the API effective user with permissions 0700. Precreate it with install -d -m 0700 /your/durable/path/cinema-images as that user; replace the example with an approved local path. Do not use /tmp, repository storage or an ephemeral container path. Directory must not overlap enabled ACCOUNT_AVATAR_DIR. Empty configuration disables photo management; invalid configured storage fails startup before migrations. One API process owns the media root via an exclusive lock. Files are private 0600 generated keys, not original filenames.

URL import requires configured PROXY_FILE; without proxies, upload still works. Imports use only the first configured proxy, one attempt, numeric public-IP HTTPS CONNECT and separate verified proxy/target TLS identities. Only direct HTTPS URLs are accepted, with no redirects, private/reserved DNS answers or hotlinking. Operator proxies must permit numeric-IP CONNECT. Incoming credentials and source URLs are not forwarded or retained. Processing admits one upload/import at a time and reports temporary busy state instead of queueing image work.

Production Compose mounts API-only named cinema_images at fixed /var/lib/messeances/cinema-images; Docker image precreates it as uid/gid 10001, mode 0700. Existing PostgreSQL/account-avatar volumes are unchanged. Enablement applies additive migration 058 and requires explicit deployment approval. Earlier binaries reject the newer migration ledger: rollback means a compatible corrective build, not dropping the table or editing history. Confirm operator-managed /api routing preserves Origin/cookies and permits multipart bodies of 5 MiB plus 64 KiB. No Nginx changes are supplied here.

Back up and restore PostgreSQL and cinema media as paired quiesced state: stop admissions, drain API and collector, stop the sole API media writer, then capture both DB and media before restarting. Restore the matching pair with correct ownership and private permissions before starting the API. Never run multiple media writers, auto-chown an unknown path or delete a volume to recover from errors. Replacement/removal unlinks predecessors only after confirmed DB commit; reference-checked periodic GC removes old orphans. Missing media does not erase stored ownership.

Theater geocoding

Theater coordinates live in stable rows outside schedule generations. After a complete snapshot exists, authenticated administrators can launch geocoding from the theater-locations page. This in-process job uses IGN Géoplateforme with a fixed 20-second timeout and processes new theaters, every ambiguous row, and changed not-found rows. It preserves every matched or manual row and unchanged not-found row. Requests run sequentially at no more than five starts per second and use bounded retries. Launch returns immediately, status and terminal counters are durable, and only one admin-launched geocoding job can run across API replicas.

Results are stored as matched, ambiguous, or not_found. Failed requests leave prior rows untouched. Schedule loading accepts manual coordinates and only matched IGN coordinates whose stored address hash still matches current address inputs.

GET /api/v1/theaters returns latitude and longitude as numbers when accepted and explicit JSON null otherwise. Other theater-bearing API responses remain unchanged.

Then start PostgreSQL, the Go API, and Nuxt:

make dev

Open http://localhost:3000. The API runs at http://localhost:8080 by default.

When admin access is enabled, configure both ADMIN_PASSWORD and an independently generated ADMIN_SESSION_SECRET. Password rotation changes login credentials without invalidating active sessions; session-secret rotation invalidates all active sessions. Leaving both blank disables admin access locally.

INTERNAL_API_SHARED_SECRET is optional for local development. Leaving it blank disables internal service identity and keeps Nuxt on public API routes and quotas. A configured value must be exactly 64 lowercase hexadecimal characters, and server-side Nuxt must receive the same value as private NUXT_INTERNAL_API_SHARED_SECRET.

Kinepolis bounds decoded HTML at 64 MiB; its complete schedule exceeded the former 32 MiB ceiling in October 2026. Oversized or incomplete responses still reject publication.

Sync timing defaults are SYNC_REQUEST_TIMEOUT=20s, SYNC_KINEPOLIS_REQUEST_INTERVAL=2s, SYNC_CINEVILLE_REQUEST_INTERVAL=2s, SYNC_OPERATION_TIMEOUT=2m, and SYNC_ENRICHMENT_TIMEOUT=10m. Request timeout applies to UGC, Kinepolis, Pathé, CGR, Megarama, Cinéville, MK2, Cinewest, Grand Ecran, Noé Cinémas, Véo, Majestic, Cinéode, Ciné-Movida, and Ociné and must be between 5s and 60s. Kinepolis and Cinéville intervals must be at least 1s, and operation and enrichment timeouts must be positive. MK2 uses a fixed shared two-second request interval.

Cinéville supports manual, all-provider, and scheduled sync through the existing admin controls. Proxy-only acquisition discovers the current Next.js build and actual cinema routes, excludes the test headquarters, and imports real sessions from both program arrays across the entire advertised future range. A stale-build 404, unexpected page content type, or malformed cinema-page payload allows one re-bootstrap and full acquisition restart, discarding every earlier page. A 404 requires a changed build; transient content-type or payload failures can recover on the same build. Malformed bootstrap, persistent page failures, and normalization failures remain terminal. Cinema-scoped showing IDs and signed int64 film visas are preserved. Missing source metadata stays missing; all Cinéville source and canonical ends remain unknown (end_time == start_time), even after TMDB enrichment. Runtime from the resolved movie, including cached enrichment, can provide the separate display/planning estimate described above without changing stored data. Malformed or incomplete acquisition never replaces the previous snapshot.

The opt-in Cinéville contract test validates the complete advertised snapshot through operator-supplied proxies, without database publication or TMDB/IGN calls. Ordinary tests skip it:

# From api/
CINEVILLE_LIVE_PROXY_FILE=/path/to/proxies.txt go test ./internal/cineville -run '^TestProxyFullSyncContractIntegration$' -count=1 -v -timeout 6m

MK2 supports manual, all-provider, and scheduled sync through those same admin controls, after Cinéville in the provider order. Proxy-only acquisition joins the live cinema and film catalogs with each distinct Paris cinema-complex program, using two workers, shared pacing, and bounded responses. At the final catalog/program join, a nonempty catalog title and nonzero catalog runtime take precedence; missing values complement each other, while conflicting embedded duplicates and other metadata conflicts still reject acquisition. All advertised future sessions, including events and early-morning screenings, retain their Europe/Paris calendar date. Opaque cinema codes preserve leading zeros; film IDs and cinema-scoped session IDs remain stable. Silent sessions have no language badge, rooms remain empty, and every stored end stays unknown (end_time == start_time), even with positive source or enriched runtime; display/planning estimates remain separate. Source posters and exact MK2 checkout links use strict host/path/identity policies. Incomplete or conflicting acquisition leaves the prior snapshot untouched. No MK2 schedule is automatically created or enabled. Opt-in in-memory contract verification from api/: MK2_LIVE_PROXY_FILE=/home/mathis/Documents/Dev/movieflow/tmp/proxies.txt go test ./internal/mk2 -run '^TestProxyFullSyncContractIntegration$' -count=1 -v -timeout 3m; it uses a two-minute deadline and logs sanitized counts only.

Cinewest is the eighth provider, after MK2, available through manual, all-provider, and scheduled sync without creating or enabling schedules. One proxy-only, two-worker acquisition covers nine Cine Office cinemas and four ticketingcine sites in Mont-de-Marsan, Béthune, Brignoles and Le Pontet. Capitole Studios uses site EMS1378, program 7721200, and public cinema ID cinewest-ticketingcine-EMS1378. Public bootstrap supplies Cine Office credentials in memory only; each cinema's own token selects its four catalogs. The company entry is excluded. Ticketingcine JSON-RPC requests use the exact cinema Referer; obsolete Capitole Webedia endpoints are never requested. All 13 cinemas must succeed before atomic publication, including explicitly empty programs. Cine Office sessions referencing valid film identities absent from the media catalog are omitted without dropping their cinema; the admin outcome and operational logs expose skipped. Session identities, cinema/screen associations, times, options, and conflicting duplicates remain fatal even for orphan sessions. Sessions return automatically when their film metadata reappears. No direct networking fallback, token logging, or raw response persistence is allowed.

Cinewest source identities are namespaced by platform; cinema-scoped source session IDs are hashed into stable showing identities. Published Cine Office showend instants remain authoritative even when source or TMDB runtime differs. Ticketingcine, including new Capitole sessions, uses runtime plus persisted first-part duration, with the existing confirmed-TMDB fallback only for missing source runtime. Exact session feature infinity_vision takes priority over other projection flags. Retained Webedia sessions keep unknown canonical ends and separate response-only estimates. Service dates roll over at 03:00 in Europe/Paris; published future events are not clipped to a 14-day horizon. Silent Cine Office sessions retain VERSION_MUET with no language badge. Only verified official website roots, partner session fragments and bounded source poster shapes are exposed. Legacy Capitole checkout paths require the migrated cinema plus a valid historical Webedia showing identity; no context-free or new-session checkout fallback exists.

Migration 060 moves Capitole cinema references across retained generations, screening history, activity, geocoding, photos and account selections/follows. Film identities, session hashes, receipts, immutable activity anchors and stored historical URLs stay unchanged; no film mapping or old-slug redirect is invented. Affected account/photo revisions advance once, and public aggregate location revision advances once for a cinema/media re-key without changing schedule publication or enrichment revisions. Conflicting destination data or exhausted revisions abort the whole transaction. Quiesce writers before upgrade; photos move by database key only, with no filesystem changes. Use a compatible corrective build or separately approved consistent database/media restore after commit, never an old binary, down migration or ledger edit.

The opt-in Cinewest source contract test validates the complete 13-cinema snapshot in memory through operator-supplied proxies, without database publication or TMDB/IGN calls. Ordinary tests skip it:

# From api/
CINEWEST_LIVE_PROXY_FILE=/path/to/proxies.txt go test ./internal/cinewest -run '^TestProxyFullSyncContractIntegration$' -count=1

Grand Ecran (grandecran) follows Cinewest in the manual, all-provider, and scheduled order. Proxy-only acquisition discovers the current cinema list and asset build, follows each cinema's advertised future dates without a fixed horizon, and retains numeric films and opaque local events. Original-version sessions use VOSTFR; service days begin at 03:00 Europe/Paris. Missing runtime, poster, or room stays unknown; safe posters outside the existing acsta.net image policy are omitted, while malformed URLs fail acquisition. Stored ends always equal starts, with separate runtime/ads estimates for display and planning. Stable showing IDs exclude room metadata. Missing rooms receive best-effort booking-page enrichment with four workers, at most 4096 distinct URLs, and a separate 20-minute stage budget; unresolved rooms remain empty. Canonical booking links never change to resolved checkout URLs. Challenge/block responses and parent cancellation fail the sync; ordinary room failures do not discard sessions. Incomplete acquisition preserves the previous snapshot, and all-provider publication remains atomic. No schedule is seeded. SYNC_OPERATION_TIMEOUT bounds publication, while SYNC_ENRICHMENT_TIMEOUT bounds each provider’s TMDB enrichment pass; neither bounds the room stage.

Opt-in Grand Ecran contract verification runs without database publication: from api/, use GRANDECRAN_LIVE_PROXY_FILE=/home/mathis/Documents/Dev/movieflow/tmp/proxies.txt go test ./internal/grandecran -run '^TestProxyScheduleContractIntegration$' -count=1 -timeout 5m. It checks the full advertised schedule and at most eight room pages, logging sanitized counters only. The optional TestProxyFullSyncContractIntegration uses the same environment and -timeout 25m to exercise the complete bounded room stage. Neither test stores raw responses or proxy credentials.

Noé Cinémas (noecinemas) is the tenth provider, after Grand Ecran in manual, all-provider, and scheduled order. Trigger through authenticated admin/API (POST /api/v1/admin/syncs/noecinemas) or existing sync schedules; there is no Noé CLI or make sync extension. Acquisition requires PROXY_FILE, discovers all cinemas including empty programs, and follows every advertised future service date without a fixed horizon. Whole cinema days start at 03:00 Europe/Paris; numeric films and opaque local events retain their identities. VF, VOSTFR, VFSTF, and projection formats remain distinct. Missing runtime/poster/room stays unknown (including a zero source runtime); stored ends equal starts regardless of runtime, with existing estimates separate. Session identity hashes cinema ID and exact source session ID, never booking URL or recovered room. Only verified default desktop booking entries on the finite per-cinema allowlist are accepted; relay and resolved checkout URLs are never published. Missing L'Aigle rooms alone receive optional same-host, no-cookie HTML recovery, bounded to four workers, 4096 URLs, and 20 minutes. Ordinary room failures leave empty rooms; challenge or parent cancellation fails acquisition. Incomplete acquisition preserves the previous snapshot. No schedule is seeded or enabled, and no deployment configuration changes are required.

Opt-in Noé contract verification runs without database publication: from api/, use NOECINEMAS_LIVE_PROXY_FILE=/home/mathis/Documents/Dev/movieflow/tmp/proxies.txt go test ./internal/noecinemas -run '^TestProxyScheduleContractIntegration$' -count=1 -v -timeout 10m. It validates the complete advertised snapshot and samples at most eight missing-room pages within an eight-minute parent deadline, logging normalized counters only. Ordinary unit tests use synthetic fixtures and never contact providers.

Véo (veo) is the eleventh provider, after Noé and before metadata, available through authenticated manual (POST /api/v1/admin/syncs/veo), all-provider, and scheduled synchronization. No CLI, schedule seed, or enabling occurs. PROXY_FILE is required. One atomic acquisition covers the main and Sète Boxoffice circuits plus Castelia (EMS1361) on ticketingcine. Boxoffice uses each cinema's advertised future service dates (03:00 Europe/Paris boundaries), without a fixed horizon or invented fallback dates. Contradictory indexes/schedules reject publication; an internally consistent empty index retains its cinema without claiming complete days. Castelia's observed dates remain unknown coverage. Opaque Boxoffice events and ticketingcine films keep separate source identities; session identities hash the exact source ID with its cinema. Unverified source release dates are not copied.

Le Moderne in Saint-Affrique (P0793) retains its main-catalog programme and original six-key relay.mvtx.us/ticketing/dbz links, including the VF/VO code. Source date, clock, tags and version must match the advertised session. Migration 074 keeps SQL and frontend booking validation aligned. Its observed source → relay root → matching /noweb response means exact end enrichment is unavailable: the client stops before /noweb, keeps the API room, and leaves the canonical end unknown. Unexpected relay pages and redirects still fail atomically.

Guéret retains main-catalog identity, name and location veo-G06DB; only its acquisition switches to www.cinema-senechal.com / upstream P1394 for advertised program, schedule and movie metadata. Shared movie IDs must retain exactly equal metadata across origins or the whole snapshot fails. Its original relay.mvtx.us/ticketing/dbz booking links are bound to P1394 and source start/tags, never replaced publicly with resolved URLs. Private GET-only checkout follows only the three observed redirects: relay source 302 to same-host root, root 302 to achat.cinema-senechal.com/r/POSITIVE, then 307 to its canonical session with matching epoch. That terminal page requires one POST form whose action exactly equals the fetched URL and no login/password controls. No form is submitted. Source API rooms win; ends remain published clocks or unknown, never runtime padding. Other cinemas' checkout and Castelia policies remain unchanged.

Guéret alone also permits unavailable enrichment when its bound relay root (hop 1) returns HTTP 302 with exactly one raw root-relative /noweb? Location whose strict four query values (house_id, movie_id, perfd, perft) all equal the trusted root. Absolute/protocol-relative representations, aliases, changed values and all other near-misses remain redirect failures. The redirect body is closed unread and /noweb is never fetched or parsed; no new fetch authority or fourth production hop is added. The original advertised session, relay link and API room remain, with unknown end. This outcome cannot repair the existing second-response action retry and does not establish cancellation or any specific availability cause. Private backend gueret_unavailable_checkouts counts first-response outcomes observed before either success or an atomic abort, not published sessions.

All source-advertised Boxoffice sessions and dates remain present, including already-started sessions. The sync captures one now instant for its entire run: sessions starting at or before that instant skip checkout enrichment, preserve the API room (or unknown room), and retain canonical unknown ends (end_time == start_time), never runtime-derived. Future Boxoffice sessions, including those with a known room, are eligible for optional checkout enrichment. Acquisition permits one cookie-less HTTPS canonicalization hop on achat.veocinemas.fr, preserving the exact cinema slug and binding the destination epoch to the source session start; queries, fragments, extra hops and other aliases fail closed. The transport-level unavailable-enrichment exception is a first-hop HTTP 307 from the bound source to the exact absolute same-cinema https://achat.veocinemas.fr/SLUG/reserver/ entry, without query, fragment, credentials, port or path aliases: it is never followed or treated as a session page. Such sessions retain their original booking URL, advertised date and API room, with unknown end (end_time == start_time) and unknown room when absent from the API. This does not establish cancellation or permit a general unbookability/error fallback. Public booking URLs remain the exact source /SLUG/r/ID or /SLUG/reserver/r/ID entries, never the resolved destinations; additive migration 064 permits both strict source forms without rewriting existing rows or published migrations. The original 16-worker checkout pool, per-request timeout, 2 MiB decoded HTML limit and five-minute enrichment deadline bound this work. Véo transport/5xx failures are scoped to their logical request: retries use at most four distinct proxies, and a transient failure does not permanently remove a proxy from the shared catalog or checkout pools. Healthy proxies are preferred; failed proxies remain fallbacks and return to normal rotation after a successful request. Exhausted requests, challenges and invalid responses still abort acquisition. Only the scoped published s_fin end clock is accepted, with charset-aware parsing and safe overnight/DST handling; unpublished ends stay canonical unknown. Missing rooms alone may be backfilled from auditorium_showtime. Exhausted checkout transport retries, response-read failures and HTTP 5xx leave only that session’s end unknown while preserving its advertised room and original booking link. Enrichment budget expiry retains completed lookups and leaves all remaining sessions unchanged with unknown ends; later manual or scheduled syncs retry enrichment. Challenges, unsafe URLs/redirects, invalid content and parser failures still fail the whole sync and retain the previous snapshot. A transport failure or budget expiry cannot excuse a previously rejected page awaiting parser retry. Parent cancellation and required catalog/program/schedule acquisition failures remain fatal. Private backend counters and the admin operational journal report transient failures and budget-skipped lookups without exposing provider bodies or transport causes. Castelia ends use runtime plus first-part duration under the existing ticketingcine rule, unaffected by the Boxoffice cutoff. Existing display estimates remain separate from canonical ends.

Opt-in bounded Véo contract verification (no publication or full chain crawl): from api/, use VEO_LIVE_PROXY_FILE=/home/mathis/Documents/Dev/movieflow/tmp/proxies.txt go test -tags=nodynamic ./internal/veo -run '^TestProxyScheduleContractIntegration$' -count=1 -v -timeout 5m. It samples the two catalogs, canonical advertised indexes and full advertised horizons of one representative cinema per circuit, metadata and Castelia before at most four deliberately future checkout samples (preferring earliest known-runtime films); maximum 16 logical requests with bounded proxy retries, logging counters and actual source gaps only. Ordinary tests use synthetic fixtures without provider traffic. Migration 063 adds Véo to existing identity, history, scheduler, coverage, geocoding and photo constraints without changing other providers or seeding schedules.

Fronton (P3863) additionally publishes the exact room literal cine-fronton. It is retained verbatim only with the trusted Fronton canonical session and its exact POST booking form, and only as a fallback when the API room is absent; other arbitrary room strings remain rejected. Checkout parser diagnostics expose only closed rejection reasons and validated cinema/hashed showing identities in structured backend logs, never provider values, URLs, bodies or error causes. Public status fields remain unchanged.

For Toulouse (veo-G0699), Saint-Chamond (veo-P0833), Caussade (veo-W0065), Colomiers (veo-P6864) and Guéret (veo-G06DB), the first normal checkout response may also be unavailable enrichment when the trusted fetched canonical session is bound to the original source/start and its sole active POST form passes every raw/DOM check except action equality: the decoded action must be the exact cinema entry from the fixed allowlist (https://achat.veocinemas.fr/toulouse/reserver/, https://achat.veocinemas.fr/saint-chamond/reserver/, https://achat.veocinemas.fr/caussade/reserver/, https://achat.veocinemas.fr/colomiers/reserver/ or https://achat.cinema-senechal.com/reserver/, respectively). Queries, fragments (including bare delimiters), aliases, credentials, ports, escaping, duplicate forms/attributes, login controls and all other mismatches remain failures. Malformed/conflicting room/end clocks and unresolved end clocks still fail. All generic-page room/end facts are discarded, even plausible ones; the advertised session, original booking URL and API room remain, with unknown end. No action is followed and no additional request is made. Other cinemas gain no such exception. The private backend counter toulouse_unavailable_checkouts counts these first-response outcomes, including outcomes observed before another row aborts the atomic map; it is not a publication count.

Full opt-in acquisition contracts are also available without database publication or TMDB calls. From api/, set KINEPOLIS_LIVE_PROXY_FILE=/path/to/proxies.txt and run go test -tags=nodynamic ./internal/kinepolis -run '^TestProxyFullSyncContractIntegration$' -count=1 -v -timeout 6m; for Véo set VEO_LIVE_PROXY_FILE=/path/to/proxies.txt and use ./internal/veo with -timeout 36m. Véo fetches every future checkout, so this is a full provider crawl. Ordinary verification skips both contracts; output contains counters and closed rejection diagnostics only.

An explicitly bounded resilience mitigation permits one application-level re-fetch of the original checkout URL/start only when exact action equality is the sole failed form check, at most eight additional checkout calls across an entire run. All other form checks, room/clock/end rules and cancellation must pass eligibility; network/challenge and other failures are not retried by this mitigation. The second response must be a fully validated, exact canonical POST booking page, even with a numeric/null room marker; an unavailable-entry outcome cannot recover a mismatch. Only second-page facts are used, and a failed retry or exhausted budget still aborts publication atomically. Backend-only bounded attempt/recovery counts and value-free first-rejection diagnostics distinguish live exercise from success without exercise. This is a mitigation, not a proven historical root-cause fix.

PORT must be a decimal port from 1 through 65535. WEB_ORIGIN must be an exact http or https origin without credentials, path, query, or fragment.

TRUSTED_PROXY_CIDRS is an optional comma-separated list of exact CIDR ranges for reverse proxies that connect directly to the API. When the socket peer is trusted, public rate limits resolve X-Forwarded-For from right to left across trusted hops; malformed chains fall back to the socket peer. Forwarding headers from every other peer are ignored. Leave this setting empty for direct client connections. Operators must enumerate deployed ingress peer ranges and must not use broad public network ranges.

Nuxt separates private API access from browser access. NUXT_API_BASE is private to server-side rendering and defaults to http://localhost:8080; production Compose fixes it to the internal http://api:8080 service address. NUXT_PUBLIC_API_BASE defaults to empty for same-origin browser /api/v1/... requests; Nuxt development proxy forwards /api to the local API. Production Compose requires this public API origin to equal WEB_ORIGIN when accounts are enabled. Do not append /api: client paths already include /api/v1. NUXT_PUBLIC_SITE_URL is the canonical public site origin and defaults to http://localhost:3000; production Compose derives it from WEB_ORIGIN. Configure nonempty public values as exact http or https origins without trailing slash or path. Never expose internal api:8080 as a browser URL. Account requests always use same-origin paths; private account SSR forwards only its own session cookie and a generated request ID, never admin cookies or internal-service credentials.

Majestic (majestic) is the twelfth provider, after Véo in manual, all-provider and scheduled order. Trigger through authenticated POST /api/v1/admin/syncs/majestic or existing sync schedules. No CLI or schedule seed is added. PROXY_FILE is required; two workers acquire one ticketingcine get_prog program for each of the seven fixed EMS sites, including empty schedules. All seven must validate before atomic publication. Session dates use actual Europe/Paris calendar dates, include already-started sessions on those dates and retain the full source horizon without truncation. Ends use source runtime plus first-part minutes; absent/zero runtime remains unknown, with existing public runtime fallback applied separately. Global film and exact session IDs remain stable; local events are site-scoped. Source names, locations, rooms, languages, formats and accepted booking URLs remain source-faithful. Missing posters stay missing; no extra recovery requests occur. Migration 066 extends durable/history/provider constraints without rewriting older migrations, existing data or other-provider policy, and observed date unions never claim complete cinema-activity coverage.

Cinéode (cineode) is the thirteenth provider, after Majestic in manual, all-provider and scheduled order. Trigger through authenticated POST /api/v1/admin/syncs/cineode or existing sync schedules. No CLI or schedule seed is added. PROXY_FILE is required; two workers acquire each of the 37 approved cinemas through its server-rendered /seances/ page, with address-only /infos-cine/ and /contact/ fallbacks when needed. The stale /horaires/ view is never used. Every cinema must validate before atomic publication, including genuine empty programmes. Published grids may contain 1-14 consecutive Paris calendar dates; repeated film headers must agree. Session-date unions do not establish complete activity coverage.

Cinéode publishes no native session IDs, rooms or end times. Stable synthetic showing IDs bind cinema, film and Paris start timestamp; canonical ends remain unknown (end_time == start_time), while existing public estimates stay explicitly estimated and response-only. Missing runtime, poster and address fields remain missing. Public links are validated cinema programme websites labeled Site du cinéma Cinéode, not session checkout links. Migration 067 extends existing provider, identity, history and scheduler constraints without seeding schedules or changing other-provider policy. Opt-in read-only live verification from api/: CINEODE_LIVE_PROXY_FILE=/path/to/proxies.txt go test -tags=nodynamic,cineode_live ./internal/cineode -run '^TestProxyFullSyncContractIntegration$' -count=1 -v; ordinary checks never crawl Cinéode.

Ociné (ocine) follows Ciné-Movida in manual, all-provider and scheduler order. Use authenticated POST /api/v1/admin/syncs/ocine with configured PROXY_FILE, or explicitly create a disabled schedule through existing admin controls; no CLI or schedule seed is added. Two workers acquire all three fixed cinema grids, access-page addresses and every unique booking page under a provider-specific 25-minute crawl bound. Publication is atomic across the three cinemas. Source charset, film identities, per-session VF/VO, original Acsta posters, published calendar days, rooms and visible end clocks are retained. Missing end equals start, regardless of runtime; public runtime estimates remain response-only. Non-bookable rows have no checkout and may retain unknown version. This includes future sessions published as divs without a past class; their advertised clock and session tags are retained using the existing non-bookable identity. Observed calendar dates do not imply complete cinema activity coverage. Migration 070 adds Ociné-scoped persistence and identity guards without changing other-provider policy. Ordinary tests use synthetic pages and never crawl Ociné.

Production internal API identity

Production Compose requires one operator-owned INTERNAL_API_SHARED_SECRET. It passes that value to API as INTERNAL_API_SHARED_SECRET and to web as private NUXT_INTERNAL_API_SHARED_SECRET; it is never public Nuxt runtime config. Copy deploy/.env.production.example to ignored deploy/.env.production, replace the syntactically valid all-zero fake value with output from openssl rand -hex 32, and restrict file access. Generate this value independently from every password and session secret. Keep it only in ignored operator configuration. Never put it in tracked files, browser-visible variables, URLs, command arguments, tickets, chat, logs, screenshots, or shell transcripts.

Deploy API and web images together with the same ignored environment file. For rotation, replace the single INTERNAL_API_SHARED_SECRET value and recreate API and web together in one Compose rollout; never rotate one service independently. Missing or empty production interpolation stops Compose. A runtime mismatch fails closed: invalid service credentials receive no internal capacity, regular reads use public quota, and internal film-bundle requests fail authentication, which can surface as film SSR 502 or a cold sitemap 503.

After both services are healthy, request /sitemaps/films.xml, /sitemaps/cinemas.xml, and /sitemaps/cities.xml once through the public origin to seed successful process-local cache entries. Restarting or recreating web clears those entries, so seed them again after each rollout before relying on stale-on-refresh-failure behavior.

Production example sets TRUSTED_PROXY_CIDRS=127.0.0.1/32 only for operator-managed Nginx connecting directly to API through its IPv4 loopback binding. This is safe only when Nginx is the immediate socket peer and every API proxy location overwrites X-Forwarded-For from verified client data, or constructs a chain using only explicitly trusted upstream proxies. Otherwise leave the variable empty. Never trust 127.0.0.0/8, Docker or private network ranges, or ::1/128 without evidence for the exact immediate peer. Repository Compose binds services to loopback but does not install, inspect, test, reload, or manage external Nginx; those host changes remain a separate operator action. See Nginx admin login rate limiting for the matching forwarding contract.

The authenticated /admin/sync operational journal retains the original failure messages and wrapped causes. Provider request failures also include the request method/URL, actual attempt count and HTTP status when available. These details are saved only for failed runs, escaped as literal text, and bounded to 20 lines, 2048 bytes per line and 8192 bytes per journal. Existing saved runs cannot recover details that were previously discarded.

Backend operational logs use JSON on stderr. Prometheus metrics are available without application authentication at GET /metrics on the API listener. Restrict this endpoint with deployment network or reverse-proxy controls; production Compose keeps the API host binding on loopback.

Production analytics

Production Compose includes self-hosted Umami 3.4.1 and a dedicated PostgreSQL 15 service. Analytics storage is isolated in the umami_postgres_data volume, and its database has no published host port. Umami failure does not block the API or web services. The dashboard is bound to host loopback at 127.0.0.1:3001 by default.

Umami's default bot detection stays enabled. UMAMI_IGNORE_IP adds comma-separated IP/CIDR exclusions at collection and defaults to 195.225.131.0/24: production request logs on October 31, 2026 linked this network's ExaSearchBot page fetches to ordinary Chrome analytics requests. These exclusions also cover the crawler's browser rendering, which user-agent checks miss. Review the list when crawler networks change; it is not a complete list of automated traffic. CLIENT_IP_HEADER=x-real-ip uses the client address supplied by the operator-managed reverse proxy, which must overwrite X-Real-IP from the actual client address on every Umami request. Keep the Umami port on loopback. Apply changed exclusions by recreating only Umami with docker compose --env-file deploy/.env.production -f deploy/compose.production.yaml up -d --no-deps --pull never --wait umami from the repository root. Excluded requests are rejected before analytics writes; historical records and public crawling remain unchanged.

The umami-retention service runs deploy/umami-retention.sql immediately after the analytics database becomes healthy and then every 24 hours. Each run uses one PostgreSQL transaction and deletes audience sessions, session links, website events, event data, session data, revenue events, session replay chunks and saved replay references, and heatmap events whose retention timestamp is null or at least 25 months old. Account, team, website, report, segment, link, pixel, board, share, two-factor, and application-setting records are preserved. The SQL takes a transaction-scoped advisory lock and validates required analytics tables and column types plus the classification of every public base table before deleting anything. A schema mismatch aborts and rolls back the run, then the container restart policy retries; purge output never includes database credentials. Service health remains unavailable until the first successful purge and becomes unavailable if no successful purge has completed within 25 hours, so production --wait startup detects this retention failure.

Retention compatibility depends on a schema contract, independently of the Umami release label. The nine analytics tables and the UUID/timestamp columns actually used by cleanup are required. Extra columns, changes to unused columns, and either timestamp nullability are accepted. Reviewed configuration tables (including API keys and manually authored website annotations) and Prisma migration metadata are preserved when present; their absence does not block cleanup. Missing required tables, missing or incompatible required columns, and unclassified public base tables abort the transaction with diagnostics naming the mismatch. New tables need an explicit retention classification so analytics data cannot silently escape cleanup. Keep UMAMI_IMAGE pinned for repeatable deployments; before an upgrade, compare the upstream schema, review new or changed data, and validate the contract against a disposable database. Update the contract and deletion logic only when the schema requires it. Until retention completes its first successful run, pre-existing rows older than 25 months remain.

Bootstrap Umami in two stages:

  1. Copy deploy/.env.production.example to ignored deploy/.env.production. Generate independent values for UMAMI_POSTGRES_PASSWORD, UMAMI_APP_SECRET, and UMAMI_TWO_FACTOR_ENCRYPTION_KEY; openssl rand -hex 32 produces a URL-safe 64-character value suitable for each. Keep both NUXT_PUBLIC_UMAMI_* values empty, run make prod, and reach the loopback dashboard through operator-managed access such as an SSH tunnel to port 3001. Sign in with Umami's initial admin / umami credentials, immediately replace the password, and create the MesSeances website.
  2. Configure operator-managed public routing, DNS, and TLS so visitors can reach the tracker script without publishing the Compose port directly. Keep dashboard access restricted. This repository does not provision a reverse proxy, DNS, certificates, or public dashboard access. Set NUXT_PUBLIC_UMAMI_SCRIPT_URL to the browser-reachable absolute script URL and NUXT_PUBLIC_UMAMI_WEBSITE_ID to the website UUID, then rerun make prod. Both values are intentionally public and are not credentials; leaving either empty disables tracker injection.

Keep Umami secrets only in ignored deployment environment files. Back up umami_postgres_data under the same retention policy as other production data. Normal Compose recreation preserves named volumes. Never run docker compose down -v: -v deletes both application and analytics database volumes.

Persisted synchronization diagnostics in sync_runs have a 30-day maximum. Migration 024 removes already-expired terminal rows and adds a partial retention index. API startup performs the same cutoff purge and refuses to start if it fails; while the API remains running it repeats the purge every 24 hours. Only succeeded and failed rows with a non-null finished_at at or before the cutoff are deleted. Rows with state='running' are never selected, regardless of started_at. Existing expired rows remain until migration or API startup first succeeds.

Short links become eligible for deletion once strictly older than 90 days. Migration 027 removes eligible links and adds an index on created_at. API startup repeats the strict cutoff purge and refuses to start if it fails; while running it retries every 24 hours, so healthy cleanup can retain an eligible link until the next daily run and failures can extend retention further. Periodic failures are logged without link targets or database details. Short-link resolution responses use Cache-Control: no-store so newly resolved targets cannot outlive database retention in browser or intermediary caches.

Production log retention

Production Compose routes stdout and stderr from every container to Docker's journald logging driver. This includes API JSON logs on stderr. The repository also provides deploy/journald/90-messeances-retention.conf, which sets global MaxRetentionSec=30day and daily journal-file rotation with MaxFileSec=1day. This global policy is intended only for the confirmed dedicated VPS.

Host configuration cannot be installed or verified from this repository worktree. Production's 30-day technical-log maximum is therefore blocked until an operator runs these commands on the VPS from the deployed repository checkout:

sudo install -D -m 0644 deploy/journald/90-messeances-retention.conf /etc/systemd/journald.conf.d/90-messeances-retention.conf
sudo systemctl restart systemd-journald
sudo journalctl --rotate
sudo journalctl --vacuum-time=30d

journalctl --vacuum-time=30d permanently removes archived host journal files older than 30 days. Verify effective host settings and container drivers after make prod:

sudo systemd-analyze cat-config systemd/journald.conf | grep -E '^(MaxRetentionSec=30day|MaxFileSec=1day)$'
sudo journalctl --disk-usage
for service in postgres umami-postgres umami umami-retention api web; do
  container_id="$(docker compose --env-file deploy/.env.production -f deploy/compose.production.yaml ps -q "$service")"
  test -n "$container_id"
  test "$(docker inspect --format '{{.HostConfig.LogConfig.Type}}' "$container_id")" = journald
done
sudo journalctl CONTAINER_NAME=messeances-production-api-1 --since '10 minutes ago' --no-pager

The final command should show recent API JSON records after the API has handled traffic. MesSeances does not retain reverse-proxy or separate host request logs; if operators add either later, they must keep those outputs under the same maximum or update the privacy statement.

To stop PostgreSQL later without deleting local data:

docker compose --project-directory . --env-file deploy/.env -f deploy/compose.yaml down

Development and releases

Development integrates through feature pull requests into dev. Release pull requests use selectable release template from dev to protected main; valid merged releases create strict stable tags, stable GitHub Releases, versioned API/web GHCR images, and latest aliases. Production deployment remains manual.

See development and release operation for exact worktree commands, pull request title/body contract, GitHub and gh flows, branch-protection check names, publication behavior, and failure recovery.

Contributor checks

API builds and direct avatar/account test commands require -tags=nodynamic to use the pinned CGO-free WebP encoder rather than a host library. Makefile, Air, CI and Docker supply the tag. Stored avatars are WebP-only; old development PNG references are unsupported and rejected by migration 048 without automatic data deletion. See the accounts runbook.

After make install, the ordinary offline gate preserves Go/frontend unit tests, formatting, lint, typecheck, and builds. Cheap checks precede builds, and Nuxt generation is serial. It removes live-provider and account-browser opt-ins and does not run database integration, provider synchronization, or real TMDB/IGN calls:

make preflight
make check
# Optional local synthetic browser suite (install Chromium once).
make install-browser
make test-browser
PYTHONDONTWRITEBYTECODE=1 python3 scripts/validate.py run --check tooling-unit
docker compose --project-directory . --env-file deploy/.env -f deploy/compose.yaml config
docker compose --project-directory . --env-file deploy/.env.production.example -f deploy/compose.production.yaml config

Each structured invocation prints a unique tmp/validation/<run-id>/report.json path. Reports retain exact commands, source/index fingerprints, versions, observed exits, and test counts; complete stdout/stderr, Go JSONL, and JUnit remain private and ignored beside them. Failed, blocked, skipped, and zero-selected coverage are not passes. Make may return its own wrapper exit 2; inspect the report or invoke the CLI directly for the underlying check exit.

Focused Go feedback and canonical integration are separate:

python3 scripts/validate.py run --check go-unit --go-package ./internal/config
make test-race
# Automatically creates and removes its own PostgreSQL 18 Docker container.
make test-integration

Integration preserves the existing ten-package CI selection and applies migrations through existing isolated-schema fixtures. Make and CI use a uniquely owned postgres:18-alpine container with a Docker-assigned loopback port, tmpfs data, and generated private credentials; inherited TEST_DATABASE_URL is ignored. Readiness uses docker exec psql, with no host client or Compose database. Exact ownership is verified before disposal on success, failure, and SIGINT/SIGTERM. Cleanup failure cannot pass; SIGKILL/host failure cannot guarantee cleanup. Direct runner integration against an operator-provided disposable database remains separate and requires host psql; ordinary preflight remains read-only. Python tooling tests additionally require Bash/jq. Chrome is needed only for optional browser prerequisites. See validation commands, evidence, privacy, and prerequisites; use the Playwright CLI and browser test workflow for synthetic frontend scenarios and the accounts runbook for account integration.

For a deliberate proxy-only Megarama full-chain contract smoke, run from api/ with an operator-supplied proxy file. This opt-in test builds and validates a dataset in memory, logs counts only, and does not publish to a database or call TMDB/IGN. Ordinary tests skip it when the variable is unset:

MEGARAMA_LIVE_PROXY_FILE=/absolute/path/to/proxies.txt go test ./internal/megarama -run '^TestProxyFullSyncContractIntegration$' -count=1 -v

With the API and Nuxt already running from the current build, verify exact entity titles, breadcrumb and catalog ItemList structured data, current-only /films discovery, all-canonical sitemap inventory without lastmod, contextual entity links, historical redirects, and crawler error behavior:

npm --prefix web run verify:crawlability
EXPECT_UPSTREAM_FAILURE=1 npm --prefix web run verify:crawlability

Run the failure mode with Nuxt configured against an intentionally unavailable API origin. Neither command starts services or triggers provider/TMDB requests.

Core Web Vitals benchmark

Use one unchanged production-like API/Nuxt runtime with populated current data. Command measures one URL per invocation and does not start or stop services. Before measuring, open running instance's /sitemap.xml and select one successful current film, cinema, and city URL that renders expected poster content. Do not use crawlability fixture slugs or empty/error routes.

Run five independent three-run mobile measurements with same absolute Chrome executable, preserving generated report filenames after each invocation:

make web-vitals URL="http://localhost:3000/" RUNS=3 CHROME_BIN="/absolute/path/to/chrome"
make web-vitals URL="http://localhost:3000/films" RUNS=3 CHROME_BIN="/absolute/path/to/chrome"
make web-vitals URL="http://localhost:3000/film/<current-film-slug>" RUNS=3 CHROME_BIN="/absolute/path/to/chrome"
make web-vitals URL="http://localhost:3000/cinema/<current-cinema-slug>" RUNS=3 CHROME_BIN="/absolute/path/to/chrome"
make web-vitals URL="http://localhost:3000/ville/<current-city-slug>/cinemas" RUNS=3 CHROME_BIN="/absolute/path/to/chrome"

Local HTML and JSON reports are written under web/.lighthouseci/reports/ and ignored by Git. Record exact URL, report filenames, and median LCP, CLS, and TBT for each template separately. Do not average templates together, and report threshold failures unchanged.

Each invocation fails unless median LCP is at most 2500 ms, median CLS is strictly below 0.1, and median TBT is at most 200 ms. LCP and CLS are direct page-load lab metrics. TBT is only an INP proxy because page-load Lighthouse does not measure representative INP; report INP as unconfirmed unless independent field evidence establishes it.

Provider recovery and enrichment budgets

Pathé restarts its complete catalog/program acquisition once when a future programme references a show absent from the earlier catalog. Persistent unknown future shows still reject publication; past-only references do not require current catalog metadata. MK2 preserves the first native film URL when duplicate catalog entries supply different slug aliases for the same film ID; conflicting titles, runtime, poster, dates and genres still fail.

Cinéville cinema JSON requests can retry unexpected content types across up to four distinct proxies before the existing one-time bootstrap/restart. HTML challenges, 403/429 and persistent invalid data remain fatal. Proxy failures are request-scoped rather than permanently exhausting the pool during a long crawl. Véo retains one native booking when exact duplicate sessions disagree only on room, leaving that optional room unknown; all other session conflicts remain fatal. Its bounded acquisition-only contract uses VEO_ACQUISITION_PROXY_FILE with go test -tags=nodynamic ./internal/veo -run '^TestProxyAcquisitionContractIntegration$' -count=1 -v -timeout 4m from api/.

Ociné uses the same request-scoped proxy rotation. Exhausted checkout transport/read/5xx failures retain complete validated programme sessions, original booking links and grid rooms, with canonical end equal to start. Challenges, unsafe redirects, malformed checkout facts and parent cancellation still abort publication; catalogue and programme acquisition remain required. ocine_source_summary logs transient checkout counts. Its opt-in bounded three-programme/one-checkout contract uses OCINE_LIVE_PROXY_FILE and go test -tags=nodynamic ./internal/ocine -run '^TestProxyProgramAndCheckoutContractIntegration$' -count=1 -v -timeout 3m.

Provider-triggered TMDB enrichment now has its own positive SYNC_ENRICHMENT_TIMEOUT (default ten minutes), separate from the existing publication operation timeout. Temporary TMDB I/O and 500/502/503/504 failures retry at most three times with bounded backoff and normal pacing. Authentication, rate limits, not-found and invalid response payloads do not retry; cancellation remains observable without logging credentials or bodies. Any failed enrichment item marks the pass partially complete, even when processing reaches the end without a fatal error. Ordinary tests never contact these sources; live contracts require the approved proxy file and publish no database state.

About

French cinema showtime planner with visual timelines, time-window search, and direct booking links.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages