update
This commit is contained in:
@@ -9,6 +9,11 @@ load_dotenv(ROOT / ".env")
|
||||
TMDB_TOKEN = os.environ["TMDB_TOKEN"]
|
||||
MINIMAX_KEY = os.environ["MINIMAX_KEY"]
|
||||
|
||||
# Wer den Hostmodus einschalten darf. Einmal /?host=<schlüssel> aufrufen,
|
||||
# danach steckt es in der Sitzung. Ohne Eintrag kommt niemand an die
|
||||
# Hostknöpfe — auch nicht über die Tastenkombination.
|
||||
HOST_SCHLUESSEL = os.getenv("HOST_SCHLUESSEL", "")
|
||||
|
||||
TMDB_BASE = "https://api.themoviedb.org/3"
|
||||
IMAGE_BASE = "https://image.tmdb.org/t/p"
|
||||
|
||||
|
||||
@@ -116,10 +116,25 @@ def existing_netflix_ids(conn):
|
||||
return {row["id"]: row["netflix_id"] for row in rows}
|
||||
|
||||
|
||||
# Sammlung, Rad und Gesehen zeigen auch Filme, die nie auf Netflix liefen.
|
||||
# Deren Daten stehen trotzdem im Katalog — sonst blieben die Karten leer.
|
||||
BENUTZT = ("wishlist", "suggestions", "watched")
|
||||
|
||||
|
||||
def benutzte_ids(conn):
|
||||
"""Alle movie_ids, an denen jemand hängt."""
|
||||
teile = " UNION ".join(f"SELECT movie_id FROM {t}" for t in BENUTZT)
|
||||
return {r[0] for r in conn.execute(teile)}
|
||||
|
||||
|
||||
def drop_missing(conn, keep_ids):
|
||||
"""Was weder auf Netflix läuft noch im Kanon steht, fliegt raus."""
|
||||
"""Was weder auf Netflix läuft noch im Kanon steht, fliegt raus.
|
||||
|
||||
Ausgenommen, was jemand gemerkt, vorgeschlagen oder geschaut hat: die
|
||||
Karten dazu holen ihre Daten aus dieser Tabelle.
|
||||
"""
|
||||
current = {r["id"] for r in conn.execute("SELECT id FROM movies").fetchall()}
|
||||
gone = current - set(keep_ids)
|
||||
gone = current - set(keep_ids) - benutzte_ids(conn)
|
||||
if gone:
|
||||
conn.executemany("DELETE FROM movies WHERE id = ?", [(i,) for i in gone])
|
||||
return len(gone)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
@@ -22,7 +23,7 @@ from . import (
|
||||
watched,
|
||||
wishlist,
|
||||
)
|
||||
from .config import IMAGE_BASE, STATIC_DIR
|
||||
from .config import HOST_SCHLUESSEL, IMAGE_BASE, STATIC_DIR
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
# Ein einzelner Zuschauer erzeugt gut zwanzig Stream-Anfragen je Sekunde.
|
||||
@@ -93,12 +94,19 @@ async def startup():
|
||||
features.init()
|
||||
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
|
||||
# ein sauberer Stand
|
||||
if not HOST_SCHLUESSEL:
|
||||
log.warning(
|
||||
"HOST_SCHLUESSEL fehlt in der .env — der Hostmodus bleibt zu."
|
||||
)
|
||||
scheduler.add_job(sicherung, "cron", hour=4, minute=0, id="daily-backup")
|
||||
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
||||
scheduler.start()
|
||||
if not db.get_meta("last_sync"):
|
||||
log.info("Leere Datenbank, starte ersten Sync im Hintergrund")
|
||||
asyncio.create_task(guarded_sync())
|
||||
else:
|
||||
# Altbestand: gemerkte Filme, die noch keine Katalogdaten haben
|
||||
asyncio.create_task(sync.katalog_nachtragen())
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
@@ -364,14 +372,21 @@ def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(No
|
||||
|
||||
|
||||
async def film_holen(movie_id: int):
|
||||
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB."""
|
||||
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB.
|
||||
|
||||
Was von TMDB kommt, landet gleich im Katalog. Sonst blieben die Karten
|
||||
in Sammlung, Rad und Gesehen leer: die lesen ihre Daten von dort.
|
||||
"""
|
||||
movie = db.get_movie(movie_id)
|
||||
if movie:
|
||||
return movie
|
||||
try:
|
||||
return await search.detail(movie_id)
|
||||
movie = await search.detail(movie_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
||||
with db.connect() as conn:
|
||||
db.upsert(conn, movie)
|
||||
return movie
|
||||
|
||||
|
||||
@app.get("/api/features")
|
||||
@@ -493,7 +508,11 @@ def vergessen(
|
||||
@app.get("/api/users")
|
||||
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
return {"items": users.alle(), "mine": users.aktuell(sid)}
|
||||
return {
|
||||
"items": users.alle(),
|
||||
"mine": users.aktuell(sid),
|
||||
"host": users.ist_host(sid),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/users")
|
||||
@@ -514,17 +533,116 @@ def name_entfernen(user_id: int):
|
||||
return {"removed": 1}
|
||||
|
||||
|
||||
@app.get("/api/users/{user_id}/schutz")
|
||||
async def schutz_zeigen(
|
||||
user_id: int,
|
||||
response: Response,
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
"""Welcher Film hinterlegt ist — nur für den Host und den Namen selbst.
|
||||
|
||||
Wer ihn ändern darf, darf ihn auch sehen: sonst müsste man raten, was
|
||||
man gerade überschreibt.
|
||||
"""
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
if not users.ist_host(sid) and users.besitzer(sid) != user_id:
|
||||
raise HTTPException(status_code=403, detail="Nicht dein Name")
|
||||
movie_id = users.schutz_von(user_id)
|
||||
if movie_id is None:
|
||||
return {"film": None}
|
||||
film = await film_holen(movie_id)
|
||||
return {
|
||||
"film": {
|
||||
"id": film["id"],
|
||||
"title": film["title"],
|
||||
"year": film.get("year"),
|
||||
"poster_path": film.get("poster_path"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/schutz")
|
||||
def schutz_setzen(
|
||||
user_id: int,
|
||||
response: Response,
|
||||
movie_id: int | None = Body(None, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
"""Anmeldefilm hinterlegen — oder mit movie_id null wieder freigeben.
|
||||
|
||||
Der Host pflegt die Runde, sonst darf jeder nur den eigenen Namen
|
||||
ändern. Wer angemeldet ist, hat den alten Film ohnehin gewusst.
|
||||
"""
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
if not users.ist_host(sid) and users.besitzer(sid) != user_id:
|
||||
raise HTTPException(status_code=403, detail="Nicht dein Name")
|
||||
if not users.schutz_setzen(user_id, movie_id):
|
||||
raise HTTPException(status_code=404, detail="Unbekannter Name")
|
||||
events.senden("users")
|
||||
return {"geschuetzt": movie_id is not None}
|
||||
|
||||
|
||||
@app.get("/api/host")
|
||||
def host_stand(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
return {"host": users.ist_host(sid)}
|
||||
|
||||
|
||||
@app.post("/api/host")
|
||||
def host_modus(
|
||||
response: Response,
|
||||
an: bool = Body(..., embed=True),
|
||||
schluessel: str | None = Body(None, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
|
||||
|
||||
Freischalten geht nur mit dem Schlüssel aus der .env, einmal über
|
||||
/?host=<schlüssel>. Ohne das käme jeder mit der Tastenkombination an
|
||||
den Anmeldefilmen vorbei.
|
||||
|
||||
Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe
|
||||
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
|
||||
diese Route nicht zuständig.
|
||||
"""
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
if not an:
|
||||
return {"host": users.host_setzen(sid, False)}
|
||||
|
||||
darf = users.ist_host(sid) or (
|
||||
bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL)
|
||||
)
|
||||
if not darf:
|
||||
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
|
||||
return {"host": users.host_setzen(sid, True)}
|
||||
|
||||
|
||||
@app.post("/api/users/waehlen")
|
||||
def name_waehlen(
|
||||
response: Response,
|
||||
user_id: int | None = Body(None, embed=True),
|
||||
movie_id: int | None = Body(None, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
"""Wer bin ich? Ohne user_id wieder anonym."""
|
||||
"""Wer bin ich? Ohne user_id wieder anonym.
|
||||
|
||||
Hat der Name einen Anmeldefilm, muss movie_id ihn treffen. Der Host
|
||||
und wer schon angemeldet ist, kommen ohne durch.
|
||||
"""
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
if user_id is None:
|
||||
users.abwaehlen(sid)
|
||||
return {"mine": None}
|
||||
|
||||
film = users.schutz_von(user_id)
|
||||
frei = (
|
||||
film is None
|
||||
or users.ist_host(sid)
|
||||
or users.besitzer(sid) == user_id
|
||||
)
|
||||
if not frei and movie_id != film:
|
||||
raise HTTPException(status_code=403, detail="Das ist nicht der Film.")
|
||||
|
||||
benutzer = users.waehlen(sid, user_id)
|
||||
if not benutzer:
|
||||
raise HTTPException(status_code=404, detail="Unbekannter Name")
|
||||
@@ -576,14 +694,13 @@ def bewerten(
|
||||
watched_id: int,
|
||||
response: Response,
|
||||
stars: int = Body(..., embed=True),
|
||||
text: str | None = Body(None, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
mitglied(sid)
|
||||
stars = max(1, min(watched.MAX_STERNE, stars))
|
||||
# Der Name kommt aus der Runde, nicht aus der Anfrage
|
||||
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid), text)
|
||||
eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid))
|
||||
if not eintrag:
|
||||
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
|
||||
events.senden("watched")
|
||||
@@ -599,6 +716,40 @@ def wertung_loeschen(rating_id: int):
|
||||
return {"removed": 1}
|
||||
|
||||
|
||||
@app.post("/api/watched/{watched_id}/notes")
|
||||
def gaestebuch_schreiben(
|
||||
watched_id: int,
|
||||
response: Response,
|
||||
text: str = Body(..., embed=True),
|
||||
parent_id: int | None = Body(None, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
"""Kommentar zum Abend, oder mit parent_id eine Antwort auf einen anderen."""
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
mitglied(sid)
|
||||
note_id = watched.anmerken(watched_id, sid, text, parent_id)
|
||||
if note_id is None:
|
||||
raise HTTPException(status_code=400, detail="Kein Text oder Ziel unbekannt")
|
||||
events.senden("watched")
|
||||
return {"id": note_id}
|
||||
|
||||
|
||||
# Eigener Pfad, damit er /api/watched/{watched_id} nicht in die Quere kommt
|
||||
@app.delete("/api/watched-notes/{note_id}")
|
||||
def gaestebuch_loeschen(
|
||||
note_id: int,
|
||||
response: Response,
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
"""Der Verfasser oder der Host — samt allen Antworten darunter."""
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
entfernt = watched.anmerkung_loeschen(note_id, sid, users.ist_host(sid))
|
||||
if not entfernt:
|
||||
raise HTTPException(status_code=404, detail="Nicht gefunden oder nicht deins")
|
||||
events.senden("watched")
|
||||
return {"removed": entfernt}
|
||||
|
||||
|
||||
@app.delete("/api/suggestions/alle")
|
||||
def runde_zuruecksetzen():
|
||||
"""Host leert die Runde: Vorschläge weg, Rad wieder leer."""
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from . import canon, db, imagehash, netflix, teaser, tmdb
|
||||
from . import canon, db, imagehash, netflix, search, teaser, tmdb
|
||||
|
||||
log = logging.getLogger("sync")
|
||||
|
||||
@@ -144,6 +144,40 @@ async def resolve_canon():
|
||||
return places
|
||||
|
||||
|
||||
async def katalog_nachtragen():
|
||||
"""Filme in den Katalog holen, die jemand gemerkt hat, die aber weder
|
||||
auf Netflix laufen noch im Kanon stehen.
|
||||
|
||||
Ohne sie bleiben die Karten in Sammlung, Rad und Gesehen leer: kein
|
||||
Teaser, keine Note, keine Bilder. Die Detailansicht holt sich das
|
||||
jedes Mal neu von TMDB — hier landet es einmal in der Datenbank.
|
||||
"""
|
||||
with db.connect() as conn:
|
||||
fehlen = sorted(
|
||||
db.benutzte_ids(conn)
|
||||
- {r["id"] for r in conn.execute("SELECT id FROM movies")}
|
||||
)
|
||||
if not fehlen:
|
||||
return 0
|
||||
|
||||
bremse = asyncio.Semaphore(TMDB_CONCURRENCY)
|
||||
|
||||
async def hole(movie_id):
|
||||
async with bremse:
|
||||
try:
|
||||
return await search.detail(movie_id)
|
||||
except Exception:
|
||||
# Ein gelöschter TMDB-Eintrag soll den Rest nicht aufhalten
|
||||
return None
|
||||
|
||||
filme = [f for f in await asyncio.gather(*map(hole, fehlen)) if f]
|
||||
with db.connect() as conn:
|
||||
for film in filme:
|
||||
db.upsert(conn, film)
|
||||
log.info("%s Filme aus Sammlungen in den Katalog geholt", len(filme))
|
||||
return len(filme)
|
||||
|
||||
|
||||
async def run():
|
||||
log.info("Sync startet")
|
||||
canon_places = await resolve_canon()
|
||||
@@ -180,15 +214,18 @@ async def run():
|
||||
db.set_meta(conn, "movie_count", len(on_netflix))
|
||||
db.set_meta(conn, "canon_count", sum(1 for m in movies if m["canon_group"] is not None))
|
||||
|
||||
nachgetragen = await katalog_nachtragen()
|
||||
|
||||
log.info(
|
||||
"Sync fertig: %s Filme (%s Netflix, %s Kanon), "
|
||||
"%s Netflix-Links, %s neue Teaser, %s entfernt",
|
||||
"%s Netflix-Links, %s neue Teaser, %s entfernt, %s nachgetragen",
|
||||
len(movies),
|
||||
len(on_netflix),
|
||||
sum(1 for m in movies if m["canon_group"] is not None),
|
||||
linked,
|
||||
new_teasers,
|
||||
removed,
|
||||
nachgetragen,
|
||||
)
|
||||
return {
|
||||
"movies": len(movies),
|
||||
@@ -196,6 +233,7 @@ async def run():
|
||||
"netflix_links": linked,
|
||||
"new_teasers": new_teasers,
|
||||
"removed": removed,
|
||||
"nachgetragen": nachgetragen,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Die Runde: wer mitmacht, und wer gerade wer ist.
|
||||
|
||||
Der Host pflegt die Namensliste. Alle anderen wählen sich einen davon aus.
|
||||
Kein Passwort — für acht Freunde, die sich ohnehin über Discord hören, wäre
|
||||
das nur eine Hürde ohne Nutzen.
|
||||
|
||||
Wer nicht will, dass ein anderer in seinem Namen bewertet, hinterlegt in
|
||||
der Verwaltung einen Anmeldefilm. Dann muss man ihn beim Wechsel erst in
|
||||
der Suche finden und anklicken. Kein Passwort im Klartext, nichts zu
|
||||
merken außer einem Film — und für einen Filmabend unter Freunden genau die
|
||||
richtige Hürde. Wer keinen Film hinterlegt, ist weiter offen für alle.
|
||||
|
||||
Die Zuordnung hängt an derselben Cookie-Sitzung wie Vorschläge und
|
||||
Wertungen. Damit steht der Name serverseitig fest und kommt nicht mehr aus
|
||||
@@ -26,18 +30,37 @@ CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
user_id INTEGER NOT NULL,
|
||||
seit TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Wer den Hostmodus eingeschaltet hat. Der Host führt durch den Abend und
|
||||
-- wechselt die Namen frei, ohne jedes Mal den Anmeldefilm zu suchen.
|
||||
CREATE TABLE IF NOT EXISTS host_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
seit TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def init():
|
||||
with connect() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
# Der Anmeldefilm kam später dazu
|
||||
spalten = {r[1] for r in conn.execute("PRAGMA table_info(users)")}
|
||||
if "auth_movie_id" not in spalten:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN auth_movie_id INTEGER")
|
||||
|
||||
|
||||
def _oeffentlich(row):
|
||||
"""Der hinterlegte Film verlässt den Server nie — sonst stünde das
|
||||
Passwort im Netzwerk-Tab. Nach draußen geht nur, ob einer gesetzt ist."""
|
||||
benutzer = dict(row)
|
||||
benutzer["geschuetzt"] = benutzer.pop("auth_movie_id", None) is not None
|
||||
return benutzer
|
||||
|
||||
|
||||
def alle():
|
||||
with connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY name COLLATE NOCASE").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
return [_oeffentlich(r) for r in rows]
|
||||
|
||||
|
||||
def anlegen(name):
|
||||
@@ -59,7 +82,24 @@ def anlegen(name):
|
||||
def von_name(name):
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE name = ?", (name,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
return _oeffentlich(row) if row else None
|
||||
|
||||
|
||||
def schutz_setzen(user_id, movie_id):
|
||||
"""Anmeldefilm hinterlegen oder mit None wieder freigeben."""
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"UPDATE users SET auth_movie_id = ? WHERE id = ?", (movie_id, user_id)
|
||||
).rowcount
|
||||
|
||||
|
||||
def schutz_von(user_id):
|
||||
"""Die hinterlegte Film-Kennung, oder None bei offenem Namen."""
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT auth_movie_id FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
return row["auth_movie_id"] if row else None
|
||||
|
||||
|
||||
def entfernen(user_id):
|
||||
@@ -84,7 +124,7 @@ def waehlen(session_id, user_id):
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
return dict(da)
|
||||
return _oeffentlich(da)
|
||||
|
||||
|
||||
def abwaehlen(session_id):
|
||||
@@ -103,7 +143,32 @@ def aktuell(session_id):
|
||||
"WHERE s.session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
return _oeffentlich(row) if row else None
|
||||
|
||||
|
||||
def ist_host(session_id):
|
||||
with connect() as conn:
|
||||
return bool(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM host_sessions WHERE session_id = ?", (session_id,)
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
|
||||
def host_setzen(session_id, an):
|
||||
"""Den Hostmodus merkt sich auch der Server — sonst könnte er den
|
||||
Anmeldefilm nicht überspringen."""
|
||||
with connect() as conn:
|
||||
if an:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO host_sessions (session_id, seit) VALUES (?, ?)",
|
||||
(session_id, datetime.now(timezone.utc).isoformat(timespec="seconds")),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"DELETE FROM host_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
return an
|
||||
|
||||
|
||||
def besitzer(session_id):
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""Bereits geschaute Filme und ihre Bewertungen.
|
||||
"""Bereits geschaute Filme — das Buch der Abende.
|
||||
|
||||
Der Host trägt ein, was am Abend lief. Bewerten darf jeder, eine Stimme
|
||||
je Sitzung — dieselbe Cookie-Sitzung wie bei den Vorschlägen. Der Name ist
|
||||
freiwillig, ohne ihn zählt die Stimme trotzdem.
|
||||
Der Host trägt ein, was am Abend lief. Jeder Film bekommt eine Seite mit
|
||||
zwei Teilen. Links die Momentaufnahme: eine Wertung je Person, nur Sterne,
|
||||
so wie es direkt nach dem Abspann war. Rechts die Diskussion: Kommentare,
|
||||
auf die man antworten kann, so tief man will.
|
||||
|
||||
Titel und Poster werden mitgespeichert, damit ein Eintrag lesbar bleibt,
|
||||
auch wenn der Film später aus dem Katalog fällt.
|
||||
auch wenn der Film später aus dem Katalog fällt. Szenenbild und Laufzeit
|
||||
kommen aus dem Katalog.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import users
|
||||
@@ -39,16 +42,54 @@ CREATE TABLE IF NOT EXISTS ratings (
|
||||
);
|
||||
"""
|
||||
|
||||
# Die Diskussion: ein Kommentar zum Abend, oder mit parent_id eine
|
||||
# Antwort auf einen anderen Kommentar — beliebig tief
|
||||
NOTES = """
|
||||
CREATE TABLE IF NOT EXISTS watched_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
watched_id INTEGER NOT NULL,
|
||||
parent_id INTEGER,
|
||||
session_id TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
MAX_STERNE = 5
|
||||
# Manche schreiben eine ganze Rezension — rund 600 Wörter passen hinein
|
||||
MAX_TEXT = 4000
|
||||
MAX_KOMMENTAR = 4000
|
||||
|
||||
# Was das Gästebuch aus dem Katalog dazuholt
|
||||
AUS_KATALOG = ("backdrop_path", "images", "runtime", "genres")
|
||||
|
||||
|
||||
def init():
|
||||
with connect() as conn:
|
||||
conn.executescript(SCHEMA + RATINGS)
|
||||
conn.executescript(SCHEMA + RATINGS + NOTES)
|
||||
# Eine Stimme je Person und Film
|
||||
users.je_person(conn, "ratings", RATINGS, "watched_id")
|
||||
# Herzen an Kommentaren gab es kurz — keiner hat sie vermisst
|
||||
conn.execute("DROP TABLE IF EXISTS watched_hearts")
|
||||
# Kurz hingen Antworten an der Wertung statt aneinander. Daten gab
|
||||
# es dazu nie, die Spalte bekommt nur ihren neuen Namen.
|
||||
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched_notes)")}
|
||||
if "rating_id" in spalten:
|
||||
conn.execute("ALTER TABLE watched_notes RENAME COLUMN rating_id TO parent_id")
|
||||
# Eine Weile stand ein Text an der Wertung. Seit die Sterne allein
|
||||
# die Wertung sind, gehört er in die Diskussion — einmalig umziehen.
|
||||
alte = conn.execute(
|
||||
"SELECT * FROM ratings WHERE text IS NOT NULL AND text != ''"
|
||||
).fetchall()
|
||||
for r in alte:
|
||||
conn.execute(
|
||||
"INSERT INTO watched_notes "
|
||||
"(watched_id, parent_id, session_id, user_id, text, created_at) "
|
||||
"VALUES (?, NULL, ?, ?, ?, ?)",
|
||||
(r["watched_id"], r["session_id"], r["user_id"], r["text"], r["created_at"]),
|
||||
)
|
||||
if alte:
|
||||
conn.execute("UPDATE ratings SET text = NULL")
|
||||
# Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken
|
||||
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched)")}
|
||||
if "hidden" not in spalten:
|
||||
@@ -66,43 +107,80 @@ def _jetzt():
|
||||
|
||||
|
||||
def alle(session_id):
|
||||
"""Zuletzt geschaut zuerst, mit Schnitt und der eigenen Stimme."""
|
||||
"""Zuletzt geschaut zuerst, mit Schnitt, eigener Stimme und
|
||||
Kommentaren. Die laufende Nummer zählt vom ersten Abend."""
|
||||
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
|
||||
with connect() as conn:
|
||||
filme = conn.execute(
|
||||
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC"
|
||||
f"SELECT w.*, {felder} FROM watched w "
|
||||
"LEFT JOIN movies m ON m.id = w.movie_id "
|
||||
"ORDER BY w.watched_at DESC, w.id DESC"
|
||||
).fetchall()
|
||||
stimmen = conn.execute(
|
||||
"SELECT id, watched_id, session_id, user_id, stars, name, text "
|
||||
"SELECT id, watched_id, session_id, user_id, stars, name, created_at "
|
||||
"FROM ratings ORDER BY created_at ASC"
|
||||
).fetchall()
|
||||
notizen = conn.execute(
|
||||
"SELECT n.*, u.name FROM watched_notes n "
|
||||
"LEFT JOIN users u ON u.id = n.user_id "
|
||||
"ORDER BY n.created_at ASC"
|
||||
).fetchall()
|
||||
|
||||
ich = besitzer(session_id)
|
||||
|
||||
def notiz(n):
|
||||
return {
|
||||
"id": n["id"],
|
||||
"text": n["text"],
|
||||
"name": n["name"],
|
||||
"created_at": n["created_at"],
|
||||
"mine": gehoert_mir(n, session_id, ich),
|
||||
"replies": [],
|
||||
}
|
||||
|
||||
# Die Kommentare eines Films als Baum: jede Antwort hängt sich an
|
||||
# ihren Vater, was keinen hat, steht oben. Die Liste kommt nach
|
||||
# Zeit sortiert, also ist der Vater immer schon da.
|
||||
knoten = {n["id"]: notiz(n) for n in notizen}
|
||||
kommentare = {}
|
||||
for n in notizen:
|
||||
k = knoten[n["id"]]
|
||||
vater = knoten.get(n["parent_id"]) if n["parent_id"] is not None else None
|
||||
if vater:
|
||||
vater["replies"].append(k)
|
||||
else:
|
||||
kommentare.setdefault(n["watched_id"], []).append(k)
|
||||
|
||||
je_film = {}
|
||||
for s in stimmen:
|
||||
je_film.setdefault(s["watched_id"], []).append(s)
|
||||
|
||||
out = []
|
||||
for film in filme:
|
||||
for nummer, film in zip(range(len(filme), 0, -1), filme):
|
||||
eigene = je_film.get(film["id"], [])
|
||||
werte = [s["stars"] for s in eigene]
|
||||
meine = next((s for s in eigene if gehoert_mir(s, session_id, ich)), None)
|
||||
daten = dict(film)
|
||||
daten["images"] = json.loads(daten["images"]) if daten["images"] else []
|
||||
daten["genres"] = json.loads(daten["genres"]) if daten["genres"] else []
|
||||
out.append(
|
||||
{
|
||||
**dict(film),
|
||||
**daten,
|
||||
"nummer": nummer,
|
||||
"ratings": [
|
||||
{
|
||||
"id": s["id"],
|
||||
"stars": s["stars"],
|
||||
"name": s["name"],
|
||||
"text": s["text"],
|
||||
"created_at": s["created_at"],
|
||||
"mine": gehoert_mir(s, session_id, ich),
|
||||
}
|
||||
for s in eigene
|
||||
],
|
||||
"notes": kommentare.get(film["id"], []),
|
||||
"average": round(sum(werte) / len(werte), 1) if werte else None,
|
||||
"count": len(werte),
|
||||
"mine": meine["stars"] if meine else None,
|
||||
"mine_text": meine["text"] if meine else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -130,7 +208,9 @@ def markieren(movie):
|
||||
|
||||
|
||||
def entfernen(watched_id):
|
||||
"""Samt Wertungen und Kommentaren."""
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM watched_notes WHERE watched_id = ?", (watched_id,))
|
||||
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
|
||||
return conn.execute(
|
||||
"DELETE FROM watched WHERE id = ?", (watched_id,)
|
||||
@@ -156,15 +236,9 @@ def sichtbarkeit(watched_id, versteckt):
|
||||
).rowcount
|
||||
|
||||
|
||||
def bewerten(watched_id, session_id, stars, name=None, text=None):
|
||||
"""Sterne und ein optionaler Satz dazu.
|
||||
|
||||
Kommt kein Text mit (None), bleibt der bisherige stehen — wer nur die
|
||||
Sterne nachjustiert, verliert seinen Satz nicht. Ein leerer String
|
||||
löscht ihn ausdrücklich.
|
||||
|
||||
Gibt None zurück, wenn der Film gar nicht in der Liste steht.
|
||||
"""
|
||||
def bewerten(watched_id, session_id, stars, name=None):
|
||||
"""Sterne setzen. Gibt None zurück, wenn der Film nicht in der Liste
|
||||
steht."""
|
||||
with connect() as conn:
|
||||
da = conn.execute(
|
||||
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
|
||||
@@ -172,14 +246,6 @@ def bewerten(watched_id, session_id, stars, name=None, text=None):
|
||||
if not da:
|
||||
return None
|
||||
ich = besitzer(session_id)
|
||||
if text is None:
|
||||
alt = conn.execute(
|
||||
f"SELECT text FROM ratings WHERE watched_id = ? AND {WEM}",
|
||||
(watched_id, ich, ich, session_id),
|
||||
).fetchone()
|
||||
text = alt["text"] if alt else None
|
||||
else:
|
||||
text = text.strip()[:MAX_TEXT] or None
|
||||
# Eine Stimme je Person — die eigene alte fliegt vorher raus
|
||||
conn.execute(
|
||||
f"DELETE FROM ratings WHERE watched_id = ? AND {WEM}",
|
||||
@@ -187,19 +253,11 @@ def bewerten(watched_id, session_id, stars, name=None, text=None):
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO ratings "
|
||||
"(watched_id, session_id, user_id, stars, name, text, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
watched_id,
|
||||
session_id,
|
||||
ich,
|
||||
stars,
|
||||
(name or "").strip()[:40] or None,
|
||||
text,
|
||||
_jetzt(),
|
||||
),
|
||||
"(watched_id, session_id, user_id, stars, name, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(watched_id, session_id, ich, stars, (name or "").strip()[:40] or None, _jetzt()),
|
||||
)
|
||||
return {"watched_id": watched_id, "stars": stars, "text": text}
|
||||
return {"watched_id": watched_id, "stars": stars}
|
||||
|
||||
|
||||
def stimme_loeschen(rating_id):
|
||||
@@ -210,6 +268,70 @@ def stimme_loeschen(rating_id):
|
||||
).rowcount
|
||||
|
||||
|
||||
def anmerken(watched_id, session_id, text, parent_id=None):
|
||||
"""Kommentar zum Abend, oder mit parent_id eine Antwort auf einen
|
||||
anderen. Gibt None zurück, wenn Film oder Vater fehlen."""
|
||||
text = (text or "").strip()[:MAX_KOMMENTAR]
|
||||
if not text:
|
||||
return None
|
||||
with connect() as conn:
|
||||
da = conn.execute(
|
||||
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
|
||||
).fetchone()
|
||||
if not da:
|
||||
return None
|
||||
if parent_id is not None:
|
||||
passt = conn.execute(
|
||||
"SELECT 1 FROM watched_notes WHERE id = ? AND watched_id = ?",
|
||||
(parent_id, watched_id),
|
||||
).fetchone()
|
||||
if not passt:
|
||||
return None
|
||||
cur = conn.execute(
|
||||
"INSERT INTO watched_notes "
|
||||
"(watched_id, parent_id, session_id, user_id, text, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(watched_id, parent_id, session_id, besitzer(session_id), text, _jetzt()),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _nachkommen(conn, note_id):
|
||||
"""Alle Antworten unter einem Kommentar, beliebig tief."""
|
||||
gefunden, offen = [], [note_id]
|
||||
while offen:
|
||||
kinder = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
f"SELECT id FROM watched_notes WHERE parent_id IN "
|
||||
f"({','.join('?' * len(offen))})",
|
||||
offen,
|
||||
)
|
||||
]
|
||||
gefunden.extend(kinder)
|
||||
offen = kinder
|
||||
return gefunden
|
||||
|
||||
|
||||
def anmerkung_loeschen(note_id, session_id, host):
|
||||
"""Der Verfasser und der Host dürfen weg damit — samt allem, was
|
||||
darunter hängt. Eine Antwort ohne ihren Anlass ergäbe keinen Sinn."""
|
||||
ich = besitzer(session_id)
|
||||
with connect() as conn:
|
||||
zeile = conn.execute(
|
||||
"SELECT * FROM watched_notes WHERE id = ?", (note_id,)
|
||||
).fetchone()
|
||||
if not zeile:
|
||||
return 0
|
||||
if not host and not gehoert_mir(zeile, session_id, ich):
|
||||
return 0
|
||||
alle_ids = [note_id, *_nachkommen(conn, note_id)]
|
||||
conn.executemany(
|
||||
"DELETE FROM watched_notes WHERE id = ?", [(i,) for i in alle_ids]
|
||||
)
|
||||
return len(alle_ids)
|
||||
|
||||
|
||||
def ids():
|
||||
"""movie_ids der geschauten Filme, für die Detailansicht."""
|
||||
with connect() as conn:
|
||||
|
||||
@@ -37,13 +37,20 @@ def init():
|
||||
users.je_person(conn, "wishlist", SCHEMA, "movie_id")
|
||||
|
||||
|
||||
# Was die Karte in der Detailansicht zeigt. Steht im Katalog, nicht in der
|
||||
# Sammlung — ein Film, der nie im Katalog war, bleibt hier leer.
|
||||
AUS_KATALOG = ("genres", "teaser", "overview", "runtime", "vote_average",
|
||||
"vote_count", "images")
|
||||
|
||||
|
||||
def alle(session_id):
|
||||
"""Alle Sammlungen, jede mit dem Namen ihres Besitzers."""
|
||||
felder = ", ".join(f"m.{f}" for f in AUS_KATALOG)
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
# Genres kommen aus dem Katalog. Ein Film, der nie darin stand,
|
||||
# hat keine — das Rad behandelt ihn dann als "nicht Horror".
|
||||
"SELECT w.*, u.name, m.genres FROM wishlist w "
|
||||
f"SELECT w.*, u.name, {felder} FROM wishlist w "
|
||||
"LEFT JOIN users u ON u.id = w.user_id "
|
||||
"LEFT JOIN movies m ON m.id = w.movie_id "
|
||||
"ORDER BY w.created_at DESC"
|
||||
@@ -54,9 +61,10 @@ def alle(session_id):
|
||||
**{
|
||||
k: v
|
||||
for k, v in dict(r).items()
|
||||
if k not in ("session_id", "user_id", "genres")
|
||||
if k not in ("session_id", "user_id", "genres", "images")
|
||||
},
|
||||
"genres": json.loads(r["genres"]) if r["genres"] else [],
|
||||
"images": json.loads(r["images"]) if r["images"] else [],
|
||||
"mine": gehoert_mir(r, session_id, ich),
|
||||
}
|
||||
for r in rows
|
||||
|
||||
Reference in New Issue
Block a user