chore: beta release v0.5.0-beta
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled

- Bump version to 0.5.0-beta
- Add Apache-2.0 LICENSE (official text) + NOTICE
- Add Docker support: multi-stage Dockerfile + docker-compose
  * non-root user, tini PID 1, gunicorn + uvicorn workers
  * healthcheck, OCI labels, persistent /data volume
- Add GitHub Actions CI (lint/import-check + Docker smoke-test)
- Backend polish:
  * modern lifespan handler (replaces deprecated on_event)
  * explicit sort validation (400 instead of 500)
  * title min_length=1 schema validation
  * configurable data dir + http timeout via env
  * avg_rating helper extracted (DRY)
- Frontend polish:
  * beta badge in title, footer with live version
  * loading spinner on initial fetch
  * updated README, badges, config table, roadmap
This commit is contained in:
ki
2026-07-21 23:34:01 +02:00
parent 56a8d5d302
commit 0bca2eaaa2
19 changed files with 736 additions and 124 deletions
+4
View File
@@ -0,0 +1,4 @@
"""WatchStack Watchlist für Bücher, Serien & mehr."""
__version__ = "0.5.0-beta"
__all__ = ["__version__"]
+11 -9
View File
@@ -1,4 +1,5 @@
"""Datenbank-Operationen (CRUD) für WatchStack."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from typing import Iterable, Optional, Sequence
@@ -166,23 +167,24 @@ def bump_progress(db: Session, media: models.Media, payload: schemas.ProgressUpd
# ---------- Statistik --------------------------------------------------------
def _avg(values: Sequence[Optional[float]]) -> Optional[float]:
"""Mittelwert ohne None, gerundet auf 2 Stellen; None wenn leer."""
rated = [v for v in values if v is not None]
if not rated:
return None
return round(sum(rated) / len(rated), 2)
def stats(db: Session) -> schemas.StatsResponse:
def per_kind(kind: str) -> schemas.StatsPerKind:
items = db.scalars(select(models.Media).where(models.Media.kind == kind)).all()
total = len(items)
avg = (
round(sum(m.rating for m in items if m.rating is not None) /
max(1, sum(1 for m in items if m.rating is not None)), 2)
if any(m.rating is not None for m in items)
else None
)
by_status: dict[str, int] = {s: 0 for s in models.STATUS_VALUES}
for m in items:
by_status[m.status] = by_status.get(m.status, 0) + 1
return schemas.StatsPerKind(
kind=kind,
total=total,
avg_rating=avg,
total=len(items),
avg_rating=_avg([m.rating for m in items]),
by_status=[schemas.StatsBucket(status=k, count=v) for k, v in by_status.items()],
)
+7 -6
View File
@@ -1,15 +1,17 @@
"""Datenbank-Engine und Session-Verwaltung."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import os
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
# SQLite-Datei liegt im data/-Ordner (gitignored).
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
DATA_DIR.mkdir(exist_ok=True)
# SQLite-Datei: überschreibbar via WATCHSTACK_DATA_DIR (für Docker)
_BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.environ.get("WATCHSTACK_DATA_DIR", _BASE_DIR / "data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH = DATA_DIR / "watchstack.db"
DATABASE_URL = f"sqlite:///{DB_PATH}"
@@ -39,7 +41,6 @@ def get_db():
def init_db() -> None:
"""Erstellt alle Tabellen, falls noch nicht vorhanden."""
# noqa: F401 Modelle müssen importiert sein, damit sie bei Base.metadata registriert sind.
from app import models # type: ignore[F401]
from app import models # noqa: F401 registriert Modelle bei Base.metadata
Base.metadata.create_all(bind=engine)
+5 -2
View File
@@ -1,7 +1,9 @@
"""Externe Cover-/Metadaten-Quellen (Open Library)."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import logging
import os
from typing import Optional
import httpx
@@ -10,9 +12,10 @@ log = logging.getLogger(__name__)
OL_SEARCH = "https://openlibrary.org/search.json"
OL_COVER = "https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg"
DEFAULT_TIMEOUT = float(os.environ.get("WATCHSTACK_HTTP_TIMEOUT", "8"))
def lookup_book_by_isbn(isbn: str, *, timeout: float = 8.0) -> Optional[dict]:
def lookup_book_by_isbn(isbn: str, *, timeout: float = DEFAULT_TIMEOUT) -> Optional[dict]:
"""Sucht Buchdaten anhand ISBN via Open Library."""
if not isbn:
return None
@@ -41,7 +44,7 @@ def lookup_book_by_isbn(isbn: str, *, timeout: float = 8.0) -> Optional[dict]:
}
def lookup_book_by_title(title: str, *, timeout: float = 8.0) -> Optional[dict]:
def lookup_book_by_title(title: str, *, timeout: float = DEFAULT_TIMEOUT) -> Optional[dict]:
"""Sucht Buchdaten anhand Titel (Fallback)."""
if not title:
return None
+76 -46
View File
@@ -1,65 +1,87 @@
"""FastAPI-App: REST-API + statisches Frontend."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from app import crud, schemas
from app import __version__, crud, schemas
from app.database import get_db, init_db
from app.external import lookup_book_by_isbn, lookup_book_by_title
from app.seed import seed_if_empty
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("watchstack")
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
app = FastAPI(
title="WatchStack",
description="Watchlist für Bücher, Serien & mehr.",
version="0.1.0",
)
__all__ = ["app"]
@app.on_event("startup")
def _startup() -> None:
# ---------- Lifespan (init DB + seed on boot) -------------------------------
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
with next(get_db()) as db:
seed_if_empty(db)
log.info("WatchStack %s ready", __version__)
yield
# ---------- Statische Dateien & UI ------------------------------------------
app = FastAPI(
title="WatchStack",
description="Watchlist für Bücher, Serien & mehr.",
version=__version__,
lifespan=lifespan,
contact={"name": "WatchStack", "url": "https://git.pkop.de/Vibecode/watchlist"},
license_info={"name": "Apache-2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0"},
)
# CORS — offen genug für lokale Beta, in Produktion via env einschränken
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# ---------- UI ---------------------------------------------------------------
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
def root(request: Request) -> HTMLResponse:
index = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
return HTMLResponse(index)
def root() -> HTMLResponse:
return HTMLResponse((STATIC_DIR / "index.html").read_text(encoding="utf-8"))
# ---------- Health -----------------------------------------------------------
# ---------- Health / Version -------------------------------------------------
@app.get("/api/health", tags=["meta"])
def health() -> dict:
return {"status": "ok", "service": "watchstack", "version": app.version}
def health() -> dict[str, str]:
return {"status": "ok", "service": "watchstack", "version": __version__}
# ---------- Metadaten-Lookup -------------------------------------------------
# ---------- Lookups ----------------------------------------------------------
@app.get("/api/lookup/book", tags=["lookup"])
def lookup_book(
isbn: Optional[str] = Query(None, description="ISBN-10 oder ISBN-13"),
title: Optional[str] = Query(None),
) -> dict:
isbn: str | None = Query(None, description="ISBN-10 oder ISBN-13"),
title: str | None = Query(None),
) -> dict[str, Any]:
"""Schlägt Buchdaten extern nach. ISBN hat Vorrang."""
if isbn:
result = lookup_book_by_isbn(isbn)
@@ -72,20 +94,40 @@ def lookup_book(
return result
# ---------- Sortierung -------------------------------------------------------
_SORT_KEYS = {
"updated_desc", "updated_asc",
"title_asc", "title_desc",
"rating_desc", "rating_asc",
"year_desc", "year_asc",
}
# ---------- Media ------------------------------------------------------------
def _get_or_404(db: Session, media_id: int):
"""Holt einen Eintrag oder wirft 404."""
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
return m
@app.get("/api/media", response_model=list[schemas.MediaOut], tags=["media"])
def api_list_media(
kind: Optional[str] = Query(None, pattern="^(book|series)$"),
status: Optional[str] = Query(None, pattern="^(plan|reading|done|hold|dropped)$"),
search: Optional[str] = Query(None),
genre: Optional[str] = Query(None),
tag: Optional[str] = Query(None),
kind: str | None = Query(None, pattern="^(book|series)$"),
status: str | None = Query(None, pattern="^(plan|reading|done|hold|dropped)$"),
search: str | None = Query(None, max_length=200),
genre: str | None = Query(None, max_length=60),
tag: str | None = Query(None, max_length=40),
sort: str = Query("updated_desc"),
db: Session = Depends(get_db),
):
items = crud.list_media(db, kind=kind, status=status, search=search, genre=genre, tag=tag, sort=sort)
return items
if sort not in _SORT_KEYS:
raise HTTPException(400, f"sort muss eines von {sorted(_SORT_KEYS)} sein")
return crud.list_media(db, kind=kind, status=status, search=search,
genre=genre, tag=tag, sort=sort)
@app.post("/api/media", response_model=schemas.MediaOut, status_code=201, tags=["media"])
@@ -95,10 +137,7 @@ def api_create_media(payload: schemas.MediaCreate, db: Session = Depends(get_db)
@app.get("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"])
def api_get_media(media_id: int, db: Session = Depends(get_db)):
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
return m
return _get_or_404(db, media_id)
@app.patch("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"])
@@ -107,18 +146,12 @@ def api_update_media(
payload: schemas.MediaUpdate,
db: Session = Depends(get_db),
):
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
return crud.update_media(db, m, payload)
return crud.update_media(db, _get_or_404(db, media_id), payload)
@app.delete("/api/media/{media_id}", status_code=204, tags=["media"])
def api_delete_media(media_id: int, db: Session = Depends(get_db)):
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
crud.delete_media(db, m)
crud.delete_media(db, _get_or_404(db, media_id))
return None
@@ -128,10 +161,7 @@ def api_progress(
payload: schemas.ProgressUpdate,
db: Session = Depends(get_db),
):
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
return crud.bump_progress(db, m, payload)
return crud.bump_progress(db, _get_or_404(db, media_id), payload)
# ---------- Taxonomien -------------------------------------------------------
+6 -6
View File
@@ -4,6 +4,7 @@ Zentrale Idee: ein polymorphes ``Media``-Objekt mit ``kind`` (book/series), das
optionale, mediumspezifische Felder als NULL-sparende Spalten mitführt. So
müssen wir keine separaten Tabellen für Bücher vs. Serien pflegen.
"""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from datetime import date, datetime, timezone
@@ -21,7 +22,6 @@ from sqlalchemy import (
String,
Table,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -66,7 +66,7 @@ class Tag(Base):
# ---------- Hauptentität: Media ----------------------------------------------
# Status-Werte (an MAL angelehnt, aber für Bücher + Serien nutzbar):
# Status (an MAL angelehnt, für Bücher + Serien nutzbar):
# plan -> auf der Liste / will ich lesen/schauen
# reading -> lese ich gerade (Buch) ODER watching (Serie)
# done -> fertig (abgeschlossen)
@@ -84,7 +84,7 @@ class Media(Base):
id: Mapped[int] = mapped_column(primary_key=True)
# Allgemein
kind: Mapped[str] = mapped_column(String(16), nullable=False) # book|series
kind: Mapped[str] = mapped_column(String(16), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
original_title: Mapped[Optional[str]] = mapped_column(String(200))
description: Mapped[Optional[str]] = mapped_column(Text)
@@ -97,15 +97,15 @@ class Media(Base):
# Buch-spezifisch
author: Mapped[Optional[str]] = mapped_column(String(200))
isbn: Mapped[Optional[str]] = mapped_column(String(20))
total_volumes: Mapped[Optional[int]] = mapped_column(Integer) # Bände
total_chapters: Mapped[Optional[int]] = mapped_column(Integer) # Kapitel
total_volumes: Mapped[Optional[int]] = mapped_column(Integer)
total_chapters: Mapped[Optional[int]] = mapped_column(Integer)
total_pages: Mapped[Optional[int]] = mapped_column(Integer)
volumes_read: Mapped[Optional[int]] = mapped_column(Integer, default=0)
chapters_read: Mapped[Optional[int]] = mapped_column(Integer, default=0)
pages_read: Mapped[Optional[int]] = mapped_column(Integer, default=0)
# Serien-spezifisch
network: Mapped[Optional[str]] = mapped_column(String(120)) # Sender/Streaming
network: Mapped[Optional[str]] = mapped_column(String(120))
total_seasons: Mapped[Optional[int]] = mapped_column(Integer)
total_episodes: Mapped[Optional[int]] = mapped_column(Integer)
episodes_watched: Mapped[Optional[int]] = mapped_column(Integer, default=0)
+6 -3
View File
@@ -1,4 +1,5 @@
"""Pydantic-Schemas für API-Ein- und -Ausgabe."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from datetime import date, datetime
@@ -7,15 +8,17 @@ from typing import List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
# ---------- Eingaben ---------------------------------------------------------
# ---------- Typen ------------------------------------------------------------
MediaKind = Literal["book", "series"]
MediaStatus = Literal["plan", "reading", "done", "hold", "dropped"]
# ---------- Eingaben ---------------------------------------------------------
class MediaBase(BaseModel):
kind: MediaKind
title: str = Field(..., max_length=200)
title: str = Field(..., max_length=200, min_length=1)
original_title: Optional[str] = Field(None, max_length=200)
description: Optional[str] = None
cover_url: Optional[str] = Field(None, max_length=600)
@@ -53,7 +56,7 @@ class MediaCreate(MediaBase):
class MediaUpdate(BaseModel):
"""Alle Felder optional PATCH-Semantik."""
title: Optional[str] = Field(None, max_length=200)
title: Optional[str] = Field(None, max_length=200, min_length=1)
original_title: Optional[str] = None
description: Optional[str] = None
cover_url: Optional[str] = None
+3 -2
View File
@@ -1,4 +1,5 @@
"""Legt ein paar Demo-Datensätze an, falls die DB leer ist."""
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import logging
@@ -6,7 +7,7 @@ from datetime import date
from sqlalchemy.orm import Session
from app import crud, schemas
from app import crud, models, schemas
log = logging.getLogger(__name__)
@@ -120,7 +121,7 @@ DEMO_SERIES = [
def seed_if_empty(db: Session) -> None:
if db.query(__import__("app").models.Media).count() > 0: # type: ignore[attr-defined]
if db.query(models.Media).count() > 0:
return
log.info("Leere DB lege Demo-Daten an.")
for spec in DEMO_BOOKS:
+45
View File
@@ -96,6 +96,22 @@ a { color: var(--accent-2); text-decoration: none; }
}
.brand-text p { margin: 2px 0 0; color: var(--text-dim); font-size: 14px; }
.version-badge {
display: inline-block;
margin-left: 8px;
vertical-align: middle;
font-size: 10px;
font-weight: 800;
letter-spacing: .1em;
text-transform: uppercase;
padding: 3px 7px;
border-radius: 999px;
background: linear-gradient(135deg, rgba(245,158,11,.25), rgba(239,68,68,.25));
color: #fbbf24;
border: 1px solid rgba(245,158,11,.4);
-webkit-text-fill-color: #fbbf24;
}
.hero-search {
display: flex; gap: 10px; flex-wrap: wrap;
align-items: center;
@@ -224,6 +240,22 @@ a { color: var(--accent-2); text-decoration: none; }
margin-top: 14px;
}
.grid-loading {
grid-column: 1 / -1;
display: flex; align-items: center; justify-content: center;
gap: 12px;
padding: 60px 20px;
color: var(--text-dim);
}
.spinner {
width: 22px; height: 22px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.card {
background: var(--bg-elev);
border: 1px solid var(--border);
@@ -541,3 +573,16 @@ body.kind-series .row[data-when="series"] { display: flex; }
.status-tabs { padding: 4px 12px 0; }
.tab { padding: 10px 10px; font-size: 13px; }
}
/* ===================== Footer ===================== */
.footer {
max-width: var(--container);
margin: 0 auto;
padding: 24px 24px 40px;
display: flex; justify-content: space-between; align-items: center;
color: var(--text-muted);
font-size: 12px;
border-top: 1px solid var(--border);
margin-top: 40px;
}
.footer span:last-child { color: var(--text-dim); }
+10 -2
View File
@@ -15,7 +15,7 @@
<div class="brand">
<div class="logo">W</div>
<div class="brand-text">
<h1>WatchStack</h1>
<h1>WatchStack <span class="version-badge">beta</span></h1>
<p>Deine Watchlist für <strong>Bücher</strong>, <strong>Serien</strong> & mehr.</p>
</div>
</div>
@@ -74,7 +74,10 @@
<!-- Listen-Grid -->
<section id="grid" class="grid">
<!-- Cards per JS -->
<div class="grid-loading">
<div class="spinner"></div>
<span>Lade Watchlist…</span>
</div>
</section>
<div id="empty-state" class="empty hidden">
@@ -219,6 +222,11 @@
</div>
</div>
<footer class="footer">
<span>WatchStack <span id="version"></span> · Apache 2.0</span>
<span>Made for readers &amp; binge-watchers</span>
</footer>
<!-- ===================== Toast ===================== -->
<div id="toast" class="toast" hidden></div>
+9
View File
@@ -510,6 +510,15 @@
$$('[data-close-stats]').forEach(b => b.addEventListener('click', () => { $('#modal-stats').hidden = true; }));
}
// Version aus /api/health in den Footer schreiben
(async () => {
try {
const h = await api.get('/api/health');
const el = $('#version');
if (el && h.version) el.textContent = 'v' + h.version;
} catch (_) { /* silent */ }
})();
document.addEventListener('DOMContentLoaded', () => {
wire();
loadAll();