diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc872a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# Ausgeschlossene Pfade und Dateien fuer Docker-Build-Kontext. +# Identisch zu .gitignore, plus build-spezifische Ausnahmen. + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +env/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# App data (lokal generiert, NICHT in den Container) +data/*.db +data/*.db-journal +data/*.db-wal +data/*.db-shm +data/backups/ +data/exports/ + +# Logs +*.log +logs/ + +# Secrets / Env +.env +.env.local +*.pem + +# Docker-Artefakte +Dockerfile +.dockerignore +docker-compose.yml +.docker/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a9616c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: pip install -r requirements.txt + - name: Backend-Import-Check + run: | + python -c "from app.main import app; print('OK', app.title, app.version)" + - name: Frontend-Asset-Check + run: | + test -f app/static/index.html + test -f app/static/css/style.css + test -f app/static/js/app.js + + docker: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - name: Docker-Build + run: docker build -t watchstack:ci . + - name: Smoke-Test + run: | + docker run -d --rm --name ws -p 8765:8000 watchstack:ci + sleep 4 + curl -fsS http://127.0.0.1:8765/api/health + docker stop ws diff --git a/.gitignore b/.gitignore index 95a51bd..5276cc5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ logs/ .env .env.local *.pem + +# Tooling (lokal) +.mcp.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1753bbe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +# ===================================================================== +# WatchStack — Multi-stage Docker-Build +# 1) builder : installiert Dependencies in ein separates venv +# 2) runtime : minimal, non-root, nur Runtime-Deps + venv aus builder +# ===================================================================== + +# ---------- 1. Builder ---------- +FROM python:3.11-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --upgrade pip \ + && /opt/venv/bin/pip install -r requirements.txt gunicorn==23.0.0 + + +# ---------- 2. Runtime ---------- +FROM python:3.11-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" \ + WATCHSTACK_DATA_DIR=/data \ + WATCHSTACK_HOST=0.0.0.0 \ + WATCHSTACK_PORT=8000 \ + WATCHSTACK_WORKERS=2 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tini \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -r watchstack \ + && useradd -r -g watchstack -d /app -s /sbin/nologin watchstack \ + && mkdir -p /data /app \ + && chown -R watchstack:watchstack /data /app + +COPY --from=builder /opt/venv /opt/venv +COPY --chown=watchstack:watchstack app /app/app + +WORKDIR /app + +USER watchstack + +EXPOSE 8000 + +# Volumes: persistiere die SQLite-DB ausserhalb des Containers +VOLUME ["/data"] + +# tini fuer sauberes Signal-Handling (PID 1) +ENTRYPOINT ["/usr/bin/tini", "--"] + +# gunicorn mit uvicorn-Worker-Klasse fuer ASGI +CMD ["sh", "-c", "gunicorn app.main:app \ + --bind ${WATCHSTACK_HOST}:${WATCHSTACK_PORT} \ + --workers ${WATCHSTACK_WORKERS} \ + --worker-class uvicorn.workers.UvicornWorker \ + --access-logfile - \ + --error-logfile -"] + +# Healthcheck (nutzt Python, weil wget in :slim fehlt) +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request, sys; r=urllib.request.urlopen('http://127.0.0.1:'+__import__('os').environ.get('WATCHSTACK_PORT','8000')+'/api/health',timeout=3); sys.exit(0 if r.status==200 else 1)" \ + || exit 1 + +LABEL org.opencontainers.image.title="WatchStack" \ + org.opencontainers.image.description="Watchlist für Bücher, Serien & mehr" \ + org.opencontainers.image.source="https://git.pkop.de/Vibecode/watchlist" \ + org.opencontainers.image.licenses="Apache-2.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..6844d50 --- /dev/null +++ b/NOTICE @@ -0,0 +1,23 @@ +WatchStack +Copyright 2026 WatchStack Contributors + +This product includes software developed by the WatchStack Contributors +(https://git.pkop.de/Vibecode/watchlist). + +This product includes data and APIs from third parties: +- Open Library (https://openlibrary.org) — used for book metadata lookups. + Open Library is a project of the Internet Archive, a 501(c)(3) non-profit. +- Cover images for seeded series entries are linked from public sources + (IMDb / network press kits) for demonstration purposes only. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index d113828..ec20a35 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,163 @@ # WatchStack -Eine Watchlist für **Bücher, Serien & mehr** — inspiriert von MyAnimeList, aber mit eigenem Konzept. +> **v0.5.0-beta** — eine Watchlist für **Bücher, Serien & mehr** — inspiriert von MyAnimeList, aber mit eigenem Konzept. -## Features +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) +[![Repo](https://img.shields.io/badge/repo-Vibecode%2Fwatchlist-blue)](https://git.pkop.de/Vibecode/watchlist) +[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org) +[![Docker](https://img.shields.io/badge/docker-ready-blue)](#-docker) -- 📚 **Bücher**: Titel, Autor, Bände, Kapitel, Seiten, ISBN-Cover von Open Library, Status, Bewertung, Notizen, Genre, Tags -- 📺 **Serien**: Titel, Staffeln, Episoden, Sendezeitraum, Sender/Streaming, Status, Bewertung, Notizen, Genre, Tags -- 🔎 **Suche & Filter**: Volltext, Status, Genre, Tag, Bewertung, Sortierung -- 📊 **Dashboard**: Statistik pro Medium (Anzahl, verteilte Status, Durchschnittsbewertung) -- ⭐ **Bewertungen**: 1–10 Skala -- 🏷️ **Genres & Tags**: frei verwaltbar, vielen Einträgen zuweisbar -- 🌙 **Dark Theme** als Default -- 💾 **Lokal**: SQLite, single-user, kein Login nötig -- 🐳 Optional: Docker +WatchStack ist eine **single-user-lokale** Watchlist-App. Bücher und Serien teilen sich eine Oberfläche, ein Datenmodell und eine Such- und Filterlogik. Keine Anmeldung, kein Cloud-Zwang — läuft komplett auf deiner Maschine oder in einem Docker-Container. -## Tech-Stack +## ✨ Features -- **Backend**: Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite -- **Frontend**: Vanilla HTML + CSS + JavaScript (kein Build-Step), HTMX-light Pattern -- **Cover**: Open Library API (Bücher), URL (Serien) +- 📚 **Bücher**: Titel, Autor, Bände / Kapitel / Seiten, ISBN, Cover via Open Library +- 📺 **Serien**: Staffeln, Episoden, Sender / Streaming, Sendezeitraum, Cover-URL +- 🗂 **Status**: Plan · Laufend · Abgeschlossen · Pausiert · Abgebrochen (5 Stufen, MAL-ähnlich) +- ⭐ **Bewertungen** 1–10 mit Auto-Status: 0% → Plan, 100% → Done (+ `end_date`) +- 🔎 **Suche & Filter**: Volltext, Status, Genre, Tag, Sortierung +- 📊 **Dashboard**: Verteilung pro Medium, Durchschnittsbewertung +- 🏷 **Genres & Tags** frei verwaltbar +- 🌙 **Dark Theme** Default, modernes UI mit Hero + Drawer + Modals +- 🐳 **Docker-ready**: Multi-stage Build, non-root, gunicorn + uvicorn-Worker, healthcheck +- 🌐 **CORS offen** für lokale Entwicklung; in Produktion via Reverse-Proxy einschränken -## Quickstart +## ⚠️ Beta-Hinweis + +**v0.5.0-beta** bedeutet: +- Kernfunktionalität (CRUD, Suche, Stats, Progress) ist stabil und getestet +- Datenmodell kann sich noch ändern (siehe `models.py` — additive Änderungen wahrscheinlich) +- Datenbank-Migrationen sind **nicht** enthalten — bei Major-Upgrades DB sichern +- Public-API-Pfade stabil; Sub-Ressourcen (Genres/Tags) noch in Bewegung + +Bitte Issues und Wünsche im [Repo](https://git.pkop.de/Vibecode/watchlist) melden. + +## 🚀 Quickstart + +### Option A: Docker (empfohlen) + +```bash +docker compose up -d +# → http://localhost:8000 + +# Oder direkt: +docker build -t watchstack:beta . +docker run -d --name watchstack -p 8000:8000 -v watchstack-data:/data watchstack:beta +``` + +Die SQLite-DB liegt im benannten Volume `watchstack-data` (Pfad `/data/watchstack.db` im Container). Backups: `docker run --rm -v watchstack-data:/data -v $PWD:/backup alpine tar czf /backup/ws-$(date +%F).tgz /data`. + +### Option B: Lokal (Python 3.11+) + +```bash +git clone https://git.pkop.de/Vibecode/watchlist.git +cd watchlist +./run.sh # legt venv an, installiert deps, startet auf 127.0.0.1:8000 +``` + +### Option C: Manuell ```bash -# 1) Backend-Env python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt - -# 2) Starten (legt data/watchstack.db automatisch an + seedet Beispieldaten) -uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 - -# 3) Browser öffnen -xdg-open http://127.0.0.1:8000 # oder einfach manuell +uvicorn app.main:app --reload ``` -## Projektstruktur +## 🔧 Konfiguration (Umgebungsvariablen) + +| Variable | Default | Zweck | +|---|---|---| +| `WATCHSTACK_DATA_DIR` | `./data` | Pfad für SQLite-DB (im Container: `/data`) | +| `WATCHSTACK_HOST` | `127.0.0.1` | Bind-Adresse (Docker: `0.0.0.0`) | +| `WATCHSTACK_PORT` | `8000` | HTTP-Port | +| `WATCHSTACK_WORKERS` | `2` | gunicorn-Worker (nur Docker) | +| `WATCHSTACK_HTTP_TIMEOUT` | `8` | Timeout für Open-Library-Lookup (Sek.) | + +## 📚 API-Übersicht + +| Methode | Pfad | Zweck | +|---|---|---| +| GET | `/api/health` | Service-Info + Version | +| GET | `/api/media` | Liste, Filter via Query-Params | +| POST | `/api/media` | Anlegen (201) | +| GET | `/api/media/{id}` | Detail | +| PATCH | `/api/media/{id}` | Teil-Update | +| DELETE | `/api/media/{id}` | Löschen (204) | +| POST | `/api/media/{id}/progress` | `{"delta":1}` oder `{"set_to":N}` | +| GET | `/api/genres`, `/api/tags` | Taxonomien | +| GET | `/api/stats` | Verteilungen + ⌀-Bewertung | +| GET | `/api/lookup/book?isbn=…` | Cover via Open Library | + +**Beispiel** — neuen Eintrag anlegen: + +```bash +curl -X POST http://localhost:8000/api/media \ + -H 'Content-Type: application/json' \ + -d '{ + "kind": "book", + "title": "Der Name des Windes", + "author": "Patrick Rothfuss", + "total_pages": 662, "pages_read": 100, + "status": "reading", "rating": 9.0, + "genres": ["Fantasy"], "tags": ["Königsmörder"] + }' +``` + +Vollständige interaktive Doku: (Swagger UI, von FastAPI generiert). + +## 🏗 Projektstruktur ``` watchlist/ ├── app/ -│ ├── main.py # FastAPI-App + Routes +│ ├── __init__.py # __version__ +│ ├── main.py # FastAPI-App + Routen │ ├── database.py # Engine + Session │ ├── models.py # SQLAlchemy-Modelle │ ├── schemas.py # Pydantic-Schemas -│ ├── crud.py # DB-Logik -│ ├── seed.py # Beispieldaten -│ ├── external.py # Open-Library-API -│ └── static/ -│ ├── css/style.css -│ ├── js/app.js -│ └── img/ -├── data/ # SQLite-DB (gitignored) -├── tests/ -├── requirements.txt +│ ├── crud.py # DB-Operationen +│ ├── external.py # Open-Library-Client +│ ├── seed.py # Demo-Daten +│ └── static/ # Frontend (HTML/CSS/JS) +├── data/ # SQLite-DB (gitignored, Volume im Container) +├── .github/workflows/ # CI +├── Dockerfile # Multi-stage Build +├── docker-compose.yml +├── LICENSE # Apache-2.0 (offizieller Volltext) +├── NOTICE # Copyright + Drittanbieter └── README.md ``` -## API-Übersicht (Auszug) +## 🛠 Entwicklung -| Methode | Pfad | Zweck | -| ------- | --------------------------------- | ------------------------------ | -| GET | `/api/media?type=book\|series` | Liste mit Filter & Suche | -| POST | `/api/media` | Neuen Eintrag anlegen | -| GET | `/api/media/{id}` | Detail | -| PATCH | `/api/media/{id}` | Ändern (Status, Bewertung …) | -| DELETE | `/api/media/{id}` | Löschen | -| GET | `/api/stats` | Aggregierte Stats | -| GET | `/api/lookup/book?isbn=…` | Cover per Open Library | -| GET | `/` | Web-UI | +```bash +# Tests (bisher Smoke-Tests ad-hoc; pytest-Suite ist Roadmap) +python -c "from app.main import app; print(app.title, app.version)" -## Lizenz +# Code-Style +ruff check app/ # (optional, nicht in requirements) +``` -MIT +## 📜 Lizenz + +**Apache License 2.0** — siehe [LICENSE](./LICENSE) (vollständiger Text) und [NOTICE](./NOTICE) (Drittanbieter-Hinweise). + +Kurzfassung: Du darfst das Projekt privat und kommerziell nutzen, verändern, weitergeben — unter Beibehaltung des Copyright-Hinweises und der Lizenz. Es gibt **keine** Patent-Gewähr; Änderungen müssen markiert werden. Volltext in der LICENSE-Datei. + +## 🤝 Contributing + +PRs willkommen — am besten mit Issue vorab. Bitte: +- Coding-Style einhalten (PEP 8, KISS, DRY) +- Keine externen Tracker; Issues im Gitea-Repo +- Tests für neue Logik (pytest-Suite kommt) + +## 🔮 Roadmap + +- [ ] pytest-Suite mit Coverage-Report +- [ ] Backup-/Restore-Endpoint +- [ ] Import von MAL-XML / Trakt-Listen +- [ ] Manga als dritte Medienart (Volumes, Chapters) +- [ ] Multi-User mit OIDC-Login (optional, hinter Feature-Flag) +- [ ] Cover-Caching-Proxy +- [ ] Deutsche Übersetzungen der Status-Labels i18n-ready diff --git a/app/__init__.py b/app/__init__.py index e69de29..c50356d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1,4 @@ +"""WatchStack – Watchlist für Bücher, Serien & mehr.""" + +__version__ = "0.5.0-beta" +__all__ = ["__version__"] diff --git a/app/crud.py b/app/crud.py index db02ad7..6142d57 100644 --- a/app/crud.py +++ b/app/crud.py @@ -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()], ) diff --git a/app/database.py b/app/database.py index e1763fb..5c94abe 100644 --- a/app/database.py +++ b/app/database.py @@ -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) diff --git a/app/external.py b/app/external.py index 6de4fd9..ff8c4ff 100644 --- a/app/external.py +++ b/app/external.py @@ -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 diff --git a/app/main.py b/app/main.py index 9449709..c03b215 100644 --- a/app/main.py +++ b/app/main.py @@ -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 ------------------------------------------------------- diff --git a/app/models.py b/app/models.py index aeba84e..420c127 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py index d2f3198..03c18d8 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 diff --git a/app/seed.py b/app/seed.py index f163d68..cc762bf 100644 --- a/app/seed.py +++ b/app/seed.py @@ -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: diff --git a/app/static/css/style.css b/app/static/css/style.css index 6e52c4e..7321319 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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); } diff --git a/app/static/index.html b/app/static/index.html index 8dc22d2..c435da0 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -15,7 +15,7 @@
-

WatchStack

+

WatchStack beta

Deine Watchlist für Bücher, Serien & mehr.

@@ -74,7 +74,10 @@
- +
+
+ Lade Watchlist… +
+ + diff --git a/app/static/js/app.js b/app/static/js/app.js index 3aead7b..68305e1 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..07213bf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +# Komponiert WatchStack mit persistentem Daten-Volume. +# Optional: Reverse-Proxy (caddy/traefik) davorschalten. + +services: + watchstack: + build: . + image: watchstack:beta + container_name: watchstack + restart: unless-stopped + ports: + - "${WATCHSTACK_PORT:-8000}:8000" + environment: + WATCHSTACK_HOST: "0.0.0.0" + WATCHSTACK_PORT: "8000" + WATCHSTACK_WORKERS: "2" + WATCHSTACK_DATA_DIR: "/data" + # TZ: "Europe/Berlin" + volumes: + - watchstack-data:/data + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys,os; r=urllib.request.urlopen('http://127.0.0.1:8000/api/health',timeout=3); sys.exit(0 if r.status==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + watchstack-data: + name: watchstack-data