summaryrefslogtreecommitdiff
path: root/routers
diff options
context:
space:
mode:
Diffstat (limited to 'routers')
-rw-r--r--routers/auth.py58
-rw-r--r--routers/films.py59
-rw-r--r--routers/profile.py91
3 files changed, 202 insertions, 6 deletions
diff --git a/routers/auth.py b/routers/auth.py
new file mode 100644
index 0000000..d3fb963
--- /dev/null
+++ b/routers/auth.py
@@ -0,0 +1,58 @@
+import os
+from fastapi import APIRouter, Request
+from fastapi.responses import RedirectResponse
+from fastapi.templating import Jinja2Templates
+from argon2 import PasswordHasher
+from argon2.exceptions import VerifyMismatchError
+
+templates = Jinja2Templates(directory="templates")
+
+router = APIRouter()
+
+OWNER_PASSWORD_HASH = os.getenv("OWNER_PASSWORD_HASH", "")
+ph = PasswordHasher()
+
+
+@router.get("/login")
+async def login_form(request: Request):
+ if request.session.get("authenticated"):
+ return RedirectResponse("/", status_code=303)
+ return templates.TemplateResponse(request=request, name="login.html", context={"request": request})
+
+
+@router.post("/login")
+async def login(request: Request):
+ if request.session.get("authenticated"):
+ return RedirectResponse("/", status_code=303)
+
+ form = await request.form()
+ password = form.get("password", "")
+
+ if not OWNER_PASSWORD_HASH:
+ error = "Server not configured: OWNER_PASSWORD_HASH not set"
+ return templates.TemplateResponse(
+ request=request,
+ name="login.html",
+ context={"request": request, "error": error},
+ status_code=500
+ )
+
+ try:
+ ph.verify(OWNER_PASSWORD_HASH, password)
+ request.session["authenticated"] = True
+ return RedirectResponse("/", status_code=303)
+ except VerifyMismatchError:
+ pass
+
+ return templates.TemplateResponse(
+ request=request,
+ name="login.html",
+ context={"request": request, "error": "Invalid password"},
+ status_code=401
+ )
+
+
+@router.post("/logout")
+async def logout(request: Request):
+ request.session.clear()
+ return RedirectResponse("/login", status_code=303)
diff --git a/routers/films.py b/routers/films.py
index fc087b1..4bb7cc9 100644
--- a/routers/films.py
+++ b/routers/films.py
@@ -176,6 +176,17 @@ def _notice_context(
}
+def _get_shelf_query(db: Session, shelf: str):
+ """Get a query for films on a shelf, ordered appropriately."""
+ query = db.query(Film).filter(Film.shelf == shelf)
+ if shelf == "diary":
+ return query.order_by(Film.date_watched.desc(), Film.created_at.desc(), Film.id.desc())
+ elif shelf == "queue":
+ return query.order_by(Film.created_at.desc(), Film.id.desc())
+ else: # abandoned
+ return query.order_by(Film.updated_at.desc(), Film.created_at.desc(), Film.id.desc())
+
+
def _group_films_by_month(films: list[Film]) -> list[dict]:
def month_key(film: Film) -> str:
return film.date_watched.strftime("%B %Y") if film.date_watched else "Unknown"
@@ -192,15 +203,14 @@ def _render_shelf(
db: Session,
notices: dict,
):
- query = db.query(Film).filter(Film.shelf == shelf)
+ query = _get_shelf_query(db, shelf)
+ total_films = query.count()
+ films = query.limit(20).all()
+ has_more = total_films > 20
+
if shelf == "diary":
- films = query.order_by(Film.date_watched.desc(), Film.created_at.desc(), Film.id.desc()).all()
grouped_films = _group_films_by_month(films)
- elif shelf == "queue":
- films = query.order_by(Film.created_at.desc(), Film.id.desc()).all()
- grouped_films = None
else:
- films = query.order_by(Film.updated_at.desc(), Film.created_at.desc(), Film.id.desc()).all()
grouped_films = None
return templates.TemplateResponse(
@@ -212,6 +222,8 @@ def _render_shelf(
"grouped_films": grouped_films,
"active_shelf": shelf,
"shelf_meta": SHELF_META[shelf],
+ "total_films": total_films,
+ "has_more": has_more,
**notices,
},
)
@@ -361,6 +373,41 @@ async def _film_tmdb_context(film: Film) -> dict | None:
return context
+@router.get("/films/partial")
+def get_films_partial(
+ request: Request,
+ shelf: str = Query("diary"),
+ offset: int = Query(0),
+ limit: int = Query(20),
+ db: Session = Depends(get_db),
+):
+ """Returns partial HTML for pagination. Used by infinite scroll."""
+ if shelf not in ALLOWED_SHELVES:
+ raise HTTPException(status_code=400, detail="Invalid shelf.")
+
+ query = _get_shelf_query(db, shelf)
+ total = query.count()
+ films = query.offset(offset).limit(limit).all()
+ has_more = (offset + limit) < total
+
+ if shelf == "diary":
+ grouped_films = _group_films_by_month(films)
+ else:
+ grouped_films = None
+
+ from fastapi.responses import HTMLResponse
+ html = templates.get_template("_feed_partial.html").render(
+ request=request,
+ films=films,
+ grouped_films=grouped_films,
+ active_shelf=shelf,
+ )
+
+ response = HTMLResponse(html)
+ response.headers["X-Has-More"] = "true" if has_more else "false"
+ return response
+
+
@router.get("/films/{film_id}")
async def film_detail(film_id: int, request: Request, db: Session = Depends(get_db)):
film = _get_film_or_404(db, film_id)
diff --git a/routers/profile.py b/routers/profile.py
new file mode 100644
index 0000000..1986257
--- /dev/null
+++ b/routers/profile.py
@@ -0,0 +1,91 @@
+from fastapi import APIRouter, Depends, Request
+from fastapi.templating import Jinja2Templates
+from sqlalchemy.orm import Session
+
+from database import get_db
+from models import Film
+
+router = APIRouter(tags=["profile"])
+templates = Jinja2Templates(directory="templates")
+
+
+def _diary_films(db: Session) -> list[Film]:
+ return (
+ db.query(Film)
+ .filter(Film.shelf == "diary", Film.date_watched.is_not(None))
+ .order_by(Film.date_watched.desc())
+ .all()
+ )
+
+
+def _build_profile_payload(films: list[Film]) -> dict:
+ from collections import Counter
+ from services.countries import split_country_names
+ from services.film_people import split_credit_names
+
+ if not films:
+ return {
+ "total_watched": 0,
+ "average_stars": 0,
+ "most_watched_directors": [],
+ "star_distribution": [{"stars": stars, "count": 0} for stars in (0, 1, 2, 3)],
+ "films_per_country": [],
+ "recent_films": [],
+ }
+
+ countries = Counter()
+ directors = Counter()
+ star_counts = Counter({0: 0, 1: 0, 2: 0, 3: 0})
+
+ total_stars = 0
+ valid_ratings = 0
+
+ for film in films:
+ country_names = split_country_names(film.country)
+ countries.update(country_names)
+
+ directors.update(split_credit_names(film.director))
+
+ stars = film.stars if film.stars in {0, 1, 2, 3} else 0
+ star_counts[stars] += 1
+ if film.stars in {0, 1, 2, 3}:
+ total_stars += film.stars
+ valid_ratings += 1
+
+ total_watched = len(films)
+ average_stars = round(total_stars / valid_ratings, 1) if valid_ratings else 0
+
+ return {
+ "total_watched": total_watched,
+ "average_stars": average_stars,
+ "most_watched_directors": [
+ {"director": director, "count": count}
+ for director, count in sorted(directors.items(), key=lambda item: (-item[1], item[0]))[:5]
+ ],
+ "star_distribution": [{"stars": stars, "count": star_counts[stars]} for stars in (0, 1, 2, 3)],
+ "films_per_country": [
+ {"country": country, "count": count}
+ for country, count in sorted(countries.items(), key=lambda item: (-item[1], item[0]))[:10]
+ ],
+ "recent_films": [
+ {
+ "id": film.id,
+ "title": film.title,
+ "poster_url": film.poster_url,
+ "date_watched": film.date_watched.isoformat() if film.date_watched else None,
+ "stars": film.stars,
+ }
+ for film in films[:4]
+ ],
+ }
+
+
+@router.get("/tyler")
+async def public_profile(request: Request, db: Session = Depends(get_db)):
+ films = _diary_films(db)
+ payload = _build_profile_payload(films)
+ return templates.TemplateResponse(
+ request=request,
+ name="profile.html",
+ context={"request": request, **payload},
+ )