rename project to fhdhview
No files matched your search
@@ -0,0 +1,55 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from fhdhview.api.deps import check_auth, get_session
|
||||
from fhdhview.services.screenwriter.importer import import_sessions
|
||||
from fhdhview.services.screenwriter.parser import XmlParseError, parse_screenwriter_xml
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["Admin"],
|
||||
dependencies=[Depends(check_auth)]
|
||||
)
|
||||
|
||||
@router.get("", response_class=FileResponse)
|
||||
async def upload_form():
|
||||
return FileResponse(BASE_DIR / "html" / "upload.html")
|
||||
|
||||
|
||||
@router.post("/upload", response_class=JSONResponse)
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
if not file.filename or not file.filename.lower().endswith(".xml"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="File must be an XML file"
|
||||
)
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > 10 * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="File too large"
|
||||
)
|
||||
|
||||
try:
|
||||
aampos = parse_screenwriter_xml(contents)
|
||||
except XmlParseError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
await import_sessions(aampos, session=session)
|
||||
|
||||
return JSONResponse(content={"filename": file.filename, "size": len(contents)})
|
||||
@@ -0,0 +1,28 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import secrets
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from fhdhview.core.config import settings
|
||||
from fhdhview.core.database import async_session
|
||||
|
||||
security = HTTPBasic()
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession]:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def check_auth(credentials: HTTPBasicCredentials = Depends(security)):
|
||||
if not secrets.compare_digest(credentials.password.encode(), settings.api_password.encode()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/health",
|
||||
tags=["Health"]
|
||||
)
|
||||
|
||||
|
||||
class HealthCheck(BaseModel):
|
||||
"""Response model to validate and return when performing a health check."""
|
||||
|
||||
status: str = "OK"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
summary="Perform a Health Check",
|
||||
response_description="Return HTTP Status Code 200 (OK)",
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=HealthCheck,
|
||||
)
|
||||
def get_health() -> HealthCheck:
|
||||
"""
|
||||
## Perform a Health Check
|
||||
Endpoint to perform a healthcheck on. This endpoint can primarily be used Docker
|
||||
to ensure a robust container orchestration and management is in place. Other
|
||||
services which rely on proper functioning of the API service will not deploy if this
|
||||
endpoint returns any other HTTP status code except 200 (OK).
|
||||
Returns:
|
||||
HealthCheck: Returns a JSON response with the health status
|
||||
"""
|
||||
return HealthCheck(status="OK")
|
||||
@@ -0,0 +1,19 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/info",
|
||||
tags=["Info"]
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_class=FileResponse)
|
||||
async def get_info():
|
||||
return FileResponse(BASE_DIR / "html" / "info.html")
|
||||
@@ -0,0 +1,47 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from fhdhview.api.deps import get_session
|
||||
from fhdhview.core.config import settings
|
||||
from fhdhview.models.screening import Screening
|
||||
from fhdhview.schemas.screening import ScreeningRead
|
||||
|
||||
router = APIRouter(prefix="/screenings", tags=["Screenings"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[ScreeningRead],
|
||||
summary="Get upcoming screenings for the infodesk"
|
||||
)
|
||||
async def get_infodesk_screenings(
|
||||
session: AsyncSession = Depends(get_session)
|
||||
) -> list[ScreeningRead]:
|
||||
now = datetime.now(tz=ZoneInfo(settings.timezone))
|
||||
today = now.date()
|
||||
|
||||
query = (
|
||||
select(Screening)
|
||||
.where(Screening.start > now - timedelta(minutes=10))
|
||||
.where(Screening.start < today + timedelta(days=1))
|
||||
.order_by(col(Screening.start))
|
||||
.limit(8)
|
||||
)
|
||||
screenings = await session.exec(query)
|
||||
return [ScreeningRead.model_validate(s) for s in screenings.all()]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/prices",
|
||||
response_model=dict,
|
||||
summary="Get pricing information"
|
||||
)
|
||||
async def get_screening_prices() -> dict:
|
||||
return settings.prices
|
||||
@@ -0,0 +1,32 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
debug: bool = False
|
||||
api_password: str = "testertje"
|
||||
sql_file_name: str = "database.db"
|
||||
timezone: str = "Europe/Amsterdam"
|
||||
prices: dict = {
|
||||
"prijzen": [
|
||||
{"label": "Standaard", "price": "€ 12,50"},
|
||||
{"label": "Dagtarief (ma–vr tot 16u)", "price": "€ 11,00"},
|
||||
{"label": "Cineville", "price": "Gratis"},
|
||||
{"label": "Lange film (>180 min)", "price": "€ 15,00"},
|
||||
{"label": "70MM film", "price": "€ 15,00"},
|
||||
],
|
||||
"kortingsprijzen": [
|
||||
{"label": "Jongeren (t/m 26 jaar)", "price": "€ 10,00"},
|
||||
{"label": "Student Nights", "price": "€ 5,00"},
|
||||
{"label": "CJP", "price": "€ 10,00"},
|
||||
{"label": "Vrienden", "price": "€ 9,50"},
|
||||
{"label": "Kinderfilm (t/m 11 jaar)", "price": "€ 8,00"},
|
||||
{"label": "Ooievaarspas", "price": "€ 6,25"},
|
||||
{"label": "De Volharding", "price": "€ 9,00"},
|
||||
],
|
||||
}
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,17 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from fhdhview.core.config import settings
|
||||
|
||||
sqlite_url = f"sqlite+aiosqlite:///{settings.sql_file_name}"
|
||||
|
||||
engine = create_async_engine(sqlite_url, echo=settings.debug)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VANDAAG — Filmhuis Den Haag</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="screen">
|
||||
<section class="programme">
|
||||
<h1>Vandaag</h1>
|
||||
<div id="films"></div>
|
||||
</section>
|
||||
<aside class="sidebar">
|
||||
<div class="top">
|
||||
<div class="date" id="date"></div>
|
||||
<div class="logo"><img src="/static/Logo.svg" alt="Filmhuis Den Haag"></div>
|
||||
</div>
|
||||
<h2>Prijzen</h2>
|
||||
<div id="prijzen"></div>
|
||||
<h3>Kortingsprijzen</h3>
|
||||
<div id="kortingsprijzen"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<script src="/static/info.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Upload Schedule</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body style="font-family: sans-serif; max-width: 480px; margin: 40px auto;">
|
||||
<img src="/static/Logo.svg" alt="Logo" style="filter: invert(1);">
|
||||
<br>
|
||||
<br>
|
||||
<h1>Upload a schedule</h1>
|
||||
<form id="upload-form">
|
||||
<input type="file" name="file" required>
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
<p id="message"></p>
|
||||
<script>
|
||||
const form = document.getElementById("upload-form");
|
||||
const message = document.getElementById("message");
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
message.textContent = "Uploading...";
|
||||
try {
|
||||
const res = await fetch("/admin/upload", { method: "POST", body: new FormData(form) });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
message.style.color = "green";
|
||||
message.textContent = `Uploaded ${data.filename} (${data.size} bytes).`;
|
||||
} else {
|
||||
message.style.color = "red";
|
||||
message.textContent = data.detail;
|
||||
}
|
||||
} catch {
|
||||
message.style.color = "red";
|
||||
message.textContent = "Upload failed (network error).";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from fhdhview.api.admin import router as admin_router
|
||||
from fhdhview.api.health import router as health_router
|
||||
from fhdhview.api.info import router as info_router
|
||||
from fhdhview.api.screenings import router as screenings_router
|
||||
from fhdhview.core.database import init_db
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Runs on startup: ensure DB tables exist"""
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Filmhuis Den Haag Signage", lifespan=lifespan)
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
||||
|
||||
app.include_router(admin_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(info_router)
|
||||
app.include_router(screenings_router, prefix="/api")
|
||||
@@ -0,0 +1,31 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class KijkWijzer(str, Enum):
|
||||
ZES = "6"
|
||||
NEGEN = "9"
|
||||
TWAALF = "12"
|
||||
VEERTIEN = "14"
|
||||
ZESTIEN = "16"
|
||||
ACHTTIEN = "18"
|
||||
ALLE = "al"
|
||||
ANGST = "angst"
|
||||
DISCRIMINATIE = "discriminatie"
|
||||
DRUGS = "drugs-en-alcohol"
|
||||
GROF = "taal"
|
||||
GEDRAG = "gevaarlijk-gedrag"
|
||||
GEWELD = "geweld"
|
||||
SEKS = "seks"
|
||||
|
||||
|
||||
class Screen(str, Enum):
|
||||
ONE = "1"
|
||||
TWO = "2"
|
||||
THREE = "3"
|
||||
FOUR = "4"
|
||||
FIVE = "5"
|
||||
SIX = "6"
|
||||
TEN = "10"
|
||||
@@ -0,0 +1,38 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import field_serializer, field_validator
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from fhdhview.models.enums import KijkWijzer, Screen
|
||||
|
||||
|
||||
class Screening(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
session: int
|
||||
screen: Screen = Field(default=Screen.ONE, sa_column=sa.Column(sa.Enum(Screen)))
|
||||
complex_ident: str
|
||||
start: datetime
|
||||
end: datetime
|
||||
duration: int
|
||||
title: str
|
||||
rating: int
|
||||
seats_total: int
|
||||
seats_sold: int
|
||||
en_subs: bool = False
|
||||
intro: bool = False
|
||||
outro: bool = False
|
||||
kijkwijzer: list[KijkWijzer] = Field(default_factory=list, sa_type=sa.JSON)
|
||||
tags: list[str] = Field(default_factory=list, sa_type=sa.JSON)
|
||||
|
||||
@field_validator("kijkwijzer", mode="before")
|
||||
@classmethod
|
||||
def _coerce(cls, v):
|
||||
return [r.value if isinstance(r, KijkWijzer) else r for r in v]
|
||||
|
||||
@field_serializer("kijkwijzer")
|
||||
def _serialize(self, v: list[KijkWijzer]) -> list[str]:
|
||||
return [r.value if isinstance(r, KijkWijzer) else r for r in v]
|
||||
@@ -0,0 +1,63 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from fhdhview.models.enums import KijkWijzer, Screen
|
||||
|
||||
screens = {
|
||||
'DenHaag_zaal_1': Screen.ONE,
|
||||
'DenHaag_zaal_2': Screen.TWO,
|
||||
'DenHaag_zaal_3': Screen.THREE,
|
||||
'DenHaag_zaal_4': Screen.FOUR,
|
||||
'DenHaag_zaal_5': Screen.FIVE,
|
||||
'DenHaag_zaal_6': Screen.SIX,
|
||||
'Filmhuis Den Haag 10 (buitenbios)': Screen.TEN
|
||||
}
|
||||
|
||||
|
||||
kijkwijzer = {
|
||||
'vanaf 6 jaar': KijkWijzer.ZES,
|
||||
'vanaf 9 jaar': KijkWijzer.NEGEN,
|
||||
'vanaf 12 jaar': KijkWijzer.TWAALF,
|
||||
'vanaf 14 jaar': KijkWijzer.VEERTIEN,
|
||||
'vanaf 16 jaar': KijkWijzer.ZESTIEN,
|
||||
'vanaf 18 jaar': KijkWijzer.ACHTTIEN,
|
||||
'Alle leeftijden': KijkWijzer.ALLE,
|
||||
'Angst': KijkWijzer.ANGST,
|
||||
'Discriminatie': KijkWijzer.DISCRIMINATIE,
|
||||
'Drugs- en/of alcoholmisbruik': KijkWijzer.DRUGS,
|
||||
'Grof taalgebruik': KijkWijzer.GROF,
|
||||
'Gevaarlijk gedrag': KijkWijzer.GEDRAG,
|
||||
'Geweld': KijkWijzer.GEWELD,
|
||||
'Seks': KijkWijzer.SEKS
|
||||
}
|
||||
|
||||
|
||||
extra_tags = [
|
||||
'2D',
|
||||
'Action',
|
||||
'Adventure',
|
||||
'Animation',
|
||||
'Biographie',
|
||||
'Comedy',
|
||||
'Crime',
|
||||
'Documentary',
|
||||
'Drama',
|
||||
'EN subs',
|
||||
'Family film',
|
||||
'Family',
|
||||
'Fantasy',
|
||||
'Festival',
|
||||
'Horror',
|
||||
'Kids film',
|
||||
'Kids',
|
||||
'LGBTQ+',
|
||||
'Met muziek',
|
||||
'Romantic',
|
||||
'Science fiction',
|
||||
'Sciencefiction',
|
||||
'Thriller',
|
||||
'Voorfilm',
|
||||
'War',
|
||||
'with after talk',
|
||||
'with introduction'
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from fhdhview.models.enums import KijkWijzer, Screen
|
||||
|
||||
|
||||
class ScreeningRead(SQLModel):
|
||||
screen: Screen
|
||||
start: datetime
|
||||
end: datetime
|
||||
duration: int
|
||||
title: str
|
||||
rating: int
|
||||
seats_total: int
|
||||
seats_sold: int
|
||||
en_subs: bool = False
|
||||
intro: bool = False
|
||||
outro: bool = False
|
||||
kijkwijzer: list[KijkWijzer] = []
|
||||
tags: list[str] = []
|
||||
@@ -0,0 +1,99 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from sqlmodel import delete
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from fhdhview.models.enums import Screen
|
||||
from fhdhview.models.screening import KijkWijzer, Screening
|
||||
from fhdhview.models.tags import kijkwijzer, screens
|
||||
from fhdhview.services.screenwriter.schema import AAMPos
|
||||
|
||||
# Delimiters used to attach notes to titles
|
||||
DASH_DELIMITERS = (" - ", " – ", " — ", "- ")
|
||||
|
||||
# Explicit list of known screening notes (exact matches only)
|
||||
KNOWN_SCREENING_NOTES = frozenset({
|
||||
# Subtitles
|
||||
"en subs",
|
||||
"en sub",
|
||||
"nl subs",
|
||||
"nl sub",
|
||||
|
||||
# Events & talks
|
||||
"met inleiding",
|
||||
"met nagesprek",
|
||||
"met inleiding en nagesprek",
|
||||
"met voorfilms en nagesprek",
|
||||
|
||||
# Combined notes
|
||||
"en subs met inleiding",
|
||||
"en subs en nagesprek",
|
||||
})
|
||||
|
||||
|
||||
def _clean_title(title: str) -> str:
|
||||
"""Peel off trailing presentation notes using an explicit blacklist."""
|
||||
for dash in DASH_DELIMITERS:
|
||||
while dash in title:
|
||||
prefix, _, suffix = title.rpartition(dash)
|
||||
# Exact string check (case-insensitive and trimmed)
|
||||
if suffix.strip().lower() in KNOWN_SCREENING_NOTES:
|
||||
title = prefix.strip()
|
||||
else:
|
||||
break
|
||||
|
||||
return title.strip()
|
||||
|
||||
|
||||
async def import_sessions(data: AAMPos, session: AsyncSession):
|
||||
# purge old rows
|
||||
await session.exec(delete(Screening))
|
||||
|
||||
# add new screenings from data
|
||||
for screening in data.sessions.root:
|
||||
|
||||
kijk: list[KijkWijzer] = []
|
||||
for k in kijkwijzer:
|
||||
if k in screening.attributes:
|
||||
kijk.append(kijkwijzer[k])
|
||||
screening.attributes.remove(k)
|
||||
|
||||
if "EN subs" in screening.attributes:
|
||||
en_subs = True
|
||||
screening.attributes.remove("EN subs")
|
||||
else:
|
||||
en_subs = False
|
||||
|
||||
if "with introduction" in screening.attributes:
|
||||
intro = True
|
||||
screening.attributes.remove("with introduction")
|
||||
else:
|
||||
intro = False
|
||||
|
||||
if "with after talk" in screening.attributes:
|
||||
outro = True
|
||||
screening.attributes.remove("with after talk")
|
||||
else:
|
||||
outro = False
|
||||
|
||||
new_screening = Screening(
|
||||
session=screening.session_id,
|
||||
screen=screens.get(screening.screen_identifier, Screen.ONE),
|
||||
complex_ident=screening.complex_identifier,
|
||||
start=screening.start,
|
||||
end=screening.end,
|
||||
duration=screening.feature_duration,
|
||||
title=_clean_title(screening.title),
|
||||
rating=screening.rating,
|
||||
seats_total=screening.seats_available,
|
||||
seats_sold=screening.seats_sold,
|
||||
en_subs=en_subs,
|
||||
intro=intro,
|
||||
outro=outro,
|
||||
kijkwijzer=kijk,
|
||||
tags=screening.attributes
|
||||
)
|
||||
|
||||
session.add(new_screening)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,20 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from fhdhview.services.screenwriter.schema import AAMPos
|
||||
|
||||
|
||||
class XmlParseError(Exception):
|
||||
"""Raised when an XML is malformed or violates the ScreenWriter schema."""
|
||||
|
||||
|
||||
def parse_screenwriter_xml(content: bytes | str) -> AAMPos:
|
||||
"""Parser raw ScreenWriter XML into validated Pydantic models."""
|
||||
try:
|
||||
return AAMPos.from_xml(content)
|
||||
except (ValidationError, ET.ParseError) as e:
|
||||
raise XmlParseError(f"Failed to parse ScreenWriter XML: {e}") from e
|
||||
@@ -0,0 +1,37 @@
|
||||
# SPDX-FileCopyrightText: 2026 Filmhuis Den Haag
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from pydantic import BeforeValidator
|
||||
from pydantic_xml import BaseXmlModel, RootXmlModel, element, wrapped
|
||||
|
||||
from fhdhview.core.config import settings
|
||||
|
||||
DT = Annotated[datetime, BeforeValidator(lambda v: datetime.strptime(v, "%Y%m%d%H%M").replace(
|
||||
tzinfo=ZoneInfo(settings.timezone)
|
||||
))]
|
||||
|
||||
|
||||
class Session(BaseXmlModel, tag='session'):
|
||||
session_id: int = element(tag='id')
|
||||
screen_identifier: str = element()
|
||||
complex_identifier: str = element()
|
||||
start: DT = element()
|
||||
end: DT = element()
|
||||
feature_duration: int = element()
|
||||
title: str = element()
|
||||
rating: int = element()
|
||||
seats_available: int = element()
|
||||
seats_sold: int = element()
|
||||
attributes: list[str] = wrapped('session_attributes', element(tag='session_attribute'))
|
||||
|
||||
|
||||
class Sessions(RootXmlModel, tag='sessions'):
|
||||
root: list[Session]
|
||||
|
||||
|
||||
class AAMPos(BaseXmlModel, tag='AAMPos'):
|
||||
sessions: Sessions = element()
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg width="180" height="68" viewBox="0 0 180 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M158.687 19.1935C158.687 22.8129 160.895 24.7871 164.098 24.7871C166.638 24.7871 168.625 23.1419 168.625 20.729C168.625 17.9871 166.086 17 164.208 16.3419C159.128 14.5871 155.043 13.0516 155.043 7.56774C155.043 2.96129 159.018 0 163.656 0C168.625 0 172.49 3.50968 172.49 8.66452H167.963C167.963 5.92258 166.417 4.05806 163.767 4.05806C161.558 4.05806 159.57 5.26452 159.57 7.56774C159.57 9.76129 161.558 10.8581 163.656 11.6258C167.963 13.1613 173.153 15.0258 173.153 20.2903C173.153 25.9935 168.846 28.9548 163.656 28.9548C158.576 28.9548 154.159 25.4452 154.159 19.3032H158.687V19.1935Z" fill="white"/>
|
||||
<path d="M38.0981 0.658203H33.3496V28.1872H38.0981V0.658203Z" fill="white"/>
|
||||
<path d="M56.9819 24.1292V28.1872H43.8408V0.658203H48.3684V24.1292H56.9819Z" fill="white"/>
|
||||
<path d="M87.2393 28.1872H82.7117V11.1872L75.7547 28.1872H72.3313L65.3743 11.1872V28.1872H60.8467V0.658203H65.2639L74.0982 21.1679L82.9326 0.658203H87.2393V28.1872Z" fill="white"/>
|
||||
<path d="M109.104 16.5614H97.6194V28.1872H93.0918V0.658203H97.6194V12.5034H109.104V0.658203H113.632V28.1872H109.104V16.5614Z" fill="white"/>
|
||||
<path d="M119.264 0.658203H123.791V18.9743C123.791 22.155 125.889 24.7872 129.202 24.7872C132.515 24.7872 134.724 22.155 134.724 18.9743V0.658203H139.251V18.9743C139.251 24.3485 135.276 28.8453 129.202 28.8453C123.239 28.8453 119.264 24.3485 119.264 18.9743V0.658203Z" fill="white"/>
|
||||
<path d="M149.522 0.658203H144.773V28.1872H149.522V0.658203Z" fill="white"/>
|
||||
<path d="M8.94479 39.8135C18.1104 39.8135 23.3006 46.6135 23.3006 53.6328C23.3006 60.5425 18.1104 67.4522 8.94479 67.4522H0V39.8135H8.94479ZM4.52761 43.8715V63.2844H8.94479C15.1288 63.2844 18.6626 58.4586 18.6626 53.5232C18.6626 48.5877 15.1288 43.7619 8.94479 43.7619H4.52761V43.8715Z" fill="white"/>
|
||||
<path d="M32.1351 43.8715V51.6586H43.5093V55.7167H32.1351V63.2844H43.5093V67.3425H27.4971V39.8135H43.5093V43.8715H32.1351Z" fill="white"/>
|
||||
<path d="M64.6011 59.4457V39.8135H69.1287V67.3425H64.1594L53.2269 47.7102V67.3425H48.5889V39.8135H53.6686L64.6011 59.4457Z" fill="white"/>
|
||||
<path d="M95.9634 55.7167H84.5892V67.3425H79.9512V39.8135H84.5892V51.6586H95.9634V39.8135H100.601V67.3425H95.9634V55.7167Z" fill="white"/>
|
||||
<path d="M120.921 67.3425L119.706 63.8328H109.325L108.111 67.3425H103.362L112.97 39.8135H116.172L125.779 67.3425H120.921ZM114.516 47.3812L110.651 59.8844H118.27L114.516 47.3812Z" fill="white"/>
|
||||
<path d="M144.332 67.3425L143.117 63.8328H132.737L131.522 67.3425H126.773L136.381 39.8135H139.583L149.191 67.3425H144.332ZM137.927 47.3812L134.062 59.8844H141.681L137.927 47.3812Z" fill="white"/>
|
||||
<path d="M166.859 53.5223H180V57.5804H176.908C175.693 63.7223 171.166 67.9998 163.436 67.9998C154.381 67.9998 149.19 60.9804 149.19 53.5223C149.19 46.0643 154.712 39.0449 163.436 39.0449C169.951 39.0449 174.81 43.103 175.252 47.9288H170.614C170.172 45.1869 167.301 43.103 163.436 43.103C157.473 43.103 153.828 48.1481 153.828 53.5223C153.828 58.8965 157.141 63.9417 163.988 63.9417C168.736 63.9417 171.497 60.9804 172.049 57.5804H166.749V53.5223H166.859Z" fill="white"/>
|
||||
<path d="M4.52761 0.658203H0V28.1872H4.52761V0.658203Z" fill="white"/>
|
||||
<path d="M23.0801 0.658203H9.16602V5.15498H23.0801V0.658203Z" fill="white"/>
|
||||
<path d="M27.6077 9.76172H9.16602V14.2585H27.6077V9.76172Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,74 @@
|
||||
const POLL_MS = 5 * 60 * 1000;
|
||||
|
||||
// "2026-10-13T17:30:00" -> "17:30"
|
||||
function hhmm(iso) {
|
||||
return iso.slice(11, 16);
|
||||
}
|
||||
|
||||
// map kijkwijzer codes to /static/<name>.png
|
||||
function kijkwijzerIcons(codes) {
|
||||
return (codes || []).map(c =>
|
||||
`<img class="kijkwijzer" src="/static/kw/${c}.png" alt="${c}">`
|
||||
).join("");
|
||||
}
|
||||
|
||||
function renderFilms(films) {
|
||||
const rows = (films || []).map(f => {
|
||||
const isBoutique = f.screen === "6";
|
||||
const ratio = f.seats_sold / f.seats_total;
|
||||
const start = new Date(f.start); // "2026-10-13T18:00:00" parses as local time
|
||||
const minsUntil = (start - new Date()) / 60000;
|
||||
const badge = ratio >= 1
|
||||
? '<div class="badge-last uitverkocht">Uitverkocht</div>'
|
||||
: "<div></div>";
|
||||
|
||||
return `
|
||||
<div class="film">
|
||||
<div class="times"><div class="start">${hhmm(f.start)}</div><div class="end">${hhmm(f.end)}</div></div>
|
||||
<div class="icons">${kijkwijzerIcons(f.kijkwijzer)}</div>
|
||||
<div class="info">
|
||||
<div class="title">${f.title}</div>
|
||||
<div class="meta">
|
||||
${isBoutique
|
||||
? `<span class="pill">Zaal 6 - Boutique Cinema</span>`
|
||||
: `<span class="zaal">Zaal ${f.screen}</span>`}
|
||||
${f.en_subs ? "<span class='subs'>EN subs</span>" : ""}
|
||||
</div>
|
||||
</div>
|
||||
${badge}
|
||||
</div>`;
|
||||
}).join("");
|
||||
document.getElementById("films").innerHTML =
|
||||
rows + `<div class="film" "film-spacer" aria-hidden="true"></div>`;
|
||||
}
|
||||
function renderPrices(prices) {
|
||||
const row = p => `<div class="price-row"><span>${p.label}</span><span class="price">${p.price}</span></div>`;
|
||||
document.getElementById("prijzen").innerHTML = prices.prijzen.map(row).join("");
|
||||
document.getElementById("kortingsprijzen").innerHTML = prices.kortingsprijzen.map(row).join("");
|
||||
}
|
||||
|
||||
function renderClock() {
|
||||
const d = new Date();
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
document.getElementById("date").innerHTML = `${hh}<span class="colon">:</span>${mm}`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [filmsRes, pricesRes] = await Promise.all([
|
||||
fetch("/api/screenings"),
|
||||
fetch("/api/screenings/prices"),
|
||||
]);
|
||||
renderFilms(await filmsRes.json());
|
||||
renderPrices(await pricesRes.json());
|
||||
} catch (err) {
|
||||
console.error("refresh failed, keeping current display:", err);
|
||||
}
|
||||
}
|
||||
|
||||
renderClock();
|
||||
setInterval(renderClock, 1_000); // every second: updates minutes + blinks the colon
|
||||
refresh(); // initial paint
|
||||
setInterval(refresh, POLL_MS); // poll every 5 minutes
|
||||
setInterval(renderClock, 30_000); // keep the clock accurate
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,134 @@
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/figtree-latin.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: italic;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/figtree-italic-latin.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #dfe2e9;
|
||||
--bg-side: #a8adb1;
|
||||
--ink: #111318;
|
||||
--muted: #8a8f99;
|
||||
--rule: #9aa0aa;
|
||||
--badge: #101318;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: "Figtree", "Futura", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
}
|
||||
.screen { display: grid; grid-template-columns: 1fr 30%; height: 100vh; }
|
||||
|
||||
/* ---------- left: film list ---------- */
|
||||
.programme { padding: 28px 40px 40px 48px; display: flex; flex-direction: column; }
|
||||
.programme h1 {
|
||||
font-size: 152px; font-weight: 600; letter-spacing: 6px;
|
||||
text-transform: uppercase; line-height: 1;
|
||||
margin-bottom: -50px;
|
||||
position: relative; z-index: 0;
|
||||
}
|
||||
.film {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
display: grid;
|
||||
grid-template-columns: 90px 160px 1fr 170px;
|
||||
gap: 14px; align-items: start;
|
||||
padding: 16px 0 18px;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.film-spacer { min-height: 0; padding: 0; }
|
||||
.times { line-height: 1.25; }
|
||||
.times .start { font-size: 25px; font-weight: 600; }
|
||||
.times .end { font-size: 25px; color: var(--muted); }
|
||||
.icons {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding-top: 3px;
|
||||
}
|
||||
.icons .kijkwijzer {
|
||||
width: 26px; height: 26px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.icons .dot {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: var(--badge); color: #fff;
|
||||
font-size: 18px; font-weight: 600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.info { padding-top: 3px; }
|
||||
.info .title { font-size: 25px; font-weight: 500; }
|
||||
.info .meta {
|
||||
margin-top: 14px; font-size: 15px; font-weight: 400;
|
||||
letter-spacing: 1.5px; text-transform: uppercase;
|
||||
display: flex; gap: 18px; align-items: center;
|
||||
}
|
||||
.pill {
|
||||
background: var(--bg-side); border-radius: 12px;
|
||||
padding: 4px 14px;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
}
|
||||
.subs {
|
||||
margin-left: 18px;
|
||||
}
|
||||
.badge-last {
|
||||
justify-self: end; align-self: start; margin-top: -16px;
|
||||
background: var(--badge); color: #fff;
|
||||
font-size: 15px; font-weight: 600; letter-spacing: 1.5px;
|
||||
padding: 6px 12px; text-transform: uppercase; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- right: sidebar ---------- */
|
||||
.sidebar {
|
||||
background: var(--bg-side);
|
||||
padding: 50px 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar .top {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sidebar .date {
|
||||
font-size: 66px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.sidebar .date .colon {
|
||||
animation: blink 1s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.sidebar .logo {
|
||||
filter: invert(1);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 11px;
|
||||
}
|
||||
.sidebar .logo img {
|
||||
height: 46px;
|
||||
width: auto;
|
||||
}
|
||||
.sidebar h2 { margin-top: 56px; font-size: 28px; font-weight: 600; margin-bottom: 14px; }
|
||||
.price-row { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 9px; font-size: 20px; }
|
||||
.price-row .price { white-space: nowrap; }
|
||||
.sidebar h3 { margin-top: 34px; font-size: 20px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 14px; }
|
||||