This commit is contained in:
team3
2026-09-09 17:58:52 +02:00
parent 2bd03000e8
commit 847b747870
8 changed files with 419 additions and 55 deletions

View File

@@ -6,20 +6,26 @@ install:
# MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins
# Leere und OBS findet keinen Server.
# Räumt immer erst auf: ein gestoppter Container von vorhin belegt sonst
# den Namen, und "docker ps" sieht ihn nicht.
mtx:
@docker ps --filter name=horror-mtx-dev --format '{{.Names}}' | grep -q . || \
docker run -d --name horror-mtx-dev --network host \
--env-file .env \
-e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \
-v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \
bluenviron/mediamtx:latest > /dev/null
-@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null
@docker run -d --rm --name horror-mtx-dev --network host \
--env-file .env \
-e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \
-v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \
bluenviron/mediamtx:latest > /dev/null
@echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890"
mtx-stop:
-@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null
@echo "MediaMTX gestoppt."
# Startet unabhängig davon, was gerade läuft. Die eckige Klammer im Muster
# sorgt dafür, dass pkill nicht die eigene Zeile trifft.
dev: mtx
-@pkill -f "[u]vicorn app.main:app" 2>/dev/null
-@pkill -f "[v]ite --port 5173" 2>/dev/null
@echo "Backend: http://localhost:8000 Frontend: http://localhost:5173"
@cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 &
@cd frontend && npx vite --port 5173

View File

@@ -7,6 +7,7 @@ Jeder darf aufschreiben, was er sich wünscht. Alle sehen alle Wünsche,
from datetime import datetime, timezone
from .db import connect
from .users import besitzer, gehoert_mir
SCHEMA = """
CREATE TABLE IF NOT EXISTS features (
@@ -20,9 +21,23 @@ CREATE TABLE IF NOT EXISTS features (
MAX_LAENGE = 500
def _spalte_user_id(conn, tabelle):
"""Nachrüsten und aus der Sitzung füllen — einmalig je Datenbank."""
spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")}
if "user_id" in spalten:
return
conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER")
conn.execute(
f"UPDATE {tabelle} SET user_id = ("
" SELECT user_id FROM user_sessions s WHERE s.session_id = "
f" {tabelle}.session_id)"
)
def init():
with connect() as conn:
conn.executescript(SCHEMA)
_spalte_user_id(conn, "features")
def alle(session_id):
@@ -34,10 +49,15 @@ def alle(session_id):
"LEFT JOIN users u ON u.id = s.user_id "
"ORDER BY f.created_at DESC"
).fetchall()
ich = besitzer(session_id)
return [
{
**{k: v for k, v in dict(r).items() if k != "session_id"},
"mine": r["session_id"] == session_id,
**{
k: v
for k, v in dict(r).items()
if k not in ("session_id", "user_id")
},
"mine": gehoert_mir(r, session_id, ich),
}
for r in rows
]
@@ -50,9 +70,11 @@ def anlegen(session_id, text):
return None
with connect() as conn:
conn.execute(
"INSERT INTO features (session_id, text, created_at) VALUES (?, ?, ?)",
"INSERT INTO features (session_id, user_id, text, created_at) "
"VALUES (?, ?, ?, ?)",
(
session_id,
besitzer(session_id),
text,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),

View File

@@ -15,11 +15,12 @@ import uuid
from datetime import datetime, timezone
from .db import connect
from .users import besitzer
SCHEMA = """
CREATE TABLE IF NOT EXISTS suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
session_id TEXT NOT NULL,
movie_id INTEGER NOT NULL,
title TEXT NOT NULL,
year INTEGER,
@@ -37,9 +38,67 @@ COOKIE = "filmabend_sid"
DREH_DAUER = 4.2
# Ein Vorschlag je Person. Früher stand das UNIQUE auf der Sitzung — dann
# blockierte der Vorschlag des einen Namens den des nächsten, sobald beide
# denselben Browser benutzten.
INDIZES = """
CREATE UNIQUE INDEX IF NOT EXISTS suggestions_je_benutzer
ON suggestions (user_id) WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS suggestions_je_sitzung
ON suggestions (session_id) WHERE user_id IS NULL;
"""
def _unique_loesen(conn):
"""Das alte UNIQUE steckt in der Spaltendefinition — Tabelle neu bauen."""
sql = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='suggestions'"
).fetchone()
if not sql or "UNIQUE" not in sql["sql"]:
return
conn.executescript(
"""
ALTER TABLE suggestions RENAME TO suggestions_alt;
CREATE TABLE suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
user_id INTEGER,
movie_id INTEGER NOT NULL,
title TEXT NOT NULL,
year INTEGER,
poster_path TEXT,
name TEXT,
created_at TEXT NOT NULL
);
INSERT INTO suggestions
(id, session_id, user_id, movie_id, title, year, poster_path,
name, created_at)
SELECT id, session_id, user_id, movie_id, title, year, poster_path,
name, created_at FROM suggestions_alt;
DROP TABLE suggestions_alt;
"""
)
def _spalte_user_id(conn, tabelle):
"""Nachrüsten und aus der Sitzung füllen — einmalig je Datenbank."""
spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")}
if "user_id" in spalten:
return
conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER")
conn.execute(
f"UPDATE {tabelle} SET user_id = ("
" SELECT user_id FROM user_sessions s WHERE s.session_id = "
f" {tabelle}.session_id)"
)
def init():
with connect() as conn:
conn.executescript(SCHEMA)
_spalte_user_id(conn, "suggestions")
_unique_loesen(conn)
conn.executescript(INDIZES)
def neue_session():
@@ -49,15 +108,31 @@ def neue_session():
def alle():
with connect() as conn:
rows = conn.execute(
"SELECT * FROM suggestions ORDER BY created_at ASC"
# Laufzeit und Genres stehen im Katalog. Ein Vorschlag von
# außerhalb hat sie nicht — die Liste zeigt dann nur den Titel.
"SELECT s.*, m.runtime, m.genres FROM suggestions s "
"LEFT JOIN movies m ON m.id = s.movie_id "
"ORDER BY s.created_at ASC"
).fetchall()
return [dict(r) for r in rows]
return [
{
**{k: v for k, v in dict(r).items() if k != "genres"},
"genres": json.loads(r["genres"]) if r["genres"] else [],
}
for r in rows
]
# Mit Namen zählt der Name, ohne Namen die Sitzung
WEM = "(user_id = ? OR (? IS NULL AND user_id IS NULL AND session_id = ?))"
def von_session(session_id):
"""Der eigene Vorschlag — auch aus einem anderen Browser."""
ich = besitzer(session_id)
with connect() as conn:
row = conn.execute(
"SELECT * FROM suggestions WHERE session_id = ?", (session_id,)
f"SELECT * FROM suggestions WHERE {WEM}", (ich, ich, session_id)
).fetchone()
return dict(row) if row else None
@@ -66,6 +141,7 @@ def anlegen(session_id, movie, name=None):
"""Gibt den Vorschlag zurück, oder None wenn die Sitzung schon einen hat."""
eintrag = {
"session_id": session_id,
"user_id": besitzer(session_id),
"movie_id": movie["id"],
"title": movie["title"],
"year": movie.get("year"),
@@ -77,9 +153,10 @@ def anlegen(session_id, movie, name=None):
with connect() as conn:
conn.execute(
"INSERT INTO suggestions "
"(session_id, movie_id, title, year, poster_path, name, created_at) "
"VALUES (:session_id, :movie_id, :title, :year, :poster_path, "
":name, :created_at)",
"(session_id, user_id, movie_id, title, year, poster_path, "
"name, created_at) "
"VALUES (:session_id, :user_id, :movie_id, :title, :year, "
":poster_path, :name, :created_at)",
eintrag,
)
except sqlite3.IntegrityError:
@@ -89,8 +166,7 @@ def anlegen(session_id, movie, name=None):
def ersetzen(session_id, movie, name=None):
"""Wer schon vorgeschlagen hat, darf seinen Vorschlag ändern."""
with connect() as conn:
conn.execute("DELETE FROM suggestions WHERE session_id = ?", (session_id,))
loeschen(session_id)
return anlegen(session_id, movie, name)
@@ -122,9 +198,10 @@ def ziehen():
def loeschen(session_id):
ich = besitzer(session_id)
with connect() as conn:
return conn.execute(
"DELETE FROM suggestions WHERE session_id = ?", (session_id,)
f"DELETE FROM suggestions WHERE {WEM}", (ich, ich, session_id)
).rowcount

View File

@@ -106,6 +106,28 @@ def aktuell(session_id):
return dict(row) if row else None
def besitzer(session_id):
"""Die user_id dieser Sitzung, oder None ohne gewählten Namen.
Sammlungen, Vorschläge und Wertungen werden damit beschriftet. Ein
Namenswechsel im selben Browser lässt die alten Einträge dann in Ruhe —
sie gehören weiter dem Namen, unter dem sie entstanden sind.
"""
with connect() as conn:
row = conn.execute(
"SELECT user_id FROM user_sessions WHERE session_id = ?",
(session_id,),
).fetchone()
return row["user_id"] if row else None
def gehoert_mir(zeile, session_id, user_id):
"""Ein Eintrag gehört mir, wenn der Name passt — sonst die Sitzung."""
if zeile["user_id"] is not None or user_id is not None:
return zeile["user_id"] == user_id
return zeile["session_id"] == session_id
def name_von(session_id):
"""Nur der Name, für Vorschläge und Wertungen."""
benutzer = aktuell(session_id)

View File

@@ -11,6 +11,7 @@ auch wenn der Film später aus dem Katalog fällt.
from datetime import datetime, timezone
from .db import connect
from .users import besitzer, gehoert_mir
SCHEMA = """
CREATE TABLE IF NOT EXISTS watched (
@@ -37,9 +38,23 @@ CREATE TABLE IF NOT EXISTS ratings (
MAX_STERNE = 5
def _spalte_user_id(conn, tabelle):
"""Nachrüsten und aus der Sitzung füllen — einmalig je Datenbank."""
spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")}
if "user_id" in spalten:
return
conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER")
conn.execute(
f"UPDATE {tabelle} SET user_id = ("
" SELECT user_id FROM user_sessions s WHERE s.session_id = "
f" {tabelle}.session_id)"
)
def init():
with connect() as conn:
conn.executescript(SCHEMA)
_spalte_user_id(conn, "ratings")
# 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:
@@ -59,10 +74,11 @@ def alle(session_id):
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC"
).fetchall()
stimmen = conn.execute(
"SELECT id, watched_id, session_id, stars, name FROM ratings "
"SELECT id, watched_id, session_id, user_id, stars, name FROM ratings "
"ORDER BY created_at ASC"
).fetchall()
ich = besitzer(session_id)
je_film = {}
for s in stimmen:
je_film.setdefault(s["watched_id"], []).append(s)
@@ -81,7 +97,11 @@ def alle(session_id):
"average": round(sum(werte) / len(werte), 1) if werte else None,
"count": len(werte),
"mine": next(
(s["stars"] for s in eigene if s["session_id"] == session_id),
(
s["stars"]
for s in eigene
if gehoert_mir(s, session_id, ich)
),
None,
),
}
@@ -145,17 +165,25 @@ def bewerten(watched_id, session_id, stars, name=None):
).fetchone()
if not da:
return None
# Eine Stimme je Sitzung — eine neue Bewertung ersetzt die alte
# Eine Stimme je Person — die eigene alte fliegt vorher raus.
# Mit Namen zählt der Name, ohne Namen die Sitzung.
ich = besitzer(session_id)
conn.execute(
"DELETE FROM ratings WHERE watched_id = ? AND "
"(user_id = ? OR (? IS NULL AND user_id IS NULL AND session_id = ?))",
(watched_id, ich, ich, session_id),
)
conn.execute(
"INSERT INTO ratings "
"(watched_id, session_id, stars, name, created_at) "
"VALUES (?, ?, ?, ?, ?) "
"(watched_id, session_id, user_id, stars, name, created_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (watched_id, session_id) DO UPDATE SET "
"stars = excluded.stars, name = excluded.name, "
"created_at = excluded.created_at",
"user_id = excluded.user_id, stars = excluded.stars, "
"name = excluded.name, created_at = excluded.created_at",
(
watched_id,
session_id,
ich,
stars,
(name or "").strip()[:40] or None,
_jetzt(),

View File

@@ -8,10 +8,12 @@ Titel und Poster kommen mit in die Zeile. Ein gemerkter Film soll lesbar
bleiben, auch wenn er nie im Katalog stand oder Netflix ihn abgibt.
"""
import json
import sqlite3
from datetime import datetime, timezone
from .db import connect
from .users import besitzer, gehoert_mir
SCHEMA = """
CREATE TABLE IF NOT EXISTS wishlist (
@@ -27,24 +29,47 @@ CREATE TABLE IF NOT EXISTS wishlist (
"""
def _spalte_user_id(conn, tabelle):
"""Nachrüsten und aus der Sitzung füllen — einmalig je Datenbank."""
spalten = {r[1] for r in conn.execute(f"PRAGMA table_info({tabelle})")}
if "user_id" in spalten:
return
conn.execute(f"ALTER TABLE {tabelle} ADD COLUMN user_id INTEGER")
conn.execute(
f"UPDATE {tabelle} SET user_id = ("
" SELECT user_id FROM user_sessions s WHERE s.session_id = "
f" {tabelle}.session_id)"
)
def init():
with connect() as conn:
conn.executescript(SCHEMA)
_spalte_user_id(conn, "wishlist")
def alle(session_id):
"""Alle Sammlungen, jede mit dem Namen ihres Besitzers."""
with connect() as conn:
rows = conn.execute(
"SELECT w.*, u.name FROM wishlist w "
# 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 "
"LEFT JOIN user_sessions s ON s.session_id = w.session_id "
"LEFT JOIN users u ON u.id = s.user_id "
"LEFT JOIN movies m ON m.id = w.movie_id "
"ORDER BY w.created_at DESC"
).fetchall()
ich = besitzer(session_id)
return [
{
**{k: v for k, v in dict(r).items() if k != "session_id"},
"mine": r["session_id"] == session_id,
**{
k: v
for k, v in dict(r).items()
if k not in ("session_id", "user_id", "genres")
},
"genres": json.loads(r["genres"]) if r["genres"] else [],
"mine": gehoert_mir(r, session_id, ich),
}
for r in rows
]
@@ -56,10 +81,11 @@ def merken(session_id, movie):
with connect() as conn:
conn.execute(
"INSERT INTO wishlist "
"(session_id, movie_id, title, year, poster_path, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
"(session_id, user_id, movie_id, title, year, poster_path, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
session_id,
besitzer(session_id),
movie["id"],
movie["title"],
movie.get("year"),
@@ -72,20 +98,27 @@ def merken(session_id, movie):
return alle(session_id)
# Mit Namen zählt der Name, ohne Namen die Sitzung
WEM = "(user_id = ? OR (? IS NULL AND user_id IS NULL AND session_id = ?))"
def vergessen(session_id, movie_id):
ich = besitzer(session_id)
with connect() as conn:
return conn.execute(
"DELETE FROM wishlist WHERE session_id = ? AND movie_id = ?",
(session_id, movie_id),
f"DELETE FROM wishlist WHERE {WEM} AND movie_id = ?",
(ich, ich, session_id, movie_id),
).rowcount
def ids(session_id):
"""movie_ids der gemerkten Filme, für die Detailansicht."""
ich = besitzer(session_id)
with connect() as conn:
return [
r["movie_id"]
for r in conn.execute(
"SELECT movie_id FROM wishlist WHERE session_id = ?", (session_id,)
f"SELECT movie_id FROM wishlist WHERE {WEM}",
(ich, ich, session_id),
)
]

View File

@@ -349,7 +349,13 @@ async function michSetzen(id) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id ?? null }),
})
ladeRunde()
// Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne
// Nachladen zeigt die Seite weiter die des vorigen Benutzers.
await ladeRunde()
ladeVorschlag()
ladeMerkliste()
ladeGesehen()
ladeWuensche()
}
async function ladeRunde() {
@@ -688,6 +694,7 @@ onUnmounted(() => {
v-else-if="activeTab === 'wheel'"
ref="rad"
:host="host"
:ich="ich"
@open="selected = $event"
@reset="meinVorschlag = null"
/>

View File

@@ -1,9 +1,13 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
defineProps({ host: Boolean })
const props = defineProps({
host: Boolean,
// Wer gerade angemeldet ist. Beim Wechsel gehört eine andere Sammlung dazu.
ich: { type: Object, default: null },
})
const emit = defineEmits(['open', 'reset'])
const items = ref([])
@@ -95,8 +99,14 @@ async function laden_() {
}
}
watch(() => props.ich?.id, () => {
sammlungLaden()
laden_()
})
onMounted(async () => {
await laden_()
await sammlungLaden()
await laufendenDrehHolen()
verbinden()
})
@@ -184,6 +194,50 @@ async function zuruecksetzen() {
}
}
// Aus der eigenen Sammlung würfeln. Der Schalter trennt Horror von allem
// anderen — für Abende, an denen es keiner gruselig mag.
const nurHorror = ref(true)
const sammlung = ref([])
const wuerfelt = ref(false)
async function sammlungLaden() {
try {
const resp = await fetch('/api/wishlist')
if (resp.ok) sammlung.value = (await resp.json()).filter((e) => e.mine)
} catch {
sammlung.value = []
}
}
function nachGenre(horror) {
return sammlung.value.filter(
(e) => (e.genres || []).includes('Horror') === horror
)
}
const auswahl = computed(() => nachGenre(nurHorror.value))
const anzahlHorror = computed(() => nachGenre(true).length)
const anzahlOhne = computed(() => nachGenre(false).length)
async function wuerfeln() {
const treffer = auswahl.value
if (!treffer.length || wuerfelt.value) return
wuerfelt.value = true
const film = treffer[Math.floor(Math.random() * treffer.length)]
try {
const resp = await fetch('/api/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: film.movie_id }),
})
if (!resp.ok) throw new Error()
} catch {
fehler.value = 'Eintragen hat nicht geklappt.'
} finally {
wuerfelt.value = false
}
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
@@ -255,6 +309,25 @@ async function oeffnen(item) {
</div>
<aside class="liste">
<div v-if="!host" class="sammlung-wuerfeln">
<div class="schalter">
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
Horror ({{ anzahlHorror }})
</button>
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false">
Kein Horror ({{ anzahlOhne }})
</button>
</div>
<button
type="button"
class="wuerfeln"
:disabled="!auswahl.length || wuerfelt"
@click="wuerfeln"
>
Zufällig aus Sammlung
</button>
</div>
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
<ul>
@@ -265,9 +338,27 @@ async function oeffnen(item) {
:class="{ eigen: item.id === mine }"
@click="oeffnen(item)"
>
<span class="punkt" :style="{ background: FARBEN[i % FARBEN.length] }"></span>
<span class="titel">{{ item.title }}</span>
<span class="von">{{ item.name || '—' }}</span>
<span
class="cover"
:style="{ borderColor: FARBEN[i % FARBEN.length] }"
>
<img
v-if="posterUrl(item.poster_path, 'w154')"
:src="posterUrl(item.poster_path, 'w154')"
:alt="item.title"
loading="lazy"
/>
</span>
<span class="daten">
<span class="titel">{{ item.title }}</span>
<span class="meta">
{{ item.year }}<template v-if="item.runtime"> · {{ item.runtime }} Min.</template>
</span>
<span v-if="item.genres?.length" class="genres">
{{ item.genres.join(' · ') }}
</span>
<span class="von">{{ item.name || 'Anonym' }}</span>
</span>
</button>
</li>
</ul>
@@ -369,6 +460,53 @@ async function oeffnen(item) {
cursor: default;
}
.sammlung-wuerfeln {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.6rem;
}
.schalter {
display: flex;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
}
.schalter button {
flex: 1;
padding: 0.45rem 0.9rem;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
background: var(--bg);
}
.schalter button.an {
color: var(--text);
background: var(--bg-soft);
}
.wuerfeln {
padding: 0.5rem 1.1rem;
font-size: 0.88rem;
font-weight: 600;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
}
.wuerfeln:hover:not(:disabled) {
border-color: var(--accent);
}
.wuerfeln:disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.5;
}
.gewinner {
display: flex;
gap: 1rem;
@@ -431,42 +569,73 @@ async function oeffnen(item) {
.eintrag {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.7rem;
align-items: flex-start;
width: 100%;
padding: 0.4rem 0.5rem;
padding: 0.55rem;
color: var(--text);
font-size: 0.86rem;
text-align: left;
border-radius: 6px;
border-radius: 8px;
}
.eintrag:hover {
background: var(--bg-soft);
}
.eintrag.eigen .titel {
color: var(--accent);
font-weight: 600;
/* Der farbige Rand verbindet den Eintrag mit seinem Stück auf dem Rad */
.cover {
display: block;
width: 46px;
flex-shrink: 0;
overflow: hidden;
background: var(--bg-soft);
border-left: 3px solid;
border-radius: 3px;
}
.punkt {
width: 0.6rem;
height: 0.6rem;
flex-shrink: 0;
border-radius: 50%;
.cover img {
display: block;
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
}
.daten {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
padding-top: 0.1rem;
}
.titel {
flex: 1;
overflow: hidden;
font-weight: 600;
line-height: 1.25;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.eintrag.eigen .titel {
color: var(--accent);
}
.meta,
.genres,
.von {
overflow: hidden;
color: var(--muted);
font-size: 0.74rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.von {
color: var(--muted);
font-size: 0.76rem;
margin-top: 0.15rem;
font-size: 0.72rem;
opacity: 0.75;
}
.note {