From 3f07c78316fa3aeb274fa904d58d03ba38806053 Mon Sep 17 00:00:00 2001 From: Tronax Date: Mon, 15 Jun 2026 02:02:25 +0200 Subject: [PATCH] feat: initial commit for media tracking software - Setup C++/Qt6 desktop application structure - Added initial Go proxy backend for TMDB/IGDB integration --- .gitignore | 37 + CMakeLists.txt | 104 +++ README.md | 83 +++ resources/icons/appicon.ico | Bin 0 -> 370070 bytes resources/icons/appicon.png | Bin 0 -> 9754 bytes resources/icons/appicon.svg | 16 + resources/icons/check.svg | 1 + resources/icons/placeholder.svg | 6 + .../linux/ultimate-media-tracker.desktop | 8 + resources/resources.qrc | 7 + resources/win/app.rc | 1 + server/.env.example | 33 + server/Dockerfile | 14 + server/README.md | 69 ++ server/config.go | 89 +++ server/go.mod | 13 + server/go.sum | 18 + server/main.go | 318 ++++++++ server/token.go | 68 ++ src/core/Database.cpp | 677 ++++++++++++++++++ src/core/Database.h | 96 +++ src/core/Enums.h | 148 ++++ src/core/MediaItem.cpp | 8 + src/core/MediaItem.h | 234 ++++++ src/core/Settings.cpp | 107 +++ src/core/Settings.h | 71 ++ src/main.cpp | 73 ++ src/providers/AniListProvider.cpp | 130 ++++ src/providers/AniListProvider.h | 25 + src/providers/ImageCache.cpp | 125 ++++ src/providers/ImageCache.h | 50 ++ src/providers/MetadataProvider.cpp | 6 + src/providers/MetadataProvider.h | 62 ++ src/providers/OpenLibraryProvider.cpp | 75 ++ src/providers/OpenLibraryProvider.h | 24 + src/providers/ProviderManager.cpp | 74 ++ src/providers/ProviderManager.h | 45 ++ src/providers/ProxyLogin.cpp | 155 ++++ src/providers/ProxyLogin.h | 43 ++ src/providers/RawgProvider.cpp | 75 ++ src/providers/RawgProvider.h | 30 + src/providers/TmdbProvider.cpp | 133 ++++ src/providers/TmdbProvider.h | 56 ++ src/ui/DetailDialog.cpp | 404 +++++++++++ src/ui/DetailDialog.h | 78 ++ src/ui/EditDialog.cpp | 494 +++++++++++++ src/ui/EditDialog.h | 83 +++ src/ui/FilterPanel.cpp | 163 +++++ src/ui/FilterPanel.h | 47 ++ src/ui/FlowLayout.cpp | 109 +++ src/ui/FlowLayout.h | 40 ++ src/ui/MainWindow.cpp | 379 ++++++++++ src/ui/MainWindow.h | 82 +++ src/ui/MediaCard.cpp | 199 +++++ src/ui/MediaCard.h | 58 ++ src/ui/OnlineSearchDialog.cpp | 154 ++++ src/ui/OnlineSearchDialog.h | 55 ++ src/ui/SettingsDialog.cpp | 253 +++++++ src/ui/SettingsDialog.h | 47 ++ src/ui/StarRating.cpp | 120 ++++ src/ui/StarRating.h | 38 + src/ui/ThemeManager.cpp | 304 ++++++++ src/ui/ThemeManager.h | 33 + 63 files changed, 6547 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 resources/icons/appicon.ico create mode 100644 resources/icons/appicon.png create mode 100644 resources/icons/appicon.svg create mode 100644 resources/icons/check.svg create mode 100644 resources/icons/placeholder.svg create mode 100644 resources/linux/ultimate-media-tracker.desktop create mode 100644 resources/resources.qrc create mode 100644 resources/win/app.rc create mode 100644 server/.env.example create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/config.go create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/main.go create mode 100644 server/token.go create mode 100644 src/core/Database.cpp create mode 100644 src/core/Database.h create mode 100644 src/core/Enums.h create mode 100644 src/core/MediaItem.cpp create mode 100644 src/core/MediaItem.h create mode 100644 src/core/Settings.cpp create mode 100644 src/core/Settings.h create mode 100644 src/main.cpp create mode 100644 src/providers/AniListProvider.cpp create mode 100644 src/providers/AniListProvider.h create mode 100644 src/providers/ImageCache.cpp create mode 100644 src/providers/ImageCache.h create mode 100644 src/providers/MetadataProvider.cpp create mode 100644 src/providers/MetadataProvider.h create mode 100644 src/providers/OpenLibraryProvider.cpp create mode 100644 src/providers/OpenLibraryProvider.h create mode 100644 src/providers/ProviderManager.cpp create mode 100644 src/providers/ProviderManager.h create mode 100644 src/providers/ProxyLogin.cpp create mode 100644 src/providers/ProxyLogin.h create mode 100644 src/providers/RawgProvider.cpp create mode 100644 src/providers/RawgProvider.h create mode 100644 src/providers/TmdbProvider.cpp create mode 100644 src/providers/TmdbProvider.h create mode 100644 src/ui/DetailDialog.cpp create mode 100644 src/ui/DetailDialog.h create mode 100644 src/ui/EditDialog.cpp create mode 100644 src/ui/EditDialog.h create mode 100644 src/ui/FilterPanel.cpp create mode 100644 src/ui/FilterPanel.h create mode 100644 src/ui/FlowLayout.cpp create mode 100644 src/ui/FlowLayout.h create mode 100644 src/ui/MainWindow.cpp create mode 100644 src/ui/MainWindow.h create mode 100644 src/ui/MediaCard.cpp create mode 100644 src/ui/MediaCard.h create mode 100644 src/ui/OnlineSearchDialog.cpp create mode 100644 src/ui/OnlineSearchDialog.h create mode 100644 src/ui/SettingsDialog.cpp create mode 100644 src/ui/SettingsDialog.h create mode 100644 src/ui/StarRating.cpp create mode 100644 src/ui/StarRating.h create mode 100644 src/ui/ThemeManager.cpp create mode 100644 src/ui/ThemeManager.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fafcdb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# ---- CMake / C++ build ---- +/build/ +build*/ +cmake-build-*/ +CMakeCache.txt +CMakeFiles/ +CMakeUserPresets.json +*.o +*.obj +*.a +*.so +*.dylib + +# Qt generated (AUTOMOC/AUTOUIC/AUTORCC) +moc_*.cpp +ui_*.h +qrc_*.cpp +*.moc + +# ---- Go server ---- +server/umt-tmdb-proxy +server/*.exe + +# Secrets / local config (keep .env.example tracked) +.env +server/.env +*.local + +# ---- Editors / IDEs ---- +.vscode/ +.idea/ +*.swp +*~ + +# ---- OS ---- +.DS_Store +Thumbs.db diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..89b7362 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,104 @@ +cmake_minimum_required(VERSION 3.21) + +project(UltimateMediaTracker + VERSION 1.0.0 + DESCRIPTION "Track movies, series, mangas, books and games" + LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +find_package(Qt6 REQUIRED COMPONENTS Widgets Network Sql Svg Concurrent) + +qt_standard_project_setup() + +set(SOURCES + src/main.cpp + + src/core/Enums.h + src/core/MediaItem.h + src/core/MediaItem.cpp + src/core/Database.h + src/core/Database.cpp + src/core/Settings.h + src/core/Settings.cpp + + src/providers/MetadataProvider.h + src/providers/MetadataProvider.cpp + src/providers/TmdbProvider.h + src/providers/TmdbProvider.cpp + src/providers/OpenLibraryProvider.h + src/providers/OpenLibraryProvider.cpp + src/providers/AniListProvider.h + src/providers/AniListProvider.cpp + src/providers/RawgProvider.h + src/providers/RawgProvider.cpp + src/providers/ProviderManager.h + src/providers/ProviderManager.cpp + src/providers/ProxyLogin.h + src/providers/ProxyLogin.cpp + src/providers/ImageCache.h + src/providers/ImageCache.cpp + + src/ui/ThemeManager.h + src/ui/ThemeManager.cpp + src/ui/MainWindow.h + src/ui/MainWindow.cpp + src/ui/MediaCard.h + src/ui/MediaCard.cpp + src/ui/FlowLayout.h + src/ui/FlowLayout.cpp + src/ui/FilterPanel.h + src/ui/FilterPanel.cpp + src/ui/DetailDialog.h + src/ui/DetailDialog.cpp + src/ui/EditDialog.h + src/ui/EditDialog.cpp + src/ui/OnlineSearchDialog.h + src/ui/OnlineSearchDialog.cpp + src/ui/SettingsDialog.h + src/ui/SettingsDialog.cpp + src/ui/StarRating.h + src/ui/StarRating.cpp + + resources/resources.qrc +) + +# Embed a Windows .ico into the executable (shown in Explorer / taskbar). +if(WIN32) + list(APPEND SOURCES resources/win/app.rc) +endif() + +# WIN32: no console window on Windows. MACOSX_BUNDLE: produce a .app on macOS. +qt_add_executable(umt WIN32 MACOSX_BUNDLE ${SOURCES}) + +target_include_directories(umt PRIVATE src) + +target_link_libraries(umt PRIVATE + Qt6::Widgets + Qt6::Network + Qt6::Sql + Qt6::Svg + Qt6::Concurrent +) + +install(TARGETS umt + BUNDLE DESTINATION . + RUNTIME DESTINATION bin) + +# Linux desktop integration: install a .desktop launcher and the app icon so it +# shows up in menus with a proper icon. +if(UNIX AND NOT APPLE) + install(FILES resources/linux/ultimate-media-tracker.desktop + DESTINATION share/applications) + install(FILES resources/icons/appicon.png + DESTINATION share/icons/hicolor/256x256/apps + RENAME ultimate-media-tracker.png) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..94952a3 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Ultimate Media Tracker + +Ein hochgradig anpassbarer Tracker für **Filme, Serien, Mangas, Bücher und +Spiele** – geschrieben in C++17 mit **Qt 6**. Behalte den Überblick darüber, was +du gesehen/gelesen/gespielt hast, und zwar auf jeder Ebene: ganzes Werk, +Staffel/Band oder einzelne Folge/Kapitel. + +## Features + +- **5 Medientypen**: Filme, Serien, Mangas, Bücher, Spiele +- **Granularer Fortschritt**: komplett · pro Staffel/Band · pro Folge/Kapitel + (tri-state Häkchen-Baum, automatische „Abgeschlossen“-Erkennung) +- **Automatische Titelbilder** über Online-Provider, jederzeit per integrierter + Suche austauschbar oder durch eigene Datei ersetzbar +- **Mehrsprachige Titel** pro Eintrag (de/en/ja/…), inkl. Originaltitel +- **Online-Metadaten-Provider** + - Filme & Serien: **TMDB** (kostenloser API-Key nötig) + - Bücher: **OpenLibrary** (kein Key) + - Mangas: **AniList** (kein Key) +- **Umfangreiche Filter**: Genre, Status, Bewertung, Jahr, Tags, Favoriten, + **Franchise** (automatisch aus den Titeln erkannt, manuell überschreibbar), + Volltextsuche, plus Sortierung (Datum/Titel/Bewertung/Jahr/Fortschritt) +- **Geteilter TMDB-Zugang** optional über einen **OIDC-Proxy** (siehe + `server/`): die Gruppe teilt sich einen API-Key, Login via Authentik +- **Customizability**: eigene Tags, eigene Felder (Key/Value), eigene + Strukturen (beliebige Abschnitte & Einheiten), anpassbare Kartengröße +- **Modernes GUI** mit **Dark- & Light-Mode** und frei wählbarer **Akzentfarbe** +- **Bewertung** als halbe-Sterne-Widget, Favoriten, Notizen, Beschreibung +- Lokale **SQLite**-Datenbank, Cover-Cache auf Platte + +## Bauen + +Voraussetzungen: Qt 6 (Widgets, Network, Sql, Svg, Concurrent), CMake ≥ 3.21, +ein C++17-Compiler, SQLite-Treiber (Teil von Qt). + +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j +./build/uwt +``` + +Identisch unter **Linux und Windows** (sowie macOS). Unter Windows wird das +Programm als GUI-App ohne Konsolenfenster gebaut und bekommt das App-Icon +eingebettet; unter Linux installiert `cmake --install build` zusätzlich einen +`.desktop`-Eintrag samt Icon. + +## Konfiguration + +Öffne **⚙ Einstellungen**: +- **Design**: Dunkel/Hell +- **Akzentfarbe**: frei wählbar +- **Bevorzugte Sprache**: bestimmt angezeigte Titel & Provider-Sprache +- **Filme/Serien über**: entweder **eigener TMDB API-Key** (kostenlos auf + themoviedb.org) **oder** ein **Proxy-Server** (URL + Token von dessen + Login-Seite – kein eigener Key nötig, siehe `server/`) +- **Cover automatisch laden**, **Kartengröße** + +## TMDB-Proxy (optional) + +Im Verzeichnis `server/` liegt ein kleiner Go-Server, der die TMDB-API hinter +**OIDC/Authentik** kapselt. So können Freunde die App nutzen, ohne sich je einen +eigenen TMDB-Key zu erstellen – der Server hält einen geteilten Key und gibt +nach dem Login ein persönliches Token aus. Details: `server/README.md`. + +## Daten + +- Datenbank: `…/AppData/UMT/Ultimate Media Tracker/library.db` +- Cover-Bibliothek: `…/AppData/…/covers/` +- (Pfade gemäß `QStandardPaths::AppDataLocation`) + +## Architektur + +``` +src/ + core/ Enums, MediaItem (Werk→Abschnitt→Einheit), Database (SQLite), Settings + providers/ MetadataProvider-Interface + TMDB/OpenLibrary/AniList, ImageCache + ui/ ThemeManager, MainWindow, MediaCard, FilterPanel, FlowLayout, + StarRating, DetailDialog, EditDialog, OnlineSearchDialog, SettingsDialog +``` + +Das Datenmodell ist generisch: Jedes Werk besteht aus *Abschnitten* (Staffel / +Band / Teil), die *Einheiten* (Folge / Kapitel) enthalten. Filme/Bücher ohne +Struktur nutzen einfach den Status `Abgeschlossen`. diff --git a/resources/icons/appicon.ico b/resources/icons/appicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7abf2125ae802db53e4c4e669f2ed123479b8212 GIT binary patch literal 370070 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 + + + + + + + + + + + + + + diff --git a/resources/icons/check.svg b/resources/icons/check.svg new file mode 100644 index 0000000..0300421 --- /dev/null +++ b/resources/icons/check.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/placeholder.svg b/resources/icons/placeholder.svg new file mode 100644 index 0000000..3db9981 --- /dev/null +++ b/resources/icons/placeholder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/linux/ultimate-media-tracker.desktop b/resources/linux/ultimate-media-tracker.desktop new file mode 100644 index 0000000..d672c2d --- /dev/null +++ b/resources/linux/ultimate-media-tracker.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Ultimate Media Tracker +Comment=Track movies, series, mangas, books and games +Exec=umt +Icon=ultimate-media-tracker +Terminal=false +Categories=Utility;AudioVideo;Database; diff --git a/resources/resources.qrc b/resources/resources.qrc new file mode 100644 index 0000000..b1fb111 --- /dev/null +++ b/resources/resources.qrc @@ -0,0 +1,7 @@ + + + icons/check.svg + icons/placeholder.svg + icons/appicon.svg + + diff --git a/resources/win/app.rc b/resources/win/app.rc new file mode 100644 index 0000000..7e5566d --- /dev/null +++ b/resources/win/app.rc @@ -0,0 +1 @@ +IDI_ICON1 ICON "../icons/appicon.ico" diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..138f0ea --- /dev/null +++ b/server/.env.example @@ -0,0 +1,33 @@ +# --- umt-tmdb-proxy configuration --------------------------------------- +# Copy to .env and fill in. All values are read from the environment. + +# Where the server listens (inside the container / on the host). +LISTEN_ADDR=:8080 + +# Externally reachable base URL of this proxy (no trailing slash). +# This is also what users enter as "Proxy-URL" in the desktop app. +PUBLIC_URL=https://tmdb.example.com + +# The single shared TMDB v3 API key the whole group will use. +TMDB_API_KEY=your_tmdb_v3_key + +# --- Authentik / OIDC --------------------------------------------------- +# Issuer is the OIDC provider URL from your Authentik application +# (e.g. https://auth.example.com/application/o/watchlist-proxy/). +OIDC_ISSUER=https://auth.example.com/application/o/watchlist-proxy/ +OIDC_CLIENT_ID=your_client_id +OIDC_CLIENT_SECRET=your_client_secret +# Must exactly match a Redirect URI configured in Authentik. +# Defaults to PUBLIC_URL + /callback if left empty. +OIDC_REDIRECT_URL=https://tmdb.example.com/callback + +# Optional: restrict access to members of these Authentik groups +# (comma-separated). Requires a "groups" scope/claim mapping in Authentik. +# ALLOWED_GROUPS=watchlist,friends + +# A long random secret used to sign issued access tokens. KEEP IT PRIVATE. +# Generate with: openssl rand -base64 48 +SESSION_SECRET=change-me-to-a-long-random-string + +# How long an issued desktop-app token stays valid (in days). +TOKEN_TTL_DAYS=90 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..4d81517 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,14 @@ +# --- build stage --- +FROM golang:1.22-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /umt-tmdb-proxy . + +# --- run stage --- +FROM gcr.io/distroless/static-debian12 +COPY --from=build /umt-tmdb-proxy /umt-tmdb-proxy +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/umt-tmdb-proxy"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..3be9316 --- /dev/null +++ b/server/README.md @@ -0,0 +1,69 @@ +# UMT TMDB Proxy + +A small Go server that lets a group share **one** TMDB API key. Access is gated +by **OIDC via Authentik**: each user signs in once and receives a personal token +that they paste into the Ultimate Media Tracker desktop app. + +``` +desktop app ──Bearer token──▶ proxy ──?api_key=SHARED──▶ api.themoviedb.org + ▲ + └── login gated by Authentik (OIDC) +``` + +## How it works + +1. A user opens the proxy URL and clicks **Mit Authentik anmelden**. +2. The server runs the OIDC Authorization-Code + PKCE flow against Authentik and + verifies the returned ID token (signature, issuer, audience, expiry). +3. On success it mints a stateless, HMAC-signed token and shows it on a page. +4. The user enters the proxy URL + token into the app (*Einstellungen → Filme/ + Serien über → Proxy-Server*). +5. Every `/3/...` request from the app is validated and forwarded to TMDB with + the shared key appended. (Poster images come straight from the public + `image.tmdb.org` and need no key.) + +## Configure + +Copy `.env.example` to `.env` and fill it in. Required: `TMDB_API_KEY`, +`OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `SESSION_SECRET`. + +### Authentik setup + +Create an **OAuth2/OpenID Provider** + **Application**: + +- Client type: **Confidential** +- Redirect URI: `https://tmdb.example.com/callback` (your `OIDC_REDIRECT_URL`) +- Scopes: `openid`, `profile`, `email` (add a `groups` mapping if you use + `ALLOWED_GROUPS`) +- Copy the Client ID / Secret and the provider's **OpenID Configuration Issuer** + into `.env`. + +## Run + +### With Go + +```bash +go mod tidy # once, to fetch dependencies +set -a; . ./.env; set +a +go run . +``` + +### With Docker + +```bash +docker build -t umt-tmdb-proxy . +docker run --env-file .env -p 8080:8080 umt-tmdb-proxy +``` + +Put it behind a TLS-terminating reverse proxy (Caddy, Traefik, nginx) so +`PUBLIC_URL` is `https://…`; cookies are then marked `Secure` automatically. + +## Endpoints + +| Path | Purpose | +|-------------|----------------------------------------------------| +| `/` | Landing page with a login button | +| `/login` | Starts the Authentik OIDC flow | +| `/callback` | OIDC redirect target; shows the personal token | +| `/3/*` | Authenticated TMDB API proxy | +| `/healthz` | Liveness probe | diff --git a/server/config.go b/server/config.go new file mode 100644 index 0000000..f49d9ab --- /dev/null +++ b/server/config.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config holds all runtime settings, sourced from environment variables so the +// server is easy to run under systemd, Docker or a PaaS without code changes. +type Config struct { + ListenAddr string // e.g. ":8080" + PublicURL string // externally reachable base URL, e.g. https://tmdb.example.com + TMDBKey string // the single shared TMDB v3 API key + TMDBBase string // upstream base, defaults to https://api.themoviedb.org/3 + Issuer string // Authentik OIDC issuer URL + ClientID string // OAuth2 client id + ClientSecret string // OAuth2 client secret + RedirectURL string // OAuth2 redirect URL (PublicURL + /callback if empty) + SessionKey []byte // HMAC secret for signing access tokens / state cookies + AllowGroups []string // if set, the user must be in one of these Authentik groups + TokenTTL time.Duration // lifetime of issued desktop-app tokens +} + +func getenv(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func loadConfig() (*Config, error) { + c := &Config{ + ListenAddr: getenv("LISTEN_ADDR", ":8080"), + PublicURL: strings.TrimRight(getenv("PUBLIC_URL", "http://localhost:8080"), "/"), + TMDBKey: getenv("TMDB_API_KEY", ""), + TMDBBase: strings.TrimRight(getenv("TMDB_BASE_URL", "https://api.themoviedb.org/3"), "/"), + Issuer: getenv("OIDC_ISSUER", ""), + ClientID: getenv("OIDC_CLIENT_ID", ""), + ClientSecret: getenv("OIDC_CLIENT_SECRET", ""), + RedirectURL: getenv("OIDC_REDIRECT_URL", ""), + } + + if c.RedirectURL == "" { + c.RedirectURL = c.PublicURL + "/callback" + } + + secret := getenv("SESSION_SECRET", "") + if secret == "" { + return nil, fmt.Errorf("SESSION_SECRET is required (a long random string)") + } + c.SessionKey = []byte(secret) + + if groups := getenv("ALLOWED_GROUPS", ""); groups != "" { + for _, g := range strings.Split(groups, ",") { + if g = strings.TrimSpace(g); g != "" { + c.AllowGroups = append(c.AllowGroups, g) + } + } + } + + days, err := strconv.Atoi(getenv("TOKEN_TTL_DAYS", "90")) + if err != nil || days <= 0 { + days = 90 + } + c.TokenTTL = time.Duration(days) * 24 * time.Hour + + // Required fields. + var missing []string + if c.TMDBKey == "" { + missing = append(missing, "TMDB_API_KEY") + } + if c.Issuer == "" { + missing = append(missing, "OIDC_ISSUER") + } + if c.ClientID == "" { + missing = append(missing, "OIDC_CLIENT_ID") + } + if c.ClientSecret == "" { + missing = append(missing, "OIDC_CLIENT_SECRET") + } + if len(missing) > 0 { + return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", ")) + } + + return c, nil +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..d92dc83 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,13 @@ +module umt-tmdb-proxy + +go 1.22 + +require ( + github.com/coreos/go-oidc/v3 v3.11.0 + golang.org/x/oauth2 v0.21.0 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.2 // indirect + golang.org/x/crypto v0.25.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..041e4f9 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,18 @@ +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..4e5b65d --- /dev/null +++ b/server/main.go @@ -0,0 +1,318 @@ +// Command umt-tmdb-proxy is a small TMDB API proxy guarded by OIDC (Authentik). +// +// It lets a group of friends share a single TMDB API key: the server holds the +// key, requires each user to sign in once via Authentik, and then issues a +// long-lived token they paste into the Ultimate Media Tracker desktop app. +// All TMDB requests from the app are forwarded with the shared key attached. +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type server struct { + cfg *Config + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + oauth oauth2.Config + client *http.Client +} + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatalf("config error: %v", err) + } + + ctx := context.Background() + provider, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + log.Fatalf("OIDC discovery failed for %q: %v", cfg.Issuer, err) + } + + s := &server{ + cfg: cfg, + provider: provider, + verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), + oauth: oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + client: &http.Client{Timeout: 15 * time.Second}, + } + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + mux.HandleFunc("/", s.handleIndex) + mux.HandleFunc("/login", s.handleLogin) + mux.HandleFunc("/callback", s.handleCallback) + mux.HandleFunc("/3/", s.handleProxy) + + log.Printf("umt-tmdb-proxy listening on %s (public %s)", cfg.ListenAddr, cfg.PublicURL) + srv := &http.Server{ + Addr: cfg.ListenAddr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + log.Fatal(srv.ListenAndServe()) +} + +func (s *server) secure() bool { + return strings.HasPrefix(s.cfg.PublicURL, "https://") +} + +func (s *server) setCookie(w http.ResponseWriter, name, value string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: s.secure(), + SameSite: http.SameSiteLaxMode, + }) +} + +func randString(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.WriteString(w, ` + +Media Tracker TMDB-Proxy + +

Ultimate Media Tracker – TMDB-Proxy

+

Melde dich mit deinem Konto an, um ein Zugriffstoken für die +Desktop-App zu erhalten. Damit nutzt du TMDB, ohne einen eigenen API-Key zu brauchen.

+

Mit Authentik anmelden

+`) +} + +func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) { + state := randString(24) + verifier := oauth2.GenerateVerifier() + + s.setCookie(w, "umt_state", state, 600) + s.setCookie(w, "umt_verifier", verifier, 600) + + // An optional app_redirect lets the desktop app capture the token via a + // loopback URL instead of copy/paste. Only accept localhost targets. + if appcb := r.URL.Query().Get("app_redirect"); isLoopbackRedirect(appcb) { + s.setCookie(w, "umt_appcb", appcb, 600) + } else { + s.setCookie(w, "umt_appcb", "", -1) + } + + authURL := s.oauth.AuthCodeURL(state, + oauth2.AccessTypeOffline, + oauth2.S256ChallengeOption(verifier)) + http.Redirect(w, r, authURL, http.StatusFound) +} + +// isLoopbackRedirect reports whether raw is a plain-HTTP URL pointing at the +// local machine, so it is safe to hand a freshly minted token to. +func isLoopbackRedirect(raw string) bool { + if raw == "" { + return false + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "http" { + return false + } + switch u.Hostname() { + case "127.0.0.1", "localhost", "::1": + return true + } + return false +} + +func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + stateCookie, err := r.Cookie("umt_state") + if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") { + http.Error(w, "ungültiger oder abgelaufener Login-Versuch", http.StatusBadRequest) + return + } + verifierCookie, err := r.Cookie("umt_verifier") + if err != nil { + http.Error(w, "fehlender PKCE-Verifier", http.StatusBadRequest) + return + } + + oauthToken, err := s.oauth.Exchange(ctx, r.URL.Query().Get("code"), + oauth2.VerifierOption(verifierCookie.Value)) + if err != nil { + http.Error(w, "Token-Austausch fehlgeschlagen: "+err.Error(), http.StatusBadGateway) + return + } + + rawIDToken, ok := oauthToken.Extra("id_token").(string) + if !ok { + http.Error(w, "kein id_token in der Antwort", http.StatusBadGateway) + return + } + idToken, err := s.verifier.Verify(ctx, rawIDToken) + if err != nil { + http.Error(w, "id_token-Prüfung fehlgeschlagen: "+err.Error(), http.StatusUnauthorized) + return + } + + var claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + Name string `json:"preferred_username"` + Groups []string `json:"groups"` + } + if err := idToken.Claims(&claims); err != nil { + http.Error(w, "Claims konnten nicht gelesen werden", http.StatusInternalServerError) + return + } + + if !s.groupAllowed(claims.Groups) { + http.Error(w, "Dein Konto ist für diesen Dienst nicht freigeschaltet.", http.StatusForbidden) + return + } + + // Clear the transient cookies. + s.setCookie(w, "umt_state", "", -1) + s.setCookie(w, "umt_verifier", "", -1) + + appToken, err := mintToken(s.cfg.SessionKey, claims.Subject, claims.Email, s.cfg.TokenTTL) + if err != nil { + http.Error(w, "Token konnte nicht erstellt werden", http.StatusInternalServerError) + return + } + + // If the login was started by the desktop app, hand the token back via its + // loopback URL so it is captured automatically. + if appcbCookie, err := r.Cookie("umt_appcb"); err == nil && isLoopbackRedirect(appcbCookie.Value) { + s.setCookie(w, "umt_appcb", "", -1) + cb, _ := url.Parse(appcbCookie.Value) + q := cb.Query() + q.Set("token", appToken) + cb.RawQuery = q.Encode() + http.Redirect(w, r, cb.String(), http.StatusFound) + return + } + + who := claims.Name + if who == "" { + who = claims.Email + } + s.renderToken(w, who, appToken) +} + +func (s *server) groupAllowed(userGroups []string) bool { + if len(s.cfg.AllowGroups) == 0 { + return true + } + for _, allowed := range s.cfg.AllowGroups { + for _, g := range userGroups { + if g == allowed { + return true + } + } + } + return false +} + +func (s *server) renderToken(w http.ResponseWriter, who, token string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.WriteString(w, ` + +Dein Zugriffstoken + +

Angemeldet als ` + htmlEscape(who) + `

+

Kopiere dieses Token und trage es in der Desktop-App unter +Einstellungen → Filme/Serien über → Proxy-Server ein:

+ +
    +
  1. Proxy-URL: ` + htmlEscape(s.cfg.PublicURL) + `
  2. +
  3. Proxy-Token: das Feld oben
  4. +
+

Das Token ist persönlich und läuft nach einiger Zeit ab – dann hier erneut anmelden.

+`) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return r.Replace(s) +} + +// handleProxy forwards an authenticated /3/* request to TMDB with the shared key. +func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "only GET is supported", http.StatusMethodNotAllowed) + return + } + + bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer")) + if _, err := verifyToken(s.cfg.SessionKey, bearer); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Build upstream URL: /3/ -> /, keep the query, force api_key. + upstreamPath := strings.TrimPrefix(r.URL.Path, "/3") + target, err := url.Parse(s.cfg.TMDBBase + upstreamPath) + if err != nil { + http.Error(w, "bad path", http.StatusBadRequest) + return + } + q := r.URL.Query() + q.Set("api_key", s.cfg.TMDBKey) + target.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil) + if err != nil { + http.Error(w, "request build failed", http.StatusInternalServerError) + return + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "umt-tmdb-proxy/1.0") + + resp, err := s.client.Do(req) + if err != nil { + http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} diff --git a/server/token.go b/server/token.go new file mode 100644 index 0000000..478c884 --- /dev/null +++ b/server/token.go @@ -0,0 +1,68 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" +) + +// appClaims is the payload of a desktop-app access token. These tokens are +// minted by this server after a successful Authentik login and are independent +// of the (short-lived) OIDC tokens, so a friend can paste one into the desktop +// app and keep using it until it expires. +type appClaims struct { + Subject string `json:"sub"` + Email string `json:"email"` + Expires int64 `json:"exp"` // unix seconds +} + +// mintToken creates a stateless, HMAC-signed token of the form +// base64url(payload).base64url(sig). No server-side storage is needed. +func mintToken(secret []byte, sub, email string, ttl time.Duration) (string, error) { + payload := appClaims{ + Subject: sub, + Email: email, + Expires: time.Now().Add(ttl).Unix(), + } + raw, err := json.Marshal(payload) + if err != nil { + return "", err + } + body := base64.RawURLEncoding.EncodeToString(raw) + sig := sign(secret, body) + return body + "." + sig, nil +} + +// verifyToken validates the signature and expiry, returning the claims. +func verifyToken(secret []byte, token string) (*appClaims, error) { + parts := strings.SplitN(strings.TrimSpace(token), ".", 2) + if len(parts) != 2 { + return nil, errors.New("malformed token") + } + expected := sign(secret, parts[0]) + if !hmac.Equal([]byte(expected), []byte(parts[1])) { + return nil, errors.New("bad signature") + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, errors.New("bad payload encoding") + } + var claims appClaims + if err := json.Unmarshal(raw, &claims); err != nil { + return nil, errors.New("bad payload") + } + if time.Now().Unix() >= claims.Expires { + return nil, errors.New("token expired") + } + return &claims, nil +} + +func sign(secret []byte, body string) string { + m := hmac.New(sha256.New, secret) + m.Write([]byte(body)) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} diff --git a/src/core/Database.cpp b/src/core/Database.cpp new file mode 100644 index 0000000..2409b76 --- /dev/null +++ b/src/core/Database.cpp @@ -0,0 +1,677 @@ +#include "core/Database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +static QString dtToStr(const QDateTime &dt) { + return dt.isValid() ? dt.toString(Qt::ISODate) : QString(); +} +static QDateTime strToDt(const QString &s) { + return s.isEmpty() ? QDateTime() : QDateTime::fromString(s, Qt::ISODate); +} + +Database::Database(QObject *parent) + : QObject(parent) + , m_connName(QStringLiteral("umt_%1").arg(QUuid::createUuid().toString(QUuid::Id128))) +{ +} + +Database::~Database() { + { + QSqlDatabase db = QSqlDatabase::database(m_connName, false); + if (db.isOpen()) db.close(); + } + QSqlDatabase::removeDatabase(m_connName); +} + +bool Database::isOpen() const { + return QSqlDatabase::database(m_connName, false).isOpen(); +} + +bool Database::open(const QString &path) { + QFileInfo fi(path); + QDir().mkpath(fi.absolutePath()); + + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connName); + db.setDatabaseName(path); + if (!db.open()) { + m_lastError = db.lastError().text(); + return false; + } + exec(QStringLiteral("PRAGMA foreign_keys = ON;")); + exec(QStringLiteral("PRAGMA journal_mode = WAL;")); + initSchema(); + return true; +} + +bool Database::exec(const QString &sql) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + if (!q.exec(sql)) { + m_lastError = q.lastError().text(); + qWarning() << "SQL error:" << m_lastError << "for" << sql; + return false; + } + return true; +} + +void Database::initSchema() { + exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS media (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " type TEXT NOT NULL," + " title TEXT NOT NULL," + " original_title TEXT," + " cover_path TEXT," + " cover_url TEXT," + " status TEXT NOT NULL DEFAULT 'PlanToWatch'," + " rating INTEGER NOT NULL DEFAULT 0," + " favorite INTEGER NOT NULL DEFAULT 0," + " year INTEGER NOT NULL DEFAULT 0," + " overview TEXT," + " notes TEXT," + " date_added TEXT," + " date_completed TEXT," + " external_id TEXT," + " external_source TEXT," + " franchise TEXT" + ");")); + + // Migration: add the franchise column to libraries created before it existed. + { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery info(db); + bool hasFranchise = false; + if (info.exec(QStringLiteral("PRAGMA table_info(media)"))) + while (info.next()) + if (info.value(1).toString() == QLatin1String("franchise")) { + hasFranchise = true; + break; + } + if (!hasFranchise) + exec(QStringLiteral("ALTER TABLE media ADD COLUMN franchise TEXT")); + } + + exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS segments (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " media_id INTEGER NOT NULL," + " number INTEGER NOT NULL DEFAULT 0," + " title TEXT," + " FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE" + ");")); + + exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS units (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " segment_id INTEGER NOT NULL," + " number INTEGER NOT NULL DEFAULT 0," + " title TEXT," + " watched INTEGER NOT NULL DEFAULT 0," + " watched_date TEXT," + " FOREIGN KEY(segment_id) REFERENCES segments(id) ON DELETE CASCADE" + ");")); + + exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS genres (" + " media_id INTEGER NOT NULL," + " genre TEXT NOT NULL," + " PRIMARY KEY(media_id, genre)," + " FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE" + ");")); + + exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS tags (" + " media_id INTEGER NOT NULL," + " tag TEXT NOT NULL," + " PRIMARY KEY(media_id, tag)," + " FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE" + ");")); + + exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS localized_titles (" + " media_id INTEGER NOT NULL," + " lang TEXT NOT NULL," + " title TEXT NOT NULL," + " PRIMARY KEY(media_id, lang)," + " FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE" + ");")); + + exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS custom_fields (" + " media_id INTEGER NOT NULL," + " key TEXT NOT NULL," + " value TEXT," + " PRIMARY KEY(media_id, key)," + " FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE" + ");")); +} + +// --------------------------------------------------------------------------- +// Save +// --------------------------------------------------------------------------- +bool Database::saveItem(MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + db.transaction(); + + QSqlQuery q(db); + if (item.id < 0) { + if (!item.dateAdded.isValid()) + item.dateAdded = QDateTime::currentDateTime(); + q.prepare(QStringLiteral( + "INSERT INTO media (type,title,original_title,cover_path,cover_url," + "status,rating,favorite,year,overview,notes,date_added,date_completed," + "external_id,external_source,franchise) VALUES " + "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")); + } else { + q.prepare(QStringLiteral( + "UPDATE media SET type=?,title=?,original_title=?,cover_path=?,cover_url=?," + "status=?,rating=?,favorite=?,year=?,overview=?,notes=?,date_added=?," + "date_completed=?,external_id=?,external_source=?,franchise=? WHERE id=?")); + } + q.addBindValue(mediaTypeToString(item.type)); + q.addBindValue(item.title); + q.addBindValue(item.originalTitle); + q.addBindValue(item.coverPath); + q.addBindValue(item.coverUrl); + q.addBindValue(statusToString(item.status)); + q.addBindValue(item.rating); + q.addBindValue(item.favorite ? 1 : 0); + q.addBindValue(item.year); + q.addBindValue(item.overview); + q.addBindValue(item.notes); + q.addBindValue(dtToStr(item.dateAdded)); + q.addBindValue(dtToStr(item.dateCompleted)); + q.addBindValue(item.externalId); + q.addBindValue(item.externalSource); + q.addBindValue(item.franchise.trimmed()); + if (item.id >= 0) + q.addBindValue(item.id); + + if (!q.exec()) { + m_lastError = q.lastError().text(); + db.rollback(); + return false; + } + if (item.id < 0) + item.id = q.lastInsertId().toInt(); + + if (!saveGenresTags(item) || !saveLocalized(item) || + !saveCustomFields(item) || !saveSegments(item)) { + db.rollback(); + return false; + } + + if (!db.commit()) { + m_lastError = db.lastError().text(); + return false; + } + emit changed(); + return true; +} + +bool Database::saveSegments(MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery del(db); + del.prepare(QStringLiteral("DELETE FROM segments WHERE media_id=?")); + del.addBindValue(item.id); + if (!del.exec()) { m_lastError = del.lastError().text(); return false; } + + for (auto &seg : item.segments) { + QSqlQuery sq(db); + sq.prepare(QStringLiteral( + "INSERT INTO segments (media_id,number,title) VALUES (?,?,?)")); + sq.addBindValue(item.id); + sq.addBindValue(seg.number); + sq.addBindValue(seg.title); + if (!sq.exec()) { m_lastError = sq.lastError().text(); return false; } + seg.id = sq.lastInsertId().toInt(); + + for (auto &u : seg.units) { + QSqlQuery uq(db); + uq.prepare(QStringLiteral( + "INSERT INTO units (segment_id,number,title,watched,watched_date) " + "VALUES (?,?,?,?,?)")); + uq.addBindValue(seg.id); + uq.addBindValue(u.number); + uq.addBindValue(u.title); + uq.addBindValue(u.watched ? 1 : 0); + uq.addBindValue(dtToStr(u.watchedDate)); + if (!uq.exec()) { m_lastError = uq.lastError().text(); return false; } + u.id = uq.lastInsertId().toInt(); + } + } + return true; +} + +bool Database::saveGenresTags(const MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + for (const QString &table : {QStringLiteral("genres"), QStringLiteral("tags")}) { + QSqlQuery d(db); + d.prepare(QStringLiteral("DELETE FROM %1 WHERE media_id=?").arg(table)); + d.addBindValue(item.id); + if (!d.exec()) { m_lastError = d.lastError().text(); return false; } + } + for (const QString &g : item.genres) { + if (g.trimmed().isEmpty()) continue; + QSqlQuery i(db); + i.prepare(QStringLiteral( + "INSERT OR IGNORE INTO genres (media_id,genre) VALUES (?,?)")); + i.addBindValue(item.id); + i.addBindValue(g.trimmed()); + if (!i.exec()) { m_lastError = i.lastError().text(); return false; } + } + for (const QString &t : item.tags) { + if (t.trimmed().isEmpty()) continue; + QSqlQuery i(db); + i.prepare(QStringLiteral( + "INSERT OR IGNORE INTO tags (media_id,tag) VALUES (?,?)")); + i.addBindValue(item.id); + i.addBindValue(t.trimmed()); + if (!i.exec()) { m_lastError = i.lastError().text(); return false; } + } + return true; +} + +bool Database::saveLocalized(const MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery d(db); + d.prepare(QStringLiteral("DELETE FROM localized_titles WHERE media_id=?")); + d.addBindValue(item.id); + if (!d.exec()) { m_lastError = d.lastError().text(); return false; } + + for (auto it = item.localizedTitles.cbegin(); it != item.localizedTitles.cend(); ++it) { + if (it.value().trimmed().isEmpty()) continue; + QSqlQuery i(db); + i.prepare(QStringLiteral( + "INSERT OR REPLACE INTO localized_titles (media_id,lang,title) VALUES (?,?,?)")); + i.addBindValue(item.id); + i.addBindValue(it.key()); + i.addBindValue(it.value()); + if (!i.exec()) { m_lastError = i.lastError().text(); return false; } + } + return true; +} + +bool Database::saveCustomFields(const MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery d(db); + d.prepare(QStringLiteral("DELETE FROM custom_fields WHERE media_id=?")); + d.addBindValue(item.id); + if (!d.exec()) { m_lastError = d.lastError().text(); return false; } + + for (auto it = item.customFields.cbegin(); it != item.customFields.cend(); ++it) { + if (it.key().trimmed().isEmpty()) continue; + QSqlQuery i(db); + i.prepare(QStringLiteral( + "INSERT OR REPLACE INTO custom_fields (media_id,key,value) VALUES (?,?,?)")); + i.addBindValue(item.id); + i.addBindValue(it.key()); + i.addBindValue(it.value()); + if (!i.exec()) { m_lastError = i.lastError().text(); return false; } + } + return true; +} + +// --------------------------------------------------------------------------- +// Load +// --------------------------------------------------------------------------- +static MediaItem rowToItem(const QSqlQuery &q) { + MediaItem m; + m.id = q.value(QStringLiteral("id")).toInt(); + m.type = mediaTypeFromString(q.value(QStringLiteral("type")).toString()); + m.title = q.value(QStringLiteral("title")).toString(); + m.originalTitle = q.value(QStringLiteral("original_title")).toString(); + m.coverPath = q.value(QStringLiteral("cover_path")).toString(); + m.coverUrl = q.value(QStringLiteral("cover_url")).toString(); + m.status = statusFromString(q.value(QStringLiteral("status")).toString()); + m.rating = q.value(QStringLiteral("rating")).toInt(); + m.favorite = q.value(QStringLiteral("favorite")).toInt() != 0; + m.year = q.value(QStringLiteral("year")).toInt(); + m.overview = q.value(QStringLiteral("overview")).toString(); + m.notes = q.value(QStringLiteral("notes")).toString(); + m.dateAdded = strToDt(q.value(QStringLiteral("date_added")).toString()); + m.dateCompleted = strToDt(q.value(QStringLiteral("date_completed")).toString()); + m.externalId = q.value(QStringLiteral("external_id")).toString(); + m.externalSource= q.value(QStringLiteral("external_source")).toString(); + m.franchise = q.value(QStringLiteral("franchise")).toString(); + return m; +} + +void Database::loadSegments(MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery sq(db); + sq.prepare(QStringLiteral( + "SELECT id,number,title FROM segments WHERE media_id=? ORDER BY number")); + sq.addBindValue(item.id); + sq.exec(); + while (sq.next()) { + Segment seg; + seg.id = sq.value(0).toInt(); + seg.number = sq.value(1).toInt(); + seg.title = sq.value(2).toString(); + + QSqlQuery uq(db); + uq.prepare(QStringLiteral( + "SELECT id,number,title,watched,watched_date FROM units " + "WHERE segment_id=? ORDER BY number")); + uq.addBindValue(seg.id); + uq.exec(); + while (uq.next()) { + Unit u; + u.id = uq.value(0).toInt(); + u.number = uq.value(1).toInt(); + u.title = uq.value(2).toString(); + u.watched = uq.value(3).toInt() != 0; + u.watchedDate = strToDt(uq.value(4).toString()); + seg.units.push_back(u); + } + item.segments.push_back(seg); + } +} + +void Database::loadGenresTags(MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery gq(db); + gq.prepare(QStringLiteral("SELECT genre FROM genres WHERE media_id=? ORDER BY genre")); + gq.addBindValue(item.id); + gq.exec(); + while (gq.next()) item.genres << gq.value(0).toString(); + + QSqlQuery tq(db); + tq.prepare(QStringLiteral("SELECT tag FROM tags WHERE media_id=? ORDER BY tag")); + tq.addBindValue(item.id); + tq.exec(); + while (tq.next()) item.tags << tq.value(0).toString(); +} + +void Database::loadLocalized(MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + q.prepare(QStringLiteral("SELECT lang,title FROM localized_titles WHERE media_id=?")); + q.addBindValue(item.id); + q.exec(); + while (q.next()) + item.localizedTitles.insert(q.value(0).toString(), q.value(1).toString()); +} + +void Database::loadCustomFields(MediaItem &item) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + q.prepare(QStringLiteral("SELECT key,value FROM custom_fields WHERE media_id=?")); + q.addBindValue(item.id); + q.exec(); + while (q.next()) + item.customFields.insert(q.value(0).toString(), q.value(1).toString()); +} + +std::optional Database::loadItem(int id) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + q.prepare(QStringLiteral("SELECT * FROM media WHERE id=?")); + q.addBindValue(id); + if (!q.exec() || !q.next()) + return std::nullopt; + MediaItem m = rowToItem(q); + loadSegments(m); + loadGenresTags(m); + loadLocalized(m); + loadCustomFields(m); + return m; +} + +QVector Database::queryItems(const FilterCriteria &c) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + + QString sql = QStringLiteral("SELECT DISTINCT media.* FROM media"); + QStringList wheres; + QVariantList binds; + + if (!c.genres.isEmpty()) { + // require all selected genres -> one join condition per genre + for (int i = 0; i < c.genres.size(); ++i) { + sql += QStringLiteral(" JOIN genres g%1 ON g%1.media_id=media.id " + "AND g%1.genre=?").arg(i); + binds << c.genres[i]; + } + } + if (!c.tags.isEmpty()) { + for (int i = 0; i < c.tags.size(); ++i) { + sql += QStringLiteral(" JOIN tags t%1 ON t%1.media_id=media.id " + "AND t%1.tag=?").arg(i); + binds << c.tags[i]; + } + } + + if (c.type) { wheres << QStringLiteral("media.type=?"); binds << mediaTypeToString(*c.type); } + if (c.status) { wheres << QStringLiteral("media.status=?"); binds << statusToString(*c.status); } + if (c.favoritesOnly) wheres << QStringLiteral("media.favorite=1"); + if (c.minRating > 0) { wheres << QStringLiteral("media.rating>=?"); binds << c.minRating; } + if (c.yearFrom > 0) { wheres << QStringLiteral("media.year>=?"); binds << c.yearFrom; } + if (c.yearTo > 0) { wheres << QStringLiteral("media.year<=?"); binds << c.yearTo; } + if (!c.searchText.trimmed().isEmpty()) { + wheres << QStringLiteral("(media.title LIKE ? OR media.original_title LIKE ? " + "OR media.overview LIKE ?)"); + const QString like = QStringLiteral("%%1%").arg(c.searchText.trimmed()); + binds << like << like << like; + } + + if (!wheres.isEmpty()) + sql += QStringLiteral(" WHERE ") + wheres.join(QStringLiteral(" AND ")); + + QString order; + const QString dir = c.sortDescending ? QStringLiteral("DESC") : QStringLiteral("ASC"); + if (c.sortMode == QLatin1String("title")) order = QStringLiteral("media.title %1").arg(dir); + else if (c.sortMode == QLatin1String("rating")) order = QStringLiteral("media.rating %1").arg(dir); + else if (c.sortMode == QLatin1String("year")) order = QStringLiteral("media.year %1").arg(dir); + else order = QStringLiteral("media.date_added %1").arg(dir); + sql += QStringLiteral(" ORDER BY ") + order; + + QSqlQuery q(db); + q.prepare(sql); + for (const QVariant &b : binds) q.addBindValue(b); + + QVector result; + if (!q.exec()) { + m_lastError = q.lastError().text(); + qWarning() << "queryItems failed:" << m_lastError << sql; + return result; + } + while (q.next()) { + MediaItem m = rowToItem(q); + loadSegments(m); + loadGenresTags(m); + loadLocalized(m); + result.push_back(m); + } + + // Franchise is auto-detected by clustering titles, so it can't be expressed + // in SQL; filter the materialised rows instead. An explicit franchise on the + // item overrides the auto-detected cluster label. + if (!c.franchise.trimmed().isEmpty()) { + const QString want = c.franchise.trimmed(); + const QHash autoMap = titleFranchiseMap(); + QVector filtered; + for (const MediaItem &m : result) { + const QString explicitF = m.franchise.trimmed(); + const QString f = explicitF.isEmpty() ? autoMap.value(m.title) : explicitF; + if (f == want) filtered.push_back(m); + } + result = filtered; + } + + // progress sort can't be done in SQL cheaply; do it here + if (c.sortMode == QLatin1String("progress")) { + std::sort(result.begin(), result.end(), + [&](const MediaItem &a, const MediaItem &b) { + return c.sortDescending ? a.progress() > b.progress() + : a.progress() < b.progress(); + }); + } + return result; +} + +bool Database::deleteItem(int id) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + q.prepare(QStringLiteral("DELETE FROM media WHERE id=?")); + q.addBindValue(id); + if (!q.exec()) { m_lastError = q.lastError().text(); return false; } + emit changed(); + return true; +} + +bool Database::setUnitWatched(int unitId, bool watched) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + q.prepare(QStringLiteral("UPDATE units SET watched=?, watched_date=? WHERE id=?")); + q.addBindValue(watched ? 1 : 0); + q.addBindValue(watched ? dtToStr(QDateTime::currentDateTime()) : QString()); + q.addBindValue(unitId); + if (!q.exec()) { m_lastError = q.lastError().text(); return false; } + emit changed(); + return true; +} + +bool Database::setSegmentWatched(int segmentId, bool watched) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + q.prepare(QStringLiteral("UPDATE units SET watched=?, watched_date=? WHERE segment_id=?")); + q.addBindValue(watched ? 1 : 0); + q.addBindValue(watched ? dtToStr(QDateTime::currentDateTime()) : QString()); + q.addBindValue(segmentId); + if (!q.exec()) { m_lastError = q.lastError().text(); return false; } + emit changed(); + return true; +} + +bool Database::setItemStatus(int itemId, WatchStatus status) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + q.prepare(QStringLiteral("UPDATE media SET status=?, date_completed=? WHERE id=?")); + q.addBindValue(statusToString(status)); + q.addBindValue(status == WatchStatus::Completed + ? dtToStr(QDateTime::currentDateTime()) : QString()); + q.addBindValue(itemId); + if (!q.exec()) { m_lastError = q.lastError().text(); return false; } + emit changed(); + return true; +} + +bool Database::setFavorite(int itemId, bool fav) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + q.prepare(QStringLiteral("UPDATE media SET favorite=? WHERE id=?")); + q.addBindValue(fav ? 1 : 0); + q.addBindValue(itemId); + if (!q.exec()) { m_lastError = q.lastError().text(); return false; } + emit changed(); + return true; +} + +bool Database::setRating(int itemId, int rating) { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + q.prepare(QStringLiteral("UPDATE media SET rating=? WHERE id=?")); + q.addBindValue(rating); + q.addBindValue(itemId); + if (!q.exec()) { m_lastError = q.lastError().text(); return false; } + emit changed(); + return true; +} + +QStringList Database::allGenres() const { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + QStringList out; + if (q.exec(QStringLiteral("SELECT DISTINCT genre FROM genres ORDER BY genre"))) + while (q.next()) out << q.value(0).toString(); + return out; +} + +QStringList Database::allTags() const { + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + QStringList out; + if (q.exec(QStringLiteral("SELECT DISTINCT tag FROM tags ORDER BY tag"))) + while (q.next()) out << q.value(0).toString(); + return out; +} + +QHash Database::titleFranchiseMap() const { + QStringList titles; + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + if (q.exec(QStringLiteral("SELECT title FROM media"))) + while (q.next()) titles << q.value(0).toString(); + return clusterFranchises(titles); +} + +QHash Database::franchiseCounts() const { + const QHash autoMap = titleFranchiseMap(); + QHash counts; + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + if (q.exec(QStringLiteral("SELECT title, franchise FROM media"))) { + while (q.next()) { + const QString title = q.value(0).toString(); + const QString explicitF = q.value(1).toString().trimmed(); + const QString f = explicitF.isEmpty() ? autoMap.value(title) : explicitF; + if (!f.isEmpty()) ++counts[f]; + } + } + return counts; +} + +QStringList Database::allFranchises() const { + const QHash counts = franchiseCounts(); + QStringList out; + for (auto it = counts.cbegin(); it != counts.cend(); ++it) + if (it.value() >= 2) out << it.key(); + out.sort(Qt::CaseInsensitive); + return out; +} + +QStringList Database::franchiseNames() const { + QStringList out = franchiseCounts().keys(); + out.sort(Qt::CaseInsensitive); + return out; +} + +Database::Stats Database::stats() const { + Stats s; + QSqlDatabase db = QSqlDatabase::database(m_connName); + QSqlQuery q(db); + if (q.exec(QStringLiteral("SELECT status, COUNT(*) FROM media GROUP BY status"))) { + while (q.next()) { + const WatchStatus st = statusFromString(q.value(0).toString()); + const int n = q.value(1).toInt(); + s.total += n; + switch (st) { + case WatchStatus::Completed: s.completed += n; break; + case WatchStatus::InProgress: s.inProgress += n; break; + case WatchStatus::PlanToWatch:s.planned += n; break; + default: break; + } + } + } + QSqlQuery t(db); + if (t.exec(QStringLiteral("SELECT type, COUNT(*) FROM media GROUP BY type"))) { + while (t.next()) + s.perType[int(mediaTypeFromString(t.value(0).toString()))] = t.value(1).toInt(); + } + return s; +} + +} // namespace umt diff --git a/src/core/Database.h b/src/core/Database.h new file mode 100644 index 0000000..6c3f490 --- /dev/null +++ b/src/core/Database.h @@ -0,0 +1,96 @@ +#pragma once + +#include "core/MediaItem.h" + +#include +#include +#include +#include +#include + +namespace umt { + +// Describes an active set of filters applied to the library listing. +struct FilterCriteria { + std::optional type; // restrict to one media type + std::optional status; + QString searchText; // matches title / original / overview + QStringList genres; // AND-match all selected genres + QStringList tags; + QString franchise; // restrict to one franchise group + int minRating = 0; + int yearFrom = 0; + int yearTo = 0; + bool favoritesOnly = false; + QString sortMode = QStringLiteral("dateAdded"); // dateAdded|title|rating|year|progress + bool sortDescending = true; +}; + +// SQLite-backed persistence layer. Owns the schema and all CRUD operations. +class Database : public QObject { + Q_OBJECT +public: + explicit Database(QObject *parent = nullptr); + ~Database() override; + + bool open(const QString &path); + bool isOpen() const; + QString lastError() const { return m_lastError; } + + // --- media CRUD --- + // Inserts (id<0) or updates a full item incl. its segment/unit tree. + bool saveItem(MediaItem &item); + bool deleteItem(int id); + std::optional loadItem(int id); + + // Returns items (with full segment trees) matching the criteria. + QVector queryItems(const FilterCriteria &c); + + // Quick toggles avoid re-serializing the whole tree. + bool setUnitWatched(int unitId, bool watched); + bool setSegmentWatched(int segmentId, bool watched); + bool setItemStatus(int itemId, WatchStatus status); + bool setFavorite(int itemId, bool fav); + bool setRating(int itemId, int rating); + + // --- aggregate metadata for filter UIs --- + QStringList allGenres() const; + QStringList allTags() const; + // Franchise groups with at least two members (for the filter dropdown). + QStringList allFranchises() const; + // Every distinct franchise name incl. singletons (for input autocompletion). + QStringList franchiseNames() const; + + struct Stats { + int total = 0; + int completed = 0; + int inProgress = 0; + int planned = 0; + QHash perType; // MediaType(int) -> count + }; + Stats stats() const; + +signals: + void changed(); + +private: + bool exec(const QString &sql); + void initSchema(); + QHash franchiseCounts() const; + // Auto-detected franchise label per distinct title (explicit overrides not + // applied here; callers layer those on top). + QHash titleFranchiseMap() const; + void loadSegments(MediaItem &item); + bool saveSegments(MediaItem &item); + void loadGenresTags(MediaItem &item); + bool saveGenresTags(const MediaItem &item); + void loadLocalized(MediaItem &item); + bool saveLocalized(const MediaItem &item); + void loadCustomFields(MediaItem &item); + bool saveCustomFields(const MediaItem &item); + + QString m_connName; + QString m_lastError; +}; + +} // namespace umt diff --git a/src/core/Enums.h b/src/core/Enums.h new file mode 100644 index 0000000..7b4126a --- /dev/null +++ b/src/core/Enums.h @@ -0,0 +1,148 @@ +#pragma once + +#include +#include +#include + +namespace umt { + +// The four top-level kinds of media this app tracks. +enum class MediaType { + Movie, + Series, + Manga, + Book, + Game +}; + +// User-facing progress status. Custom statuses are stored separately as tags. +enum class WatchStatus { + PlanToWatch, + InProgress, + Completed, + OnHold, + Dropped +}; + +inline QString mediaTypeToString(MediaType t) { + switch (t) { + case MediaType::Movie: return QStringLiteral("Movie"); + case MediaType::Series: return QStringLiteral("Series"); + case MediaType::Manga: return QStringLiteral("Manga"); + case MediaType::Book: return QStringLiteral("Book"); + case MediaType::Game: return QStringLiteral("Game"); + } + return QStringLiteral("Movie"); +} + +inline MediaType mediaTypeFromString(const QString &s) { + if (s == QLatin1String("Series")) return MediaType::Series; + if (s == QLatin1String("Manga")) return MediaType::Manga; + if (s == QLatin1String("Book")) return MediaType::Book; + if (s == QLatin1String("Game")) return MediaType::Game; + return MediaType::Movie; +} + +// Localized, human-readable label (German UI). +inline QString mediaTypeLabel(MediaType t) { + switch (t) { + case MediaType::Movie: return QStringLiteral("Filme"); + case MediaType::Series: return QStringLiteral("Serien"); + case MediaType::Manga: return QStringLiteral("Mangas"); + case MediaType::Book: return QStringLiteral("Bücher"); + case MediaType::Game: return QStringLiteral("Spiele"); + } + return {}; +} + +inline QString statusToString(WatchStatus s) { + switch (s) { + case WatchStatus::PlanToWatch: return QStringLiteral("PlanToWatch"); + case WatchStatus::InProgress: return QStringLiteral("InProgress"); + case WatchStatus::Completed: return QStringLiteral("Completed"); + case WatchStatus::OnHold: return QStringLiteral("OnHold"); + case WatchStatus::Dropped: return QStringLiteral("Dropped"); + } + return QStringLiteral("PlanToWatch"); +} + +inline WatchStatus statusFromString(const QString &s) { + if (s == QLatin1String("InProgress")) return WatchStatus::InProgress; + if (s == QLatin1String("Completed")) return WatchStatus::Completed; + if (s == QLatin1String("OnHold")) return WatchStatus::OnHold; + if (s == QLatin1String("Dropped")) return WatchStatus::Dropped; + return WatchStatus::PlanToWatch; +} + +inline QString statusLabel(WatchStatus s) { + switch (s) { + case WatchStatus::PlanToWatch: return QStringLiteral("Geplant"); + case WatchStatus::InProgress: return QStringLiteral("Läuft"); + case WatchStatus::Completed: return QStringLiteral("Abgeschlossen"); + case WatchStatus::OnHold: return QStringLiteral("Pausiert"); + case WatchStatus::Dropped: return QStringLiteral("Abgebrochen"); + } + return {}; +} + +inline QColor statusColor(WatchStatus s) { + switch (s) { + case WatchStatus::PlanToWatch: return QColor("#8e8e93"); + case WatchStatus::InProgress: return QColor("#0a84ff"); + case WatchStatus::Completed: return QColor("#30d158"); + case WatchStatus::OnHold: return QColor("#ffd60a"); + case WatchStatus::Dropped: return QColor("#ff453a"); + } + return QColor("#8e8e93"); +} + +// Singular, human-readable category label for a single item (German UI). +inline QString mediaTypeSingular(MediaType t) { + switch (t) { + case MediaType::Movie: return QStringLiteral("Film"); + case MediaType::Series: return QStringLiteral("Serie"); + case MediaType::Manga: return QStringLiteral("Manga"); + case MediaType::Book: return QStringLiteral("Buch"); + case MediaType::Game: return QStringLiteral("Spiel"); + } + return {}; +} + +// A distinct accent colour per media type, for category chips. +inline QColor mediaTypeColor(MediaType t) { + switch (t) { + case MediaType::Movie: return QColor("#ff9f0a"); + case MediaType::Series: return QColor("#bf5af2"); + case MediaType::Manga: return QColor("#ff375f"); + case MediaType::Book: return QColor("#64d2ff"); + case MediaType::Game: return QColor("#32d74b"); + } + return QColor("#8e8e93"); +} + +// Label for the sub-container of a media type (Season vs Volume vs Part). +inline QString segmentLabel(MediaType t) { + switch (t) { + case MediaType::Series: return QStringLiteral("Staffel"); + case MediaType::Manga: return QStringLiteral("Band"); + case MediaType::Book: return QStringLiteral("Teil"); + case MediaType::Game: return QStringLiteral("Akt"); + default: return QStringLiteral("Abschnitt"); + } +} + +// Label for the leaf unit (Episode vs Chapter vs Page-block). +inline QString unitLabel(MediaType t) { + switch (t) { + case MediaType::Series: return QStringLiteral("Folge"); + case MediaType::Manga: return QStringLiteral("Kapitel"); + case MediaType::Book: return QStringLiteral("Kapitel"); + case MediaType::Game: return QStringLiteral("Kapitel"); + default: return QStringLiteral("Einheit"); + } +} + +} // namespace umt + +Q_DECLARE_METATYPE(umt::MediaType) +Q_DECLARE_METATYPE(umt::WatchStatus) diff --git a/src/core/MediaItem.cpp b/src/core/MediaItem.cpp new file mode 100644 index 0000000..2fb87fd --- /dev/null +++ b/src/core/MediaItem.cpp @@ -0,0 +1,8 @@ +#include "core/MediaItem.h" + +// All MediaItem logic is currently inline in the header. +// This translation unit exists to give the struct a home for future +// non-trivial helpers and to keep the build target list stable. + +namespace umt { +} // namespace umt diff --git a/src/core/MediaItem.h b/src/core/MediaItem.h new file mode 100644 index 0000000..b6684c3 --- /dev/null +++ b/src/core/MediaItem.h @@ -0,0 +1,234 @@ +#pragma once + +#include "core/Enums.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +// A leaf unit of progress: a single episode, chapter, etc. +struct Unit { + int id = -1; + int number = 0; // ordinal within its segment + QString title; + bool watched = false; + QDateTime watchedDate; +}; + +// A container grouping units: a season, a manga volume, a book part. +struct Segment { + int id = -1; + int number = 0; + QString title; + QVector units; + + int watchedCount() const { + int c = 0; + for (const auto &u : units) if (u.watched) ++c; + return c; + } + bool fullyWatched() const { + if (units.isEmpty()) return false; + return watchedCount() == units.size(); + } +}; + +// The central record. One row per tracked title. +struct MediaItem { + int id = -1; + MediaType type = MediaType::Movie; + + QString title; // primary display title + QString originalTitle; // original-language title + // Localized alternative titles, keyed by ISO language code ("en", "de", "ja"...). + QMap localizedTitles; + + QString coverPath; // local cached cover image path + QString coverUrl; // remote URL it was fetched from (for reference) + + QStringList genres; + QStringList tags; // free-form custom tags / custom statuses + QString franchise; // explicit franchise/series grouping (optional) + + WatchStatus status = WatchStatus::PlanToWatch; + int rating = 0; // 0..10 (0 = unrated) + bool favorite = false; + + int year = 0; + QString overview; // synopsis / description + QString notes; // private user notes + + // For movies/books without segments this can hold a single "watched" flag + // expressed through status==Completed. For multi-unit media, segments drive it. + QVector segments; + + // Arbitrary user-defined fields (high customizability). + QMap customFields; + + QDateTime dateAdded; + QDateTime dateCompleted; + + QString externalId; // provider id (e.g. tmdb/anilist/openlibrary) + QString externalSource; + + // --- progress helpers --- + int totalUnits() const { + int n = 0; + for (const auto &s : segments) n += s.units.size(); + return n; + } + int watchedUnits() const { + int n = 0; + for (const auto &s : segments) + for (const auto &u : s.units) if (u.watched) ++n; + return n; + } + // 0.0 .. 1.0 + double progress() const { + if (totalUnits() == 0) + return status == WatchStatus::Completed ? 1.0 : 0.0; + return double(watchedUnits()) / double(totalUnits()); + } + bool hasSegments() const { return !segments.isEmpty(); } + + QString bestTitleFor(const QString &langCode) const { + if (!langCode.isEmpty() && localizedTitles.contains(langCode)) + return localizedTitles.value(langCode); + return title; + } +}; + +// Derives a franchise key from a title for auto-detection: it drops a subtitle +// after a colon/dash, a trailing "(year)" and a trailing sequel marker (a number +// or roman numeral) so that e.g. "Zelda: Breath of the Wild", "Zelda - Ocarina" +// and "Final Fantasy VII" collapse onto a shared base name. +inline QString franchiseKeyFromTitle(const QString &title) { + QString t = title.trimmed(); + static const QStringList seps = {QStringLiteral(": "), QStringLiteral(" - "), + QStringLiteral(" – "), QStringLiteral(" — ")}; + for (const QString &s : seps) { + const int idx = t.indexOf(s); + if (idx > 0) { t = t.left(idx); break; } + } + const int paren = t.indexOf(QLatin1Char('(')); + if (paren > 0) t = t.left(paren); + t = t.trimmed(); + static const QRegularExpression tail( + QStringLiteral("\\s+(?:[0-9]{1,3}|[IVXLCDM]+)$")); + t.remove(tail); + return t.trimmed(); +} + +// The franchise an item belongs to: an explicit value always wins, otherwise the +// auto-detected key from its primary title. +inline QString effectiveFranchise(const MediaItem &m) { + const QString explicitF = m.franchise.trimmed(); + if (!explicitF.isEmpty()) return explicitF; + return franchiseKeyFromTitle(m.title); +} + +// Words too generic to form a franchise on their own; also trimmed from the end +// of a detected prefix so e.g. "Harry Potter und der …" collapses to "Harry +// Potter" instead of "Harry Potter Und Der". +inline const QSet &franchiseStopWords() { + static const QSet s = { + QStringLiteral("the"), QStringLiteral("a"), QStringLiteral("an"), + QStringLiteral("and"), QStringLiteral("of"), QStringLiteral("to"), + QStringLiteral("in"), QStringLiteral("on"), QStringLiteral("for"), + QStringLiteral("und"), QStringLiteral("der"), QStringLiteral("die"), + QStringLiteral("das"), QStringLiteral("den"), QStringLiteral("dem"), + QStringLiteral("ein"), QStringLiteral("eine"), QStringLiteral("le"), + QStringLiteral("la"), QStringLiteral("les"), QStringLiteral("el"), + QStringLiteral("il"), QStringLiteral("no"), QStringLiteral("wa"), + }; + return s; +} + +// Auto-groups a set of titles into franchises by their longest shared word +// prefix. Two or more titles that begin with the same words (e.g. several +// "Harry Potter …" entries) collapse onto a shared label; titles that share no +// meaningful prefix with any other keep their own normalized base name. +// Returns a map: original title -> franchise label. +inline QHash clusterFranchises(const QStringList &titlesIn) { + static const QRegularExpression nonWord(QStringLiteral("[^\\p{L}\\p{N}]+")); + + // De-duplicate while preserving order. + QStringList titles; + { + QSet seen; + for (const QString &t : titlesIn) + if (!t.trimmed().isEmpty() && !seen.contains(t)) { + seen.insert(t); + titles << t; + } + } + + const int n = titles.size(); + QVector norm(n), orig(n); + for (int i = 0; i < n; ++i) { + const QString base = franchiseKeyFromTitle(titles[i]); + orig[i] = base.split(nonWord, Qt::SkipEmptyParts); + for (const QString &w : orig[i]) norm[i] << w.toLower(); + } + + // Count how many titles share each word-prefix. + QHash prefixCount; + for (int i = 0; i < n; ++i) { + QString key; + for (int L = 0; L < norm[i].size(); ++L) { + if (L) key += QLatin1Char(' '); + key += norm[i][L]; + ++prefixCount[key]; + } + } + + const QSet &stop = franchiseStopWords(); + QHash labelForKey; // normalized prefix -> display label + QHash result; + for (int i = 0; i < n; ++i) { + // Longest prefix shared by at least two titles. + int bestL = 0; + { + QString key; + for (int L = 0; L < norm[i].size(); ++L) { + if (L) key += QLatin1Char(' '); + key += norm[i][L]; + if (prefixCount.value(key) >= 2) bestL = L + 1; + } + } + if (bestL == 0) { + result.insert(titles[i], franchiseKeyFromTitle(titles[i])); + continue; + } + // Drop trailing stop words from the shared prefix. + int L = bestL; + while (L > 1 && stop.contains(norm[i][L - 1])) --L; + // Reject a prefix that is only stop words (e.g. "The") — don't group on it. + bool hasContent = false; + for (int j = 0; j < L; ++j) + if (!stop.contains(norm[i][j])) { hasContent = true; break; } + if (!hasContent) { + result.insert(titles[i], franchiseKeyFromTitle(titles[i])); + continue; + } + QString nkey, disp; + for (int j = 0; j < L; ++j) { + if (j) { nkey += QLatin1Char(' '); disp += QLatin1Char(' '); } + nkey += norm[i][j]; + disp += orig[i][j]; + } + if (!labelForKey.contains(nkey)) labelForKey.insert(nkey, disp); + result.insert(titles[i], labelForKey.value(nkey)); + } + return result; +} + +} // namespace umt diff --git a/src/core/Settings.cpp b/src/core/Settings.cpp new file mode 100644 index 0000000..074d7e9 --- /dev/null +++ b/src/core/Settings.cpp @@ -0,0 +1,107 @@ +#include "core/Settings.h" + +namespace umt { + +AppSettings::AppSettings(QObject *parent) + : QObject(parent) + , m_s(QStringLiteral("UMT"), QStringLiteral("UltimateMediaTracker")) +{ +} + +bool AppSettings::darkMode() const { + return m_s.value(QStringLiteral("theme/dark"), true).toBool(); +} +void AppSettings::setDarkMode(bool on) { + if (darkMode() == on) return; + m_s.setValue(QStringLiteral("theme/dark"), on); + emit themeChanged(); +} + +QColor AppSettings::accentColor() const { + return QColor(m_s.value(QStringLiteral("theme/accent"), + QStringLiteral("#0a84ff")).toString()); +} +void AppSettings::setAccentColor(const QColor &c) { + if (accentColor() == c) return; + m_s.setValue(QStringLiteral("theme/accent"), c.name()); + emit accentChanged(c); + emit themeChanged(); +} + +QString AppSettings::preferredLanguage() const { + return m_s.value(QStringLiteral("locale/language"), + QStringLiteral("de")).toString(); +} +void AppSettings::setPreferredLanguage(const QString &code) { + m_s.setValue(QStringLiteral("locale/language"), code); +} + +QString AppSettings::tmdbMode() const { + return m_s.value(QStringLiteral("providers/tmdbMode"), + QStringLiteral("key")).toString(); +} +void AppSettings::setTmdbMode(const QString &mode) { + m_s.setValue(QStringLiteral("providers/tmdbMode"), mode); +} + +QString AppSettings::tmdbApiKey() const { + return m_s.value(QStringLiteral("providers/tmdbKey")).toString(); +} +void AppSettings::setTmdbApiKey(const QString &key) { + m_s.setValue(QStringLiteral("providers/tmdbKey"), key); +} + +QString AppSettings::proxyUrl() const { + return m_s.value(QStringLiteral("providers/proxyUrl")).toString(); +} +void AppSettings::setProxyUrl(const QString &url) { + m_s.setValue(QStringLiteral("providers/proxyUrl"), url); +} + +QString AppSettings::proxyToken() const { + return m_s.value(QStringLiteral("providers/proxyToken")).toString(); +} +void AppSettings::setProxyToken(const QString &token) { + m_s.setValue(QStringLiteral("providers/proxyToken"), token); +} + +QString AppSettings::rawgApiKey() const { + return m_s.value(QStringLiteral("providers/rawgKey")).toString(); +} +void AppSettings::setRawgApiKey(const QString &key) { + m_s.setValue(QStringLiteral("providers/rawgKey"), key); +} + +bool AppSettings::autoFetchCovers() const { + return m_s.value(QStringLiteral("providers/autoCovers"), true).toBool(); +} +void AppSettings::setAutoFetchCovers(bool on) { + m_s.setValue(QStringLiteral("providers/autoCovers"), on); +} + +int AppSettings::cardWidth() const { + return m_s.value(QStringLiteral("view/cardWidth"), 180).toInt(); +} +void AppSettings::setCardWidth(int px) { + if (cardWidth() == px) return; + m_s.setValue(QStringLiteral("view/cardWidth"), px); + emit cardSizeChanged(px); +} + +QString AppSettings::defaultView() const { + return m_s.value(QStringLiteral("view/default"), + QStringLiteral("grid")).toString(); +} +void AppSettings::setDefaultView(const QString &v) { + m_s.setValue(QStringLiteral("view/default"), v); +} + +QString AppSettings::sortMode() const { + return m_s.value(QStringLiteral("view/sort"), + QStringLiteral("dateAdded")).toString(); +} +void AppSettings::setSortMode(const QString &v) { + m_s.setValue(QStringLiteral("view/sort"), v); +} + +} // namespace umt diff --git a/src/core/Settings.h b/src/core/Settings.h new file mode 100644 index 0000000..e84cd4c --- /dev/null +++ b/src/core/Settings.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include +#include + +namespace umt { + +// Thin, signal-emitting wrapper over QSettings for app-wide preferences. +// High customizability lives here: theme, accent colour, language, providers, +// card size, default view, etc. +class AppSettings : public QObject { + Q_OBJECT +public: + explicit AppSettings(QObject *parent = nullptr); + + // Theme + bool darkMode() const; + void setDarkMode(bool on); + + QColor accentColor() const; + void setAccentColor(const QColor &c); + + // Localization + QString preferredLanguage() const; // ISO code, e.g. "de" + void setPreferredLanguage(const QString &code); + + // Providers / API keys + // How TMDB requests are authorized: "key" (direct, own API key) or + // "proxy" (route through a shared OIDC-protected proxy server). + QString tmdbMode() const; // "key" | "proxy" + void setTmdbMode(const QString &mode); + + QString tmdbApiKey() const; + void setTmdbApiKey(const QString &key); + + // Proxy server base URL (e.g. https://tmdb.example.com) and the access token + // obtained from its OIDC login page. + QString proxyUrl() const; + void setProxyUrl(const QString &url); + QString proxyToken() const; + void setProxyToken(const QString &token); + + // Optional RAWG API key for video-game metadata (free key from rawg.io). + QString rawgApiKey() const; + void setRawgApiKey(const QString &key); + + bool autoFetchCovers() const; + void setAutoFetchCovers(bool on); + + // View preferences + int cardWidth() const; + void setCardWidth(int px); + + QString defaultView() const; // "grid" | "list" + void setDefaultView(const QString &v); + + QString sortMode() const; + void setSortMode(const QString &v); + +signals: + void themeChanged(); + void accentChanged(const QColor &c); + void cardSizeChanged(int px); + +private: + QSettings m_s; +}; + +} // namespace umt diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..e224b08 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,73 @@ +#include "core/Settings.h" +#include "core/Database.h" +#include "core/Enums.h" +#include "providers/ProviderManager.h" +#include "providers/ImageCache.h" +#include "ui/ThemeManager.h" +#include "ui/MainWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace umt; + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + QApplication::setApplicationName(QStringLiteral("Ultimate Media Tracker")); + QApplication::setOrganizationName(QStringLiteral("UMT")); + QApplication::setApplicationVersion(QStringLiteral("1.0.0")); + QApplication::setWindowIcon(QIcon(QStringLiteral(":/icons/appicon.svg"))); + QApplication::setDesktopFileName(QStringLiteral("ultimate-media-tracker")); + + qRegisterMetaType(); + qRegisterMetaType(); + + AppSettings settings; + + ThemeManager theme(&settings); + theme.apply(); + + // database location + const QString dataDir = QStandardPaths::writableLocation( + QStandardPaths::AppDataLocation); + QDir().mkpath(dataDir); + const QString dbPath = dataDir + QStringLiteral("/library.db"); + + // One-time migration: the app was formerly "UWT/Ultimate Watchlist + // Tracker", which placed the library under a differently-named data dir. + // Carry an existing database over so the rename does not lose the library. + if (!QFileInfo::exists(dbPath)) { + QString oldDir = dataDir; + oldDir.replace(QStringLiteral("Ultimate Media Tracker"), + QStringLiteral("Ultimate Watchlist Tracker")); + oldDir.replace(QStringLiteral("/UMT/"), QStringLiteral("/UWT/")); + const QString oldDb = oldDir + QStringLiteral("/library.db"); + if (oldDir != dataDir && QFileInfo::exists(oldDb)) + QFile::copy(oldDb, dbPath); + } + + Database db; + if (!db.open(dbPath)) { + QMessageBox::critical(nullptr, QStringLiteral("Fehler"), + QStringLiteral("Datenbank konnte nicht geöffnet werden:\n%1") + .arg(db.lastError())); + return 1; + } + + ImageCache cache; + ProviderManager providers(&settings); + + QObject::connect(&settings, &AppSettings::themeChanged, &theme, &ThemeManager::apply); + + MainWindow window(&db, &settings, &theme, &providers, &cache); + window.show(); + + return app.exec(); +} diff --git a/src/providers/AniListProvider.cpp b/src/providers/AniListProvider.cpp new file mode 100644 index 0000000..e589bc0 --- /dev/null +++ b/src/providers/AniListProvider.cpp @@ -0,0 +1,130 @@ +#include "providers/AniListProvider.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +AniListProvider::AniListProvider(QObject *parent) + : MetadataProvider(parent) + , m_nam(new QNetworkAccessManager(this)) +{ +} + +void AniListProvider::search(const QString &query, MediaType) +{ + static const QString gql = QStringLiteral(R"( +query ($q: String) { + Page(perPage: 25) { + media(search: $q, type: MANGA, sort: SEARCH_MATCH) { + id + title { romaji english native } + description(asHtml: false) + coverImage { large } + genres + chapters + volumes + startDate { year } + averageScore + } + } +})"); + + QJsonObject variables; + variables.insert(QStringLiteral("q"), query); + QJsonObject body; + body.insert(QStringLiteral("query"), gql); + body.insert(QStringLiteral("variables"), variables); + + QNetworkRequest req(QUrl(QStringLiteral("https://graphql.anilist.co"))); + req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + req.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("UltimateMediaTracker/1.0")); + + QNetworkReply *r = m_nam->post(req, QJsonDocument(body).toJson(QJsonDocument::Compact)); + connect(r, &QNetworkReply::finished, this, [this, r]() { + r->deleteLater(); + if (r->error() != QNetworkReply::NoError) { + emit failed(QStringLiteral("AniList: ") + r->errorString()); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(r->readAll()); + QVector out; + const QJsonArray media = doc.object() + .value(QStringLiteral("data")).toObject() + .value(QStringLiteral("Page")).toObject() + .value(QStringLiteral("media")).toArray(); + + for (const QJsonValue &v : media) { + const QJsonObject o = v.toObject(); + SearchResult res; + res.type = MediaType::Manga; + res.source = name(); + res.externalId = QString::number(o.value(QStringLiteral("id")).toInt()); + + const QJsonObject t = o.value(QStringLiteral("title")).toObject(); + const QString romaji = t.value(QStringLiteral("romaji")).toString(); + const QString english = t.value(QStringLiteral("english")).toString(); + const QString native = t.value(QStringLiteral("native")).toString(); + res.title = !english.isEmpty() ? english + : !romaji.isEmpty() ? romaji : native; + res.originalTitle = native; + if (!romaji.isEmpty()) res.localizedTitles.insert(QStringLiteral("romaji"), romaji); + if (!english.isEmpty()) res.localizedTitles.insert(QStringLiteral("en"), english); + if (!native.isEmpty()) res.localizedTitles.insert(QStringLiteral("ja"), native); + + QString desc = o.value(QStringLiteral("description")).toString(); + desc.replace(QStringLiteral("
"), QStringLiteral("\n")); + desc.remove(QRegularExpression(QStringLiteral("<[^>]*>"))); + res.overview = desc; + + res.year = o.value(QStringLiteral("startDate")).toObject() + .value(QStringLiteral("year")).toInt(); + res.externalRating = o.value(QStringLiteral("averageScore")).toDouble() / 10.0; + + const QJsonArray genres = o.value(QStringLiteral("genres")).toArray(); + for (const QJsonValue &g : genres) res.genres << g.toString(); + + res.coverUrl = o.value(QStringLiteral("coverImage")).toObject() + .value(QStringLiteral("large")).toString(); + + const int chapters = o.value(QStringLiteral("chapters")).toInt(); + const int volumes = o.value(QStringLiteral("volumes")).toInt(); + if (volumes > 0 && chapters > 0) { + // distribute chapters across volumes as evenly as possible + const int base = chapters / volumes; + const int rem = chapters % volumes; + for (int vol = 1; vol <= volumes; ++vol) { + ResultSegment seg; + seg.number = vol; + seg.title = QStringLiteral("Band %1").arg(vol); + seg.unitCount = base + (vol <= rem ? 1 : 0); + res.segments.push_back(seg); + } + } else if (chapters > 0) { + ResultSegment seg; + seg.number = 1; + seg.title = QStringLiteral("Kapitel"); + seg.unitCount = chapters; + res.segments.push_back(seg); + } else if (volumes > 0) { + for (int vol = 1; vol <= volumes; ++vol) { + ResultSegment seg; + seg.number = vol; + seg.title = QStringLiteral("Band %1").arg(vol); + seg.unitCount = 0; + res.segments.push_back(seg); + } + } + out.push_back(res); + } + emit searchFinished(out); + }); +} + +} // namespace umt diff --git a/src/providers/AniListProvider.h b/src/providers/AniListProvider.h new file mode 100644 index 0000000..dbec771 --- /dev/null +++ b/src/providers/AniListProvider.h @@ -0,0 +1,25 @@ +#pragma once + +#include "providers/MetadataProvider.h" + +class QNetworkAccessManager; + +namespace umt { + +// AniList GraphQL provider for Manga (and could serve Anime). +// No API key required. +class AniListProvider : public MetadataProvider { + Q_OBJECT +public: + explicit AniListProvider(QObject *parent = nullptr); + + QString name() const override { return QStringLiteral("AniList"); } + bool supports(MediaType type) const override { return type == MediaType::Manga; } + + void search(const QString &query, MediaType type) override; + +private: + QNetworkAccessManager *m_nam; +}; + +} // namespace umt diff --git a/src/providers/ImageCache.cpp b/src/providers/ImageCache.cpp new file mode 100644 index 0000000..8b0a682 --- /dev/null +++ b/src/providers/ImageCache.cpp @@ -0,0 +1,125 @@ +#include "providers/ImageCache.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +ImageCache::ImageCache(QObject *parent) + : QObject(parent) + , m_nam(new QNetworkAccessManager(this)) +{ + connect(m_nam, &QNetworkAccessManager::finished, + this, &ImageCache::onFinished); + QDir().mkpath(coversDir()); +} + +QString ImageCache::coversDir() { + const QString base = QStandardPaths::writableLocation( + QStandardPaths::AppDataLocation); + return base + QStringLiteral("/covers"); +} + +QString ImageCache::cachePathFor(const QString &url) const { + const QByteArray hash = QCryptographicHash::hash( + url.toUtf8(), QCryptographicHash::Sha1).toHex(); + const QString cacheRoot = QStandardPaths::writableLocation( + QStandardPaths::CacheLocation) + QStringLiteral("/img"); + QDir().mkpath(cacheRoot); + return cacheRoot + QStringLiteral("/") + QString::fromLatin1(hash) + QStringLiteral(".img"); +} + +QPixmap ImageCache::loadLocal(const QString &path) { + if (path.isEmpty()) return {}; + QString p = path; + QFileInfo fi(p); + if (fi.isRelative()) + p = coversDir() + QStringLiteral("/") + path; + QPixmap pm; + pm.load(p); + return pm; +} + +QPixmap ImageCache::get(const QString &url) { + if (url.isEmpty()) return {}; + if (m_mem.contains(url)) return m_mem.value(url); + + const QString cp = cachePathFor(url); + if (QFile::exists(cp)) { + QPixmap pm; + if (pm.load(cp)) { + m_mem.insert(url, pm); + return pm; + } + } + // not cached -> fetch + QNetworkRequest req{QUrl(url)}; + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + req.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("UltimateMediaTracker/1.0")); + QNetworkReply *r = m_nam->get(req); + m_pending.insert(r, url); + m_toLibrary.insert(r, false); + return {}; +} + +void ImageCache::downloadToLibrary(const QString &url) { + if (url.isEmpty()) return; + QNetworkRequest req{QUrl(url)}; + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + req.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("UltimateMediaTracker/1.0")); + QNetworkReply *r = m_nam->get(req); + m_pending.insert(r, url); + m_toLibrary.insert(r, true); +} + +void ImageCache::onFinished(QNetworkReply *reply) { + reply->deleteLater(); + const QString url = m_pending.take(reply); + const bool toLibrary = m_toLibrary.take(reply); + + if (reply->error() != QNetworkReply::NoError) { + emit failed(url, reply->errorString()); + return; + } + const QByteArray data = reply->readAll(); + QPixmap pm; + if (!pm.loadFromData(data)) { + emit failed(url, QStringLiteral("Bild konnte nicht dekodiert werden")); + return; + } + + // always populate the volatile cache + QFile cf(cachePathFor(url)); + if (cf.open(QIODevice::WriteOnly)) { cf.write(data); cf.close(); } + m_mem.insert(url, pm); + emit ready(url, pm); + + if (toLibrary) { + QString ext = QFileInfo(QUrl(url).path()).suffix(); + if (ext.isEmpty()) ext = QStringLiteral("jpg"); + const QString name = QUuid::createUuid().toString(QUuid::Id128) + + QStringLiteral(".") + ext; + const QString dest = coversDir() + QStringLiteral("/") + name; + QFile out(dest); + if (out.open(QIODevice::WriteOnly)) { + out.write(data); + out.close(); + emit saved(name); // store library-relative name + } else { + emit failed(url, QStringLiteral("Cover konnte nicht gespeichert werden")); + } + } +} + +} // namespace umt diff --git a/src/providers/ImageCache.h b/src/providers/ImageCache.h new file mode 100644 index 0000000..56130b1 --- /dev/null +++ b/src/providers/ImageCache.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +namespace umt { + +// Downloads and caches remote cover images on disk + in memory. +// Emits ready() when an async download completes. +class ImageCache : public QObject { + Q_OBJECT +public: + explicit ImageCache(QObject *parent = nullptr); + + // Returns a cached pixmap immediately if available, otherwise starts a + // download and returns a null pixmap; ready() fires later with the url. + QPixmap get(const QString &url); + + // Downloads url and saves a permanent copy into the covers dir. + // Emits saved(localPath) on success. + void downloadToLibrary(const QString &url); + + // Resolve a stored cover (absolute path or library-relative) to a pixmap. + static QPixmap loadLocal(const QString &path); + + static QString coversDir(); + +signals: + void ready(const QString &url, const QPixmap &pixmap); + void saved(const QString &localPath); + void failed(const QString &url, const QString &error); + +private slots: + void onFinished(QNetworkReply *reply); + +private: + QString cachePathFor(const QString &url) const; + + QNetworkAccessManager *m_nam; + QHash m_mem; + QHash m_pending; // reply -> url + QHash m_toLibrary; // reply -> permanent save +}; + +} // namespace umt diff --git a/src/providers/MetadataProvider.cpp b/src/providers/MetadataProvider.cpp new file mode 100644 index 0000000..421debc --- /dev/null +++ b/src/providers/MetadataProvider.cpp @@ -0,0 +1,6 @@ +#include "providers/MetadataProvider.h" + +// Interface translation unit. Concrete providers live in their own files. + +namespace umt { +} // namespace umt diff --git a/src/providers/MetadataProvider.h b/src/providers/MetadataProvider.h new file mode 100644 index 0000000..42243cc --- /dev/null +++ b/src/providers/MetadataProvider.h @@ -0,0 +1,62 @@ +#pragma once + +#include "core/Enums.h" + +#include +#include +#include +#include +#include + +namespace umt { + +// A lightweight description of a season/volume returned by a provider, +// so the edit dialog can pre-build the episode/chapter tree. +struct ResultSegment { + int number = 0; + QString title; + int unitCount = 0; // episodes / chapters in this segment +}; + +// One hit from an online metadata search. +struct SearchResult { + MediaType type = MediaType::Movie; + QString title; + QString originalTitle; + QString overview; + QString coverUrl; + int year = 0; + QString externalId; + QString source; // "TMDB" | "OpenLibrary" | "AniList" + QStringList genres; + QMap localizedTitles; // lang -> title + QVector segments; // optional season/volume layout + double externalRating = 0.0; // 0..10 +}; + +// Abstract async provider. Each concrete provider talks to one web API. +class MetadataProvider : public QObject { + Q_OBJECT +public: + explicit MetadataProvider(QObject *parent = nullptr) : QObject(parent) {} + ~MetadataProvider() override = default; + + virtual QString name() const = 0; + virtual bool supports(MediaType type) const = 0; + + // Kicks off an async search. Results arrive via searchFinished(). + virtual void search(const QString &query, MediaType type) = 0; + + // Optionally enrich a chosen result (seasons/episodes, alt titles, ...). + // Default: immediately re-emit the same result via detailsFinished(). + virtual void fetchDetails(const SearchResult &partial) { + emit detailsFinished(partial); + } + +signals: + void searchFinished(const QVector &results); + void detailsFinished(const SearchResult &result); + void failed(const QString &message); +}; + +} // namespace umt diff --git a/src/providers/OpenLibraryProvider.cpp b/src/providers/OpenLibraryProvider.cpp new file mode 100644 index 0000000..d3f69b0 --- /dev/null +++ b/src/providers/OpenLibraryProvider.cpp @@ -0,0 +1,75 @@ +#include "providers/OpenLibraryProvider.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +OpenLibraryProvider::OpenLibraryProvider(QObject *parent) + : MetadataProvider(parent) + , m_nam(new QNetworkAccessManager(this)) +{ +} + +void OpenLibraryProvider::search(const QString &query, MediaType) +{ + QUrl url(QStringLiteral("https://openlibrary.org/search.json")); + QUrlQuery q; + q.addQueryItem(QStringLiteral("q"), query); + q.addQueryItem(QStringLiteral("limit"), QStringLiteral("25")); + q.addQueryItem(QStringLiteral("fields"), + QStringLiteral("title,author_name,first_publish_year,cover_i," + "subject,language,first_sentence,key")); + url.setQuery(q); + + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("UltimateMediaTracker/1.0")); + QNetworkReply *r = m_nam->get(req); + connect(r, &QNetworkReply::finished, this, [this, r]() { + r->deleteLater(); + if (r->error() != QNetworkReply::NoError) { + emit failed(QStringLiteral("OpenLibrary: ") + r->errorString()); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(r->readAll()); + QVector out; + const QJsonArray docs = doc.object().value(QStringLiteral("docs")).toArray(); + for (const QJsonValue &v : docs) { + const QJsonObject o = v.toObject(); + SearchResult res; + res.type = MediaType::Book; + res.source = name(); + res.title = o.value(QStringLiteral("title")).toString(); + res.year = o.value(QStringLiteral("first_publish_year")).toInt(); + res.externalId = o.value(QStringLiteral("key")).toString(); + + const QJsonArray authors = o.value(QStringLiteral("author_name")).toArray(); + if (!authors.isEmpty()) + res.overview = QStringLiteral("von ") + authors.first().toString(); + const QJsonArray fs = o.value(QStringLiteral("first_sentence")).toArray(); + if (!fs.isEmpty()) { + if (!res.overview.isEmpty()) res.overview += QStringLiteral("\n\n"); + res.overview += fs.first().toString(); + } + + const QJsonArray subjects = o.value(QStringLiteral("subject")).toArray(); + for (int i = 0; i < subjects.size() && i < 6; ++i) + res.genres << subjects[i].toString(); + + const int coverId = o.value(QStringLiteral("cover_i")).toInt(); + if (coverId > 0) + res.coverUrl = QStringLiteral("https://covers.openlibrary.org/b/id/%1-L.jpg") + .arg(coverId); + out.push_back(res); + } + emit searchFinished(out); + }); +} + +} // namespace umt diff --git a/src/providers/OpenLibraryProvider.h b/src/providers/OpenLibraryProvider.h new file mode 100644 index 0000000..14c3414 --- /dev/null +++ b/src/providers/OpenLibraryProvider.h @@ -0,0 +1,24 @@ +#pragma once + +#include "providers/MetadataProvider.h" + +class QNetworkAccessManager; + +namespace umt { + +// OpenLibrary provider for Books. No API key required. +class OpenLibraryProvider : public MetadataProvider { + Q_OBJECT +public: + explicit OpenLibraryProvider(QObject *parent = nullptr); + + QString name() const override { return QStringLiteral("OpenLibrary"); } + bool supports(MediaType type) const override { return type == MediaType::Book; } + + void search(const QString &query, MediaType type) override; + +private: + QNetworkAccessManager *m_nam; +}; + +} // namespace umt diff --git a/src/providers/ProviderManager.cpp b/src/providers/ProviderManager.cpp new file mode 100644 index 0000000..4f58926 --- /dev/null +++ b/src/providers/ProviderManager.cpp @@ -0,0 +1,74 @@ +#include "providers/ProviderManager.h" +#include "providers/TmdbProvider.h" +#include "providers/OpenLibraryProvider.h" +#include "providers/AniListProvider.h" +#include "providers/RawgProvider.h" +#include "core/Settings.h" + +namespace umt { + +ProviderManager::ProviderManager(AppSettings *settings, QObject *parent) + : QObject(parent) + , m_settings(settings) + , m_tmdb(new TmdbProvider(this)) + , m_openlib(new OpenLibraryProvider(this)) + , m_anilist(new AniListProvider(this)) + , m_rawg(new RawgProvider(this)) +{ + const QVector all{m_tmdb, m_openlib, m_anilist, m_rawg}; + for (MetadataProvider *p : all) { + connect(p, &MetadataProvider::searchFinished, + this, &ProviderManager::searchFinished); + connect(p, &MetadataProvider::detailsFinished, + this, &ProviderManager::detailsFinished); + connect(p, &MetadataProvider::failed, + this, &ProviderManager::failed); + } + refreshConfig(); +} + +void ProviderManager::refreshConfig() +{ + m_tmdb->setApiKey(m_settings->tmdbApiKey()); + if (m_settings->tmdbMode() == QLatin1String("proxy") && + !m_settings->proxyUrl().trimmed().isEmpty()) + m_tmdb->setProxy(m_settings->proxyUrl().trimmed(), m_settings->proxyToken().trimmed()); + else + m_tmdb->clearProxy(); + const QString lang = m_settings->preferredLanguage(); + // TMDB expects locale-style codes; map a bare language to a sensible region. + QString tmdbLang = lang; + if (lang == QLatin1String("de")) tmdbLang = QStringLiteral("de-DE"); + else if (lang == QLatin1String("en")) tmdbLang = QStringLiteral("en-US"); + else if (lang == QLatin1String("ja")) tmdbLang = QStringLiteral("ja-JP"); + m_tmdb->setLanguage(tmdbLang); + + m_rawg->setApiKey(m_settings->rawgApiKey()); +} + +MetadataProvider *ProviderManager::providerFor(MediaType type) const +{ + if (m_tmdb->supports(type)) return m_tmdb; + if (m_anilist->supports(type)) return m_anilist; + if (m_openlib->supports(type)) return m_openlib; + if (m_rawg->supports(type)) return m_rawg; + return nullptr; +} + +void ProviderManager::search(const QString &query, MediaType type) +{ + if (auto *p = providerFor(type)) + p->search(query, type); + else + emit failed(QStringLiteral("Kein Provider für diesen Medientyp.")); +} + +void ProviderManager::fetchDetails(const SearchResult &partial) +{ + if (auto *p = providerFor(partial.type)) + p->fetchDetails(partial); + else + emit detailsFinished(partial); +} + +} // namespace umt diff --git a/src/providers/ProviderManager.h b/src/providers/ProviderManager.h new file mode 100644 index 0000000..03c0049 --- /dev/null +++ b/src/providers/ProviderManager.h @@ -0,0 +1,45 @@ +#pragma once + +#include "providers/MetadataProvider.h" + +#include +#include + +namespace umt { + +class AppSettings; +class TmdbProvider; +class OpenLibraryProvider; +class AniListProvider; +class RawgProvider; + +// Routes a search to the provider that handles the requested media type and +// re-broadcasts its results. Keeps provider configuration (keys, language) +// in sync with settings. +class ProviderManager : public QObject { + Q_OBJECT +public: + explicit ProviderManager(AppSettings *settings, QObject *parent = nullptr); + + void search(const QString &query, MediaType type); + void fetchDetails(const SearchResult &partial); + + // Re-reads API key / language from settings. + void refreshConfig(); + +signals: + void searchFinished(const QVector &results); + void detailsFinished(const SearchResult &result); + void failed(const QString &message); + +private: + MetadataProvider *providerFor(MediaType type) const; + + AppSettings *m_settings; + TmdbProvider *m_tmdb; + OpenLibraryProvider *m_openlib; + AniListProvider *m_anilist; + RawgProvider *m_rawg; +}; + +} // namespace umt diff --git a/src/providers/ProxyLogin.cpp b/src/providers/ProxyLogin.cpp new file mode 100644 index 0000000..15a44ca --- /dev/null +++ b/src/providers/ProxyLogin.cpp @@ -0,0 +1,155 @@ +#include "providers/ProxyLogin.h" + +#include +#include +#include +#include +#include +#include + +namespace umt { + +namespace { +// Minimal HTML responses shown in the user's browser after the redirect. +QByteArray httpResponse(const QString &body) +{ + const QByteArray utf8 = body.toUtf8(); + QByteArray out; + out += "HTTP/1.1 200 OK\r\n"; + out += "Content-Type: text/html; charset=utf-8\r\n"; + out += "Content-Length: " + QByteArray::number(utf8.size()) + "\r\n"; + out += "Connection: close\r\n\r\n"; + out += utf8; + return out; +} +} // namespace + +ProxyLogin::ProxyLogin(QObject *parent) + : QObject(parent) + , m_server(new QTcpServer(this)) + , m_timeout(new QTimer(this)) +{ + m_timeout->setSingleShot(true); + connect(m_timeout, &QTimer::timeout, this, [this]() { + finishFail(QStringLiteral("Zeitüberschreitung – es kam kein Token an. " + "Bitte erneut versuchen.")); + }); + connect(m_server, &QTcpServer::newConnection, this, &ProxyLogin::onConnection); +} + +ProxyLogin::~ProxyLogin() = default; + +bool ProxyLogin::start(const QString &proxyUrl) +{ + QString base = proxyUrl.trimmed(); + if (base.isEmpty()) { + finishFail(QStringLiteral("Bitte zuerst eine Proxy-URL eintragen.")); + return false; + } + // Default to https:// when no scheme was given, and strip a trailing slash. + if (!base.contains(QStringLiteral("://"))) + base.prepend(QStringLiteral("https://")); + while (base.endsWith(QLatin1Char('/'))) + base.chop(1); + + const QUrl baseUrl(base); + if (!baseUrl.isValid() || baseUrl.host().isEmpty()) { + finishFail(QStringLiteral("Die Proxy-URL ist ungültig.")); + return false; + } + + if (!m_server->listen(QHostAddress::LocalHost, 0)) { + finishFail(QStringLiteral("Lokaler Login-Server konnte nicht gestartet werden: ") + + m_server->errorString()); + return false; + } + + const quint16 port = m_server->serverPort(); + const QString callback = QStringLiteral("http://127.0.0.1:%1/").arg(port); + + QUrl loginUrl(base + QStringLiteral("/login")); + QUrlQuery q; + q.addQueryItem(QStringLiteral("app_redirect"), callback); + loginUrl.setQuery(q); + + if (!QDesktopServices::openUrl(loginUrl)) { + finishFail(QStringLiteral("Browser konnte nicht geöffnet werden.")); + return false; + } + + m_timeout->start(300 * 1000); // 5 minutes to complete the login. + return true; +} + +void ProxyLogin::cancel() +{ + if (m_done) + return; + m_done = true; + m_timeout->stop(); + m_server->close(); +} + +void ProxyLogin::finishFail(const QString &message) +{ + if (m_done) + return; + m_done = true; + m_timeout->stop(); + m_server->close(); + emit failed(message); +} + +void ProxyLogin::onConnection() +{ + QTcpSocket *sock = m_server->nextPendingConnection(); + if (!sock) + return; + + connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater); + connect(sock, &QTcpSocket::readyRead, this, [this, sock]() { + const QByteArray data = sock->readAll(); + // First line: "GET /?token=... HTTP/1.1" + const int sp1 = data.indexOf(' '); + const int sp2 = sp1 >= 0 ? data.indexOf(' ', sp1 + 1) : -1; + if (sp1 < 0 || sp2 < 0) { + sock->disconnectFromHost(); + return; + } + const QString target = QString::fromUtf8(data.mid(sp1 + 1, sp2 - sp1 - 1)); + const QUrl url(QStringLiteral("http://127.0.0.1") + target); + const QString token = + QUrlQuery(url.query()).queryItemValue(QStringLiteral("token")); + + if (token.isEmpty()) { + // Stray request (e.g. /favicon.ico) – acknowledge and keep waiting. + sock->write("HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n"); + sock->disconnectFromHost(); + return; + } + + if (m_done) { + sock->disconnectFromHost(); + return; + } + m_done = true; + m_timeout->stop(); + + sock->write(httpResponse(QStringLiteral( + "" + "Anmeldung erfolgreich" + "" + "

Anmeldung erfolgreich

" + "

Du kannst dieses Fenster jetzt schließen und zur " + "App zurückkehren.

"))); + sock->flush(); + sock->disconnectFromHost(); + + m_server->close(); + emit succeeded(token); + }); +} + +} // namespace umt diff --git a/src/providers/ProxyLogin.h b/src/providers/ProxyLogin.h new file mode 100644 index 0000000..540a64d --- /dev/null +++ b/src/providers/ProxyLogin.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +class QTcpServer; +class QTimer; + +namespace umt { + +// Drives a browser-based OIDC login against the TMDB proxy and captures the +// resulting token automatically via a loopback redirect. +// +// Flow: start() opens a local listener on 127.0.0.1:, then launches +// the system browser at /login?app_redirect=http://127.0.0.1:/. +// The proxy runs the user's OIDC provider (Authentik) and, on success, redirects +// the browser back to the loopback URL with a ?token=... query. We read that +// token off the incoming HTTP request and emit succeeded(). +class ProxyLogin : public QObject { + Q_OBJECT +public: + explicit ProxyLogin(QObject *parent = nullptr); + ~ProxyLogin() override; + + // Begins listening and opens the browser. Returns false if the proxy URL is + // unusable or the local listener cannot be started (failed() is also emitted). + bool start(const QString &proxyUrl); + void cancel(); + +signals: + void succeeded(const QString &token); + void failed(const QString &message); + +private: + void onConnection(); + void finishFail(const QString &message); + + QTcpServer *m_server; + QTimer *m_timeout; + bool m_done = false; +}; + +} // namespace umt diff --git a/src/providers/RawgProvider.cpp b/src/providers/RawgProvider.cpp new file mode 100644 index 0000000..a0170bf --- /dev/null +++ b/src/providers/RawgProvider.cpp @@ -0,0 +1,75 @@ +#include "providers/RawgProvider.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +RawgProvider::RawgProvider(QObject *parent) + : MetadataProvider(parent) + , m_nam(new QNetworkAccessManager(this)) +{ +} + +void RawgProvider::search(const QString &query, MediaType) +{ + if (m_apiKey.trimmed().isEmpty()) { + emit failed(QStringLiteral( + "Für Spiele bitte einen kostenlosen RAWG API-Key in den " + "Einstellungen hinterlegen (rawg.io/apidocs).")); + return; + } + + QUrl url(QStringLiteral("https://api.rawg.io/api/games")); + QUrlQuery q; + q.addQueryItem(QStringLiteral("key"), m_apiKey.trimmed()); + q.addQueryItem(QStringLiteral("search"), query); + q.addQueryItem(QStringLiteral("page_size"), QStringLiteral("25")); + url.setQuery(q); + + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("UltimateMediaTracker/1.0")); + QNetworkReply *r = m_nam->get(req); + connect(r, &QNetworkReply::finished, this, [this, r]() { + r->deleteLater(); + if (r->error() != QNetworkReply::NoError) { + emit failed(QStringLiteral("RAWG: ") + r->errorString()); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(r->readAll()); + QVector out; + const QJsonArray games = doc.object().value(QStringLiteral("results")).toArray(); + for (const QJsonValue &v : games) { + const QJsonObject o = v.toObject(); + SearchResult res; + res.type = MediaType::Game; + res.source = name(); + res.title = o.value(QStringLiteral("name")).toString(); + res.externalId = QString::number(o.value(QStringLiteral("id")).toInt()); + + // "released" is an ISO date like "2017-03-03"; take the year prefix. + const QString released = o.value(QStringLiteral("released")).toString(); + if (released.size() >= 4) + res.year = released.left(4).toInt(); + + res.coverUrl = o.value(QStringLiteral("background_image")).toString(); + res.externalRating = o.value(QStringLiteral("rating")).toDouble(); + + const QJsonArray genres = o.value(QStringLiteral("genres")).toArray(); + for (const QJsonValue &gv : genres) { + const QString g = gv.toObject().value(QStringLiteral("name")).toString(); + if (!g.isEmpty()) res.genres << g; + } + out.push_back(res); + } + emit searchFinished(out); + }); +} + +} // namespace umt diff --git a/src/providers/RawgProvider.h b/src/providers/RawgProvider.h new file mode 100644 index 0000000..d1c4e40 --- /dev/null +++ b/src/providers/RawgProvider.h @@ -0,0 +1,30 @@ +#pragma once + +#include "providers/MetadataProvider.h" + +#include + +class QNetworkAccessManager; + +namespace umt { + +// RAWG provider for video games. Requires a free API key from rawg.io +// (https://rawg.io/apidocs). Without a key, searches fail with a hint. +class RawgProvider : public MetadataProvider { + Q_OBJECT +public: + explicit RawgProvider(QObject *parent = nullptr); + + QString name() const override { return QStringLiteral("RAWG"); } + bool supports(MediaType type) const override { return type == MediaType::Game; } + + void search(const QString &query, MediaType type) override; + + void setApiKey(const QString &key) { m_apiKey = key; } + +private: + QNetworkAccessManager *m_nam; + QString m_apiKey; +}; + +} // namespace umt diff --git a/src/providers/TmdbProvider.cpp b/src/providers/TmdbProvider.cpp new file mode 100644 index 0000000..842046b --- /dev/null +++ b/src/providers/TmdbProvider.cpp @@ -0,0 +1,133 @@ +#include "providers/TmdbProvider.h" + +#include +#include +#include +#include +#include +#include + +namespace umt { + +TmdbProvider::TmdbProvider(QObject *parent) + : MetadataProvider(parent) + , m_nam(new QNetworkAccessManager(this)) +{ +} + +void TmdbProvider::getJson(const QString &path, + const QList> ¶ms, + std::function cb) +{ + QString base = QStringLiteral("https://api.themoviedb.org/3"); + if (!m_proxyBase.isEmpty()) { + base = m_proxyBase; + while (base.endsWith(QLatin1Char('/'))) base.chop(1); + base += QStringLiteral("/3"); + } + QUrl url(base + path); + QUrlQuery q; + // In proxy mode the server injects the real API key; never leak ours. + if (m_proxyBase.isEmpty()) + q.addQueryItem(QStringLiteral("api_key"), m_apiKey); + q.addQueryItem(QStringLiteral("language"), m_language); + for (const auto &p : params) + q.addQueryItem(p.first, p.second); + url.setQuery(q); + + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("UltimateMediaTracker/1.0")); + if (!m_proxyBase.isEmpty() && !m_bearer.isEmpty()) + req.setRawHeader("Authorization", + QByteArrayLiteral("Bearer ") + m_bearer.toUtf8()); + QNetworkReply *r = m_nam->get(req); + connect(r, &QNetworkReply::finished, this, [this, r, cb]() { + r->deleteLater(); + if (r->error() != QNetworkReply::NoError) { + emit failed(QStringLiteral("TMDB: ") + r->errorString()); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(r->readAll()); + cb(doc); + }); +} + +void TmdbProvider::search(const QString &query, MediaType type) +{ + if (m_proxyBase.isEmpty() && m_apiKey.isEmpty()) { + emit failed(QStringLiteral("Kein TMDB API-Key oder Proxy konfiguriert (Einstellungen).")); + return; + } + const bool movie = (type == MediaType::Movie); + const QString path = movie ? QStringLiteral("/search/movie") + : QStringLiteral("/search/tv"); + getJson(path, {{QStringLiteral("query"), query}, + {QStringLiteral("include_adult"), QStringLiteral("false")}}, + [this, movie](const QJsonDocument &doc) { + QVector out; + const QJsonArray arr = doc.object().value(QStringLiteral("results")).toArray(); + for (const QJsonValue &v : arr) { + const QJsonObject o = v.toObject(); + SearchResult r; + r.type = movie ? MediaType::Movie : MediaType::Series; + r.source = name(); + r.externalId = QString::number(o.value(QStringLiteral("id")).toInt()); + if (movie) { + r.title = o.value(QStringLiteral("title")).toString(); + r.originalTitle = o.value(QStringLiteral("original_title")).toString(); + const QString d = o.value(QStringLiteral("release_date")).toString(); + if (d.size() >= 4) r.year = d.left(4).toInt(); + } else { + r.title = o.value(QStringLiteral("name")).toString(); + r.originalTitle = o.value(QStringLiteral("original_name")).toString(); + const QString d = o.value(QStringLiteral("first_air_date")).toString(); + if (d.size() >= 4) r.year = d.left(4).toInt(); + } + r.overview = o.value(QStringLiteral("overview")).toString(); + const QString poster = o.value(QStringLiteral("poster_path")).toString(); + if (!poster.isEmpty()) r.coverUrl = imageBase() + poster; + r.externalRating = o.value(QStringLiteral("vote_average")).toDouble(); + if (!r.originalTitle.isEmpty()) + r.localizedTitles.insert(QStringLiteral("original"), r.originalTitle); + out.push_back(r); + } + emit searchFinished(out); + }); +} + +void TmdbProvider::fetchDetails(const SearchResult &partial) +{ + if (partial.type == MediaType::Movie || + (m_proxyBase.isEmpty() && m_apiKey.isEmpty())) { + emit detailsFinished(partial); + return; + } + // TV: fetch seasons + episode counts and alternative titles. + getJson(QStringLiteral("/tv/") + partial.externalId, {}, + [this, partial](const QJsonDocument &doc) { + SearchResult r = partial; + const QJsonObject o = doc.object(); + + // genres + const QJsonArray genres = o.value(QStringLiteral("genres")).toArray(); + for (const QJsonValue &g : genres) + r.genres << g.toObject().value(QStringLiteral("name")).toString(); + + // seasons + const QJsonArray seasons = o.value(QStringLiteral("seasons")).toArray(); + for (const QJsonValue &sv : seasons) { + const QJsonObject so = sv.toObject(); + const int num = so.value(QStringLiteral("season_number")).toInt(); + if (num <= 0) continue; // skip "Specials" + ResultSegment seg; + seg.number = num; + seg.title = so.value(QStringLiteral("name")).toString(); + seg.unitCount = so.value(QStringLiteral("episode_count")).toInt(); + r.segments.push_back(seg); + } + emit detailsFinished(r); + }); +} + +} // namespace umt diff --git a/src/providers/TmdbProvider.h b/src/providers/TmdbProvider.h new file mode 100644 index 0000000..2256061 --- /dev/null +++ b/src/providers/TmdbProvider.h @@ -0,0 +1,56 @@ +#pragma once + +#include "providers/MetadataProvider.h" + +#include +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; + +namespace umt { + +// The Movie Database provider. Handles Movies and Series. +// Requires a free API key (v3) configured in settings. +class TmdbProvider : public MetadataProvider { + Q_OBJECT +public: + explicit TmdbProvider(QObject *parent = nullptr); + + QString name() const override { return QStringLiteral("TMDB"); } + bool supports(MediaType type) const override { + return type == MediaType::Movie || type == MediaType::Series; + } + + void setApiKey(const QString &key) { m_apiKey = key; } + void setLanguage(const QString &lang) { m_language = lang; } + + // Route requests through a proxy instead of api.themoviedb.org directly. + // When a proxy is set the api_key query param is omitted (the proxy injects + // its own) and the bearer token (if any) is sent for OIDC authorization. + void setProxy(const QString &baseUrl, const QString &bearerToken) { + m_proxyBase = baseUrl; + m_bearer = bearerToken; + } + void clearProxy() { m_proxyBase.clear(); m_bearer.clear(); } + bool usingProxy() const { return !m_proxyBase.isEmpty(); } + + void search(const QString &query, MediaType type) override; + void fetchDetails(const SearchResult &partial) override; + + static QString imageBase() { return QStringLiteral("https://image.tmdb.org/t/p/w500"); } + +private: + void getJson(const QString &path, const QList> ¶ms, + std::function cb); + + QNetworkAccessManager *m_nam; + QString m_apiKey; + QString m_language = QStringLiteral("de-DE"); + QString m_proxyBase; // empty = talk to TMDB directly + QString m_bearer; // OIDC access token for the proxy +}; + +} // namespace umt diff --git a/src/ui/DetailDialog.cpp b/src/ui/DetailDialog.cpp new file mode 100644 index 0000000..83a0daf --- /dev/null +++ b/src/ui/DetailDialog.cpp @@ -0,0 +1,404 @@ +#include "ui/DetailDialog.h" +#include "ui/StarRating.h" +#include "ui/OnlineSearchDialog.h" +#include "core/Database.h" +#include "core/Settings.h" +#include "providers/ImageCache.h" +#include "providers/ProviderManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +enum { RoleKind = Qt::UserRole, RoleId = Qt::UserRole + 1 }; + +DetailDialog::DetailDialog(int itemId, Database *db, ImageCache *cache, + ProviderManager *providers, AppSettings *settings, + QWidget *parent) + : QDialog(parent) + , m_id(itemId), m_db(db), m_cache(cache) + , m_providers(providers), m_settings(settings) +{ + setWindowTitle(QStringLiteral("Details")); + resize(860, 660); + + auto *root = new QHBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + // ----- left column: cover + quick controls ----- + auto *left = new QWidget(this); + left->setFixedWidth(280); + auto *ll = new QVBoxLayout(left); + ll->setContentsMargins(18, 18, 12, 18); + ll->setSpacing(12); + + auto *backBtn = new QPushButton(QStringLiteral("← Zurück"), left); + backBtn->setStyleSheet(QStringLiteral("text-align:left;")); + ll->addWidget(backBtn); + + m_cover = new QLabel(left); + m_cover->setFixedSize(244, 354); + m_cover->setAlignment(Qt::AlignCenter); + ll->addWidget(m_cover, 0, Qt::AlignHCenter); + + auto *changeCover = new QPushButton(QStringLiteral("Cover ändern…"), left); + ll->addWidget(changeCover); + + m_status = new QComboBox(left); + const WatchStatus all[] = {WatchStatus::PlanToWatch, WatchStatus::InProgress, + WatchStatus::Completed, WatchStatus::OnHold, + WatchStatus::Dropped}; + for (WatchStatus s : all) m_status->addItem(statusLabel(s), int(s)); + ll->addWidget(m_status); + + m_rating = new StarRating(left); + ll->addWidget(m_rating, 0, Qt::AlignHCenter); + + m_progress = new QProgressBar(left); + m_progress->setTextVisible(false); + m_progress->setFixedHeight(8); + ll->addWidget(m_progress); + m_progressLbl = new QLabel(left); + m_progressLbl->setObjectName(QStringLiteral("Subtle")); + m_progressLbl->setAlignment(Qt::AlignHCenter); + ll->addWidget(m_progressLbl); + + ll->addStretch(1); + auto *editBtn = new QPushButton(QStringLiteral("Bearbeiten"), left); + editBtn->setObjectName(QStringLiteral("Primary")); + ll->addWidget(editBtn); + + root->addWidget(left); + + // ----- right column (scrollable) ----- + auto *scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + auto *right = new QWidget; + auto *rl = new QVBoxLayout(right); + m_rl = rl; + rl->setContentsMargins(18, 18, 18, 18); + rl->setSpacing(10); + + m_titleLbl = new QLabel(right); + m_titleLbl->setObjectName(QStringLiteral("H1")); + m_titleLbl->setWordWrap(true); + rl->addWidget(m_titleLbl); + + m_metaLbl = new QLabel(right); + m_metaLbl->setObjectName(QStringLiteral("Subtle")); + m_metaLbl->setWordWrap(true); + rl->addWidget(m_metaLbl); + + m_altTitlesLbl = new QLabel(right); + m_altTitlesLbl->setObjectName(QStringLiteral("Subtle")); + m_altTitlesLbl->setWordWrap(true); + m_altTitlesLbl->setTextInteractionFlags(Qt::TextSelectableByMouse); + rl->addWidget(m_altTitlesLbl); + + m_genresLbl = new QLabel(right); + m_genresLbl->setWordWrap(true); + rl->addWidget(m_genresLbl); + + auto *ovLbl = new QLabel(QStringLiteral("Beschreibung"), right); + ovLbl->setObjectName(QStringLiteral("H2")); + rl->addWidget(ovLbl); + m_overview = new QPlainTextEdit(right); + m_overview->setReadOnly(true); + m_overview->setMaximumHeight(130); + rl->addWidget(m_overview); + + m_treeLbl = new QLabel(QStringLiteral("Fortschritt"), right); + m_treeLbl->setObjectName(QStringLiteral("H2")); + rl->addWidget(m_treeLbl); + m_tree = new QTreeWidget(right); + m_tree->setHeaderHidden(true); + m_tree->setColumnCount(1); + m_tree->setMinimumHeight(180); + rl->addWidget(m_tree, 1); + + auto *notesLbl = new QLabel(QStringLiteral("Notizen"), right); + notesLbl->setObjectName(QStringLiteral("H2")); + rl->addWidget(notesLbl); + m_notes = new QPlainTextEdit(right); + m_notes->setPlaceholderText(QStringLiteral("Persönliche Notizen…")); + m_notes->setMaximumHeight(90); + rl->addWidget(m_notes); + + // Trailing spacer: collapsed when the progress tree is shown (the tree + // expands instead), but expands to pin everything to the top for items + // without a progress tree (movies, games). + m_bottomStretch = new QSpacerItem(0, 0, QSizePolicy::Minimum, QSizePolicy::Fixed); + rl->addItem(m_bottomStretch); + + scroll->setWidget(right); + root->addWidget(scroll, 1); + + // ----- wiring ----- + connect(changeCover, &QPushButton::clicked, this, &DetailDialog::changeCoverViaSearch); + connect(backBtn, &QPushButton::clicked, this, [this]{ + persistNotes(); + accept(); // -> MainWindow returns to library + }); + connect(editBtn, &QPushButton::clicked, this, [this]{ + // Switch straight to the edit panel; MainWindow swaps this page out. + persistNotes(); + emit editRequested(m_id); + }); + connect(m_status, &QComboBox::currentIndexChanged, this, [this]{ + if (m_loading) return; + const WatchStatus s = WatchStatus(m_status->currentData().toInt()); + m_db->setItemStatus(m_id, s); + m_item.status = s; + emit itemModified(); + }); + connect(m_rating, &StarRating::ratingChanged, this, [this](int r){ + if (m_loading) return; + m_db->setRating(m_id, r); + m_item.rating = r; + emit itemModified(); + }); + connect(m_tree, &QTreeWidget::itemChanged, this, &DetailDialog::onTreeItemChanged); + + reload(); +} + +static QPixmap rounded(const QPixmap &src, int w, int h, int radius) +{ + QPixmap out(w, h); + out.fill(Qt::transparent); + QPainter p(&out); + p.setRenderHint(QPainter::Antialiasing); + p.setRenderHint(QPainter::SmoothPixmapTransform); + QPainterPath path; + path.addRoundedRect(0, 0, w, h, radius, radius); + p.setClipPath(path); + if (!src.isNull()) { + QPixmap sc = src.scaled(w, h, Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + p.drawPixmap((w - sc.width()) / 2, (h - sc.height()) / 2, sc); + } else { + p.fillPath(path, QColor("#2c2c2e")); + } + return out; +} + +void DetailDialog::reload() +{ + auto opt = m_db->loadItem(m_id); + if (!opt) { reject(); return; } + m_item = *opt; + + m_loading = true; + + // cover + QPixmap pm; + if (!m_item.coverPath.isEmpty()) + pm = ImageCache::loadLocal(m_item.coverPath); + else if (!m_item.coverUrl.isEmpty()) { + pm = m_cache->get(m_item.coverUrl); + connect(m_cache, &ImageCache::ready, this, + [this](const QString &url, const QPixmap &p){ + if (url == m_item.coverUrl) + m_cover->setPixmap(rounded(p, 244, 354, 12)); + }); + } + if (pm.isNull()) pm = QPixmap(QStringLiteral(":/icons/placeholder.svg")); + m_cover->setPixmap(rounded(pm, 244, 354, 12)); + + const QString lang = m_settings->preferredLanguage(); + m_titleLbl->setText(m_item.bestTitleFor(lang)); + + QStringList meta; + meta << mediaTypeLabel(m_item.type); + if (m_item.year > 0) meta << QString::number(m_item.year); + if (!m_item.originalTitle.isEmpty() && m_item.originalTitle != m_item.title) + meta << QStringLiteral("Original: ") + m_item.originalTitle; + m_metaLbl->setText(meta.join(QStringLiteral(" · "))); + + if (!m_item.localizedTitles.isEmpty()) { + QStringList lt; + for (auto it = m_item.localizedTitles.cbegin(); + it != m_item.localizedTitles.cend(); ++it) + lt << QStringLiteral("%1: %2").arg(it.key(), it.value()); + m_altTitlesLbl->setText(QStringLiteral("Titel: ") + lt.join(QStringLiteral(" "))); + m_altTitlesLbl->show(); + } else { + m_altTitlesLbl->hide(); + } + + if (!m_item.genres.isEmpty() || !m_item.tags.isEmpty()) { + QStringList chips = m_item.genres + m_item.tags; + m_genresLbl->setText(QStringLiteral("🏷 ") + chips.join(QStringLiteral(" · "))); + m_genresLbl->show(); + } else { + m_genresLbl->hide(); + } + + m_overview->setPlainText(m_item.overview); + m_notes->setPlainText(m_item.notes); + m_status->setCurrentIndex(m_status->findData(int(m_item.status))); + m_rating->setRating(m_item.rating); + + buildTree(); + refreshProgress(); + + m_loading = false; +} + +void DetailDialog::buildTree() +{ + m_tree->blockSignals(true); + m_tree->clear(); + + if (!m_item.hasSegments()) { + m_treeLbl->setVisible(false); + m_tree->setVisible(false); + m_rl->setStretchFactor(m_tree, 0); + // Expand the trailing spacer so content stays pinned to the top. + m_bottomStretch->changeSize(0, 0, QSizePolicy::Minimum, QSizePolicy::Expanding); + m_rl->invalidate(); + m_tree->blockSignals(false); + return; + } + m_treeLbl->setVisible(true); + m_tree->setVisible(true); + m_rl->setStretchFactor(m_tree, 1); + // Collapse the trailing spacer so the tree gets the extra vertical space. + m_bottomStretch->changeSize(0, 0, QSizePolicy::Minimum, QSizePolicy::Fixed); + m_rl->invalidate(); + + const QString segL = segmentLabel(m_item.type); + const QString unitL = unitLabel(m_item.type); + + for (const Segment &seg : m_item.segments) { + auto *segItem = new QTreeWidgetItem(m_tree); + const QString name = seg.title.isEmpty() + ? QStringLiteral("%1 %2").arg(segL).arg(seg.number) + : QStringLiteral("%1 %2 — %3").arg(segL).arg(seg.number).arg(seg.title); + segItem->setText(0, name); + segItem->setData(0, RoleKind, QStringLiteral("segment")); + segItem->setData(0, RoleId, seg.id); + segItem->setFlags(segItem->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); + + for (const Unit &u : seg.units) { + auto *uItem = new QTreeWidgetItem(segItem); + const QString un = u.title.isEmpty() + ? QStringLiteral("%1 %2").arg(unitL).arg(u.number) + : QStringLiteral("%1 %2 — %3").arg(unitL).arg(u.number).arg(u.title); + uItem->setText(0, un); + uItem->setData(0, RoleKind, QStringLiteral("unit")); + uItem->setData(0, RoleId, u.id); + uItem->setFlags(uItem->flags() | Qt::ItemIsUserCheckable); + uItem->setCheckState(0, u.watched ? Qt::Checked : Qt::Unchecked); + } + } + m_tree->collapseAll(); + m_tree->blockSignals(false); +} + +void DetailDialog::onTreeItemChanged(QTreeWidgetItem *item, int) +{ + if (m_loading) return; + const QString kind = item->data(0, RoleKind).toString(); + const int id = item->data(0, RoleId).toInt(); + const bool watched = item->checkState(0) == Qt::Checked; + + if (kind == QLatin1String("unit")) { + m_db->setUnitWatched(id, watched); + } else if (kind == QLatin1String("segment")) { + // Auto-tristate already cascaded to children visually; persist the bulk. + if (item->checkState(0) != Qt::PartiallyChecked) + m_db->setSegmentWatched(id, watched); + } + + // recompute local model progress from the tree + int total = 0, done = 0; + for (int s = 0; s < m_tree->topLevelItemCount(); ++s) { + QTreeWidgetItem *seg = m_tree->topLevelItem(s); + for (int u = 0; u < seg->childCount(); ++u) { + ++total; + if (seg->child(u)->checkState(0) == Qt::Checked) ++done; + } + } + m_progress->setRange(0, total ? total : 1); + m_progress->setValue(done); + m_progressLbl->setText(QStringLiteral("%1 / %2 gesehen").arg(done).arg(total)); + + // auto-complete status when everything is watched + if (total > 0 && done == total && m_item.status != WatchStatus::Completed) { + m_loading = true; + m_status->setCurrentIndex(m_status->findData(int(WatchStatus::Completed))); + m_loading = false; + m_db->setItemStatus(m_id, WatchStatus::Completed); + m_item.status = WatchStatus::Completed; + } + emit itemModified(); +} + +void DetailDialog::refreshProgress() +{ + const int total = m_item.totalUnits(); + const int done = m_item.watchedUnits(); + if (total > 0) { + m_progress->show(); + m_progressLbl->show(); + m_progress->setRange(0, total); + m_progress->setValue(done); + m_progressLbl->setText(QStringLiteral("%1 / %2 gesehen").arg(done).arg(total)); + } else { + m_progress->hide(); + m_progressLbl->hide(); + } +} + +void DetailDialog::changeCoverViaSearch() +{ + OnlineSearchDialog dlg(m_providers, m_cache, m_item.type, this); + dlg.setQuery(m_item.title); + if (dlg.exec() != QDialog::Accepted) return; + const SearchResult res = dlg.selectedResult(); + if (res.coverUrl.isEmpty()) return; + + connect(m_cache, &ImageCache::saved, this, [this](const QString &localName){ + m_item.coverPath = localName; + m_item.coverUrl.clear(); + m_db->saveItem(m_item); + QPixmap pm = ImageCache::loadLocal(localName); + m_cover->setPixmap(rounded(pm, 244, 354, 12)); + emit itemModified(); + }, Qt::SingleShotConnection); + m_cache->downloadToLibrary(res.coverUrl); +} + +void DetailDialog::persistNotes() +{ + if (m_notes->toPlainText() != m_item.notes) { + m_item.notes = m_notes->toPlainText(); + m_db->saveItem(m_item); + emit itemModified(); + } +} + +void DetailDialog::closeEvent(QCloseEvent *e) +{ + persistNotes(); + QDialog::closeEvent(e); +} + +} // namespace umt diff --git a/src/ui/DetailDialog.h b/src/ui/DetailDialog.h new file mode 100644 index 0000000..293e3e2 --- /dev/null +++ b/src/ui/DetailDialog.h @@ -0,0 +1,78 @@ +#pragma once + +#include "core/MediaItem.h" + +#include + +class QLabel; +class QTreeWidget; +class QTreeWidgetItem; +class QComboBox; +class QPlainTextEdit; +class QProgressBar; +class QVBoxLayout; +class QSpacerItem; + +namespace umt { + +class Database; +class ImageCache; +class ProviderManager; +class AppSettings; +class StarRating; + +// Full-detail view of one item: cover, metadata, localized titles, notes, +// quick status/rating/favorite editing and the season/episode (or volume/ +// chapter) progress tree with per-unit, per-segment and whole toggles. +class DetailDialog : public QDialog { + Q_OBJECT +public: + DetailDialog(int itemId, Database *db, ImageCache *cache, + ProviderManager *providers, AppSettings *settings, + QWidget *parent = nullptr); + +signals: + void itemModified(); + void editRequested(int id); + +protected: + void closeEvent(QCloseEvent *) override; + +private slots: + void onTreeItemChanged(QTreeWidgetItem *item, int column); + +private: + void reload(); + void buildTree(); + void refreshProgress(); + void changeCoverViaSearch(); + void persistNotes(); // save free-text notes if changed + + int m_id; + Database *m_db; + ImageCache *m_cache; + ProviderManager *m_providers; + AppSettings *m_settings; + MediaItem m_item; + + QLabel *m_cover = nullptr; + QLabel *m_titleLbl = nullptr; + QLabel *m_metaLbl = nullptr; + QLabel *m_altTitlesLbl = nullptr; + QLabel *m_genresLbl = nullptr; + QComboBox *m_status = nullptr; + StarRating *m_rating = nullptr; + QPlainTextEdit*m_overview = nullptr; + QPlainTextEdit*m_notes = nullptr; + QTreeWidget *m_tree = nullptr; + QLabel *m_treeLbl = nullptr; + QProgressBar *m_progress = nullptr; + QLabel *m_progressLbl = nullptr; + + QVBoxLayout *m_rl = nullptr; // right column layout + QSpacerItem *m_bottomStretch = nullptr; // pins content to top when no tree + + bool m_loading = false; +}; + +} // namespace umt diff --git a/src/ui/EditDialog.cpp b/src/ui/EditDialog.cpp new file mode 100644 index 0000000..2948ea8 --- /dev/null +++ b/src/ui/EditDialog.cpp @@ -0,0 +1,494 @@ +#include "ui/EditDialog.h" +#include "ui/StarRating.h" +#include "ui/OnlineSearchDialog.h" +#include "core/Database.h" +#include "core/Settings.h" +#include "providers/ImageCache.h" +#include "providers/ProviderManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +EditDialog::EditDialog(MediaType type, Database *db, ImageCache *cache, + ProviderManager *providers, AppSettings *settings, + QWidget *parent) + : QDialog(parent), m_db(db), m_cache(cache), m_providers(providers) + , m_settings(settings), m_editMode(false) +{ + m_item.type = type; + m_item.status = WatchStatus::PlanToWatch; + buildUi(); + loadIntoForm(); +} + +EditDialog::EditDialog(const MediaItem &existing, Database *db, ImageCache *cache, + ProviderManager *providers, AppSettings *settings, + QWidget *parent) + : QDialog(parent), m_db(db), m_cache(cache), m_providers(providers) + , m_settings(settings), m_item(existing), m_editMode(true) +{ + buildUi(); + loadIntoForm(); +} + +void EditDialog::buildUi() +{ + setWindowTitle(m_editMode ? QStringLiteral("Eintrag bearbeiten") + : QStringLiteral("Neuer Eintrag")); + resize(720, 720); + + auto *root = new QVBoxLayout(this); + + // ---- top: cover + core fields ---- + auto *top = new QHBoxLayout; + + auto *coverCol = new QVBoxLayout; + m_cover = new QLabel(this); + m_cover->setFixedSize(150, 218); + m_cover->setAlignment(Qt::AlignCenter); + coverCol->addWidget(m_cover); + auto *onlineBtn = new QPushButton(QStringLiteral("Online suchen…"), this); + auto *fileBtn = new QPushButton(QStringLiteral("Datei…"), this); + coverCol->addWidget(onlineBtn); + coverCol->addWidget(fileBtn); + coverCol->addStretch(1); + top->addLayout(coverCol); + + auto *form = new QFormLayout; + form->setSpacing(8); + + m_type = new QComboBox(this); + const MediaType types[] = {MediaType::Movie, MediaType::Series, + MediaType::Manga, MediaType::Book, + MediaType::Game}; + for (MediaType t : types) m_type->addItem(mediaTypeLabel(t), int(t)); + m_type->setEnabled(!m_editMode); + form->addRow(QStringLiteral("Typ"), m_type); + + m_title = new QLineEdit(this); + form->addRow(QStringLiteral("Titel"), m_title); + m_original = new QLineEdit(this); + form->addRow(QStringLiteral("Originaltitel"), m_original); + + m_year = new QSpinBox(this); + m_year->setRange(0, 2100); + m_year->setSpecialValueText(QStringLiteral("—")); + form->addRow(QStringLiteral("Jahr"), m_year); + + m_status = new QComboBox(this); + const WatchStatus all[] = {WatchStatus::PlanToWatch, WatchStatus::InProgress, + WatchStatus::Completed, WatchStatus::OnHold, + WatchStatus::Dropped}; + for (WatchStatus s : all) m_status->addItem(statusLabel(s), int(s)); + form->addRow(QStringLiteral("Status"), m_status); + + auto *ratingRow = new QHBoxLayout; + m_rating = new StarRating(this); + m_favorite = new QCheckBox(QStringLiteral("Favorit"), this); + ratingRow->addWidget(m_rating); + ratingRow->addStretch(1); + ratingRow->addWidget(m_favorite); + form->addRow(QStringLiteral("Bewertung"), ratingRow); + + m_genres = new QLineEdit(this); + m_genres->setPlaceholderText(QStringLiteral("Action, Drama, … (Komma-getrennt)")); + form->addRow(QStringLiteral("Genres"), m_genres); + m_tags = new QLineEdit(this); + m_tags->setPlaceholderText(QStringLiteral("eigene Tags, Komma-getrennt")); + form->addRow(QStringLiteral("Tags"), m_tags); + + m_franchise = new QLineEdit(this); + m_franchise->setPlaceholderText( + QStringLiteral("optional – sonst aus Titel erkannt")); + if (m_db) { + auto *comp = new QCompleter(m_db->franchiseNames(), m_franchise); + comp->setCaseSensitivity(Qt::CaseInsensitive); + comp->setFilterMode(Qt::MatchContains); + m_franchise->setCompleter(comp); + } + form->addRow(QStringLiteral("Franchise"), m_franchise); + + top->addLayout(form, 1); + root->addLayout(top); + + // ---- tabs: description / structure / titles / custom ---- + auto *tabs = new QTabWidget(this); + + auto *descTab = new QWidget; + auto *dl = new QVBoxLayout(descTab); + dl->addWidget(new QLabel(QStringLiteral("Beschreibung"))); + m_overview = new QPlainTextEdit(descTab); + dl->addWidget(m_overview); + dl->addWidget(new QLabel(QStringLiteral("Notizen"))); + m_notes = new QPlainTextEdit(descTab); + dl->addWidget(m_notes); + tabs->addTab(descTab, QStringLiteral("Beschreibung")); + + // structure + auto *structTab = new QWidget; + auto *sl = new QVBoxLayout(structTab); + m_segHint = new QLabel(structTab); + m_segHint->setObjectName(QStringLiteral("Subtle")); + sl->addWidget(m_segHint); + m_segments = new QTableWidget(0, 2, structTab); + m_segments->setHorizontalHeaderLabels( + {QStringLiteral("Name"), QStringLiteral("Anzahl Einheiten")}); + m_segments->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + sl->addWidget(m_segments); + auto *segBtns = new QHBoxLayout; + auto *addSeg = new QPushButton(QStringLiteral("+ Abschnitt"), structTab); + auto *delSeg = new QPushButton(QStringLiteral("– Entfernen"), structTab); + segBtns->addWidget(addSeg); + segBtns->addWidget(delSeg); + segBtns->addStretch(1); + sl->addLayout(segBtns); + tabs->addTab(structTab, QStringLiteral("Struktur")); + + // localized titles + auto *locTab = new QWidget; + auto *locl = new QVBoxLayout(locTab); + locl->addWidget(new QLabel(QStringLiteral("Titel in verschiedenen Sprachen"))); + m_localized = new QTableWidget(0, 2, locTab); + m_localized->setHorizontalHeaderLabels( + {QStringLiteral("Sprache (de, en, ja…)"), QStringLiteral("Titel")}); + m_localized->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + locl->addWidget(m_localized); + auto *locBtns = new QHBoxLayout; + auto *addLoc = new QPushButton(QStringLiteral("+ Sprache"), locTab); + auto *delLoc = new QPushButton(QStringLiteral("– Entfernen"), locTab); + locBtns->addWidget(addLoc); + locBtns->addWidget(delLoc); + locBtns->addStretch(1); + locl->addLayout(locBtns); + tabs->addTab(locTab, QStringLiteral("Titel/Sprachen")); + + // custom fields + auto *custTab = new QWidget; + auto *cl = new QVBoxLayout(custTab); + cl->addWidget(new QLabel(QStringLiteral("Eigene Felder"))); + m_custom = new QTableWidget(0, 2, custTab); + m_custom->setHorizontalHeaderLabels( + {QStringLiteral("Feld"), QStringLiteral("Wert")}); + m_custom->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + cl->addWidget(m_custom); + auto *custBtns = new QHBoxLayout; + auto *addCust = new QPushButton(QStringLiteral("+ Feld"), custTab); + auto *delCust = new QPushButton(QStringLiteral("– Entfernen"), custTab); + custBtns->addWidget(addCust); + custBtns->addWidget(delCust); + custBtns->addStretch(1); + cl->addLayout(custBtns); + tabs->addTab(custTab, QStringLiteral("Eigene Felder")); + + root->addWidget(tabs, 1); + + auto *bb = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, this); + root->addWidget(bb); + + // ---- wiring ---- + connect(onlineBtn, &QPushButton::clicked, this, &EditDialog::importFromOnline); + connect(fileBtn, &QPushButton::clicked, this, &EditDialog::chooseCoverFile); + connect(addSeg, &QPushButton::clicked, this, [this]{ addSegmentRow(); }); + connect(delSeg, &QPushButton::clicked, this, [this]{ + const int r = m_segments->currentRow(); + if (r >= 0) m_segments->removeRow(r); + }); + connect(addLoc, &QPushButton::clicked, this, [this]{ addLocalizedRow(); }); + connect(delLoc, &QPushButton::clicked, this, [this]{ + const int r = m_localized->currentRow(); + if (r >= 0) m_localized->removeRow(r); + }); + connect(addCust, &QPushButton::clicked, this, [this]{ addCustomRow(); }); + connect(delCust, &QPushButton::clicked, this, [this]{ + const int r = m_custom->currentRow(); + if (r >= 0) m_custom->removeRow(r); + }); + connect(m_type, &QComboBox::currentIndexChanged, this, [this]{ + m_item.type = MediaType(m_type->currentData().toInt()); + m_segHint->setText(QStringLiteral("Abschnitte = %1, Einheiten = %2") + .arg(segmentLabel(m_item.type), unitLabel(m_item.type))); + }); + connect(bb, &QDialogButtonBox::accepted, this, &EditDialog::accept); + connect(bb, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +void EditDialog::setCoverPreview(const QPixmap &pm) +{ + QPixmap use = pm.isNull() ? QPixmap(QStringLiteral(":/icons/placeholder.svg")) : pm; + QPixmap out(150, 218); + out.fill(Qt::transparent); + QPainter p(&out); + p.setRenderHint(QPainter::Antialiasing); + p.setRenderHint(QPainter::SmoothPixmapTransform); + QPainterPath path; path.addRoundedRect(0, 0, 150, 218, 10, 10); + p.setClipPath(path); + QPixmap sc = use.scaled(150, 218, Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + p.drawPixmap((150 - sc.width()) / 2, (218 - sc.height()) / 2, sc); + m_cover->setPixmap(out); +} + +void EditDialog::loadIntoForm() +{ + m_type->setCurrentIndex(m_type->findData(int(m_item.type))); + m_segHint->setText(QStringLiteral("Abschnitte = %1, Einheiten = %2") + .arg(segmentLabel(m_item.type), unitLabel(m_item.type))); + m_title->setText(m_item.title); + m_original->setText(m_item.originalTitle); + m_year->setValue(m_item.year); + m_status->setCurrentIndex(m_status->findData(int(m_item.status))); + m_rating->setRating(m_item.rating); + m_favorite->setChecked(m_item.favorite); + m_genres->setText(m_item.genres.join(QStringLiteral(", "))); + m_tags->setText(m_item.tags.join(QStringLiteral(", "))); + m_franchise->setText(m_item.franchise); + m_overview->setPlainText(m_item.overview); + m_notes->setPlainText(m_item.notes); + + for (auto it = m_item.localizedTitles.cbegin(); + it != m_item.localizedTitles.cend(); ++it) + addLocalizedRow(it.key(), it.value()); + for (auto it = m_item.customFields.cbegin(); + it != m_item.customFields.cend(); ++it) + addCustomRow(it.key(), it.value()); + for (const Segment &s : m_item.segments) + addSegmentRow(s.title, s.units.size()); + + // cover preview + QPixmap pm; + if (!m_item.coverPath.isEmpty()) + pm = ImageCache::loadLocal(m_item.coverPath); + else if (!m_item.coverUrl.isEmpty()) { + m_pendingCoverUrl = m_item.coverUrl; + pm = m_cache->get(m_item.coverUrl); + connect(m_cache, &ImageCache::ready, this, + [this](const QString &url, const QPixmap &p){ + if (url == m_pendingCoverUrl) setCoverPreview(p); + }); + } + setCoverPreview(pm); +} + +void EditDialog::addLocalizedRow(const QString &lang, const QString &title) +{ + const int r = m_localized->rowCount(); + m_localized->insertRow(r); + m_localized->setItem(r, 0, new QTableWidgetItem(lang)); + m_localized->setItem(r, 1, new QTableWidgetItem(title)); +} + +void EditDialog::addCustomRow(const QString &key, const QString &value) +{ + const int r = m_custom->rowCount(); + m_custom->insertRow(r); + m_custom->setItem(r, 0, new QTableWidgetItem(key)); + m_custom->setItem(r, 1, new QTableWidgetItem(value)); +} + +void EditDialog::addSegmentRow(const QString &name, int count) +{ + const int r = m_segments->rowCount(); + m_segments->insertRow(r); + m_segments->setItem(r, 0, new QTableWidgetItem(name)); + auto *spin = new QSpinBox(m_segments); + spin->setRange(0, 5000); + spin->setValue(count); + m_segments->setCellWidget(r, 1, spin); +} + +void EditDialog::importFromOnline() +{ + OnlineSearchDialog dlg(m_providers, m_cache, + MediaType(m_type->currentData().toInt()), this); + dlg.setQuery(m_title->text().isEmpty() ? QString() : m_title->text()); + if (dlg.exec() == QDialog::Accepted) + applyResult(dlg.selectedResult()); +} + +void EditDialog::applyResult(const SearchResult &r) +{ + if (!r.title.isEmpty()) m_title->setText(r.title); + if (!r.originalTitle.isEmpty()) m_original->setText(r.originalTitle); + if (r.year > 0) m_year->setValue(r.year); + if (!r.overview.isEmpty()) m_overview->setPlainText(r.overview); + if (!r.genres.isEmpty()) m_genres->setText(r.genres.join(QStringLiteral(", "))); + + m_item.externalId = r.externalId; + m_item.externalSource = r.source; + + // localized titles + for (auto it = r.localizedTitles.cbegin(); it != r.localizedTitles.cend(); ++it) { + bool exists = false; + for (int i = 0; i < m_localized->rowCount(); ++i) + if (m_localized->item(i, 0) && + m_localized->item(i, 0)->text() == it.key()) { exists = true; break; } + if (!exists) addLocalizedRow(it.key(), it.value()); + } + + // structure + if (!r.segments.isEmpty()) { + m_segments->setRowCount(0); + for (const ResultSegment &seg : r.segments) + addSegmentRow(seg.title, seg.unitCount); + } + + // cover + if (!r.coverUrl.isEmpty()) { + m_pendingCoverUrl = r.coverUrl; + m_item.coverPath.clear(); + QPixmap pm = m_cache->get(r.coverUrl); + if (!pm.isNull()) setCoverPreview(pm); + connect(m_cache, &ImageCache::ready, this, + [this](const QString &url, const QPixmap &p){ + if (url == m_pendingCoverUrl) setCoverPreview(p); + }); + } +} + +void EditDialog::chooseCoverFile() +{ + const QString f = QFileDialog::getOpenFileName( + this, QStringLiteral("Cover wählen"), QString(), + QStringLiteral("Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif)")); + if (f.isEmpty()) return; + + // copy into library covers dir + QFileInfo fi(f); + const QString name = QUuid::createUuid().toString(QUuid::Id128) + + QStringLiteral(".") + fi.suffix(); + const QString dest = ImageCache::coversDir() + QStringLiteral("/") + name; + if (QFile::copy(f, dest)) { + m_item.coverPath = name; + m_item.coverUrl.clear(); + m_pendingCoverUrl.clear(); + setCoverPreview(ImageCache::loadLocal(name)); + } +} + +void EditDialog::accept() +{ + if (m_title->text().trimmed().isEmpty()) { + QMessageBox::warning(this, windowTitle(), + QStringLiteral("Bitte einen Titel angeben.")); + return; + } + + m_item.type = MediaType(m_type->currentData().toInt()); + m_item.title = m_title->text().trimmed(); + m_item.originalTitle = m_original->text().trimmed(); + m_item.year = m_year->value(); + m_item.status = WatchStatus(m_status->currentData().toInt()); + m_item.rating = m_rating->rating(); + m_item.favorite= m_favorite->isChecked(); + m_item.overview= m_overview->toPlainText(); + m_item.notes = m_notes->toPlainText(); + + auto splitCsv = [](const QString &s) { + QStringList out; + for (const QString &p : s.split(QLatin1Char(','), Qt::SkipEmptyParts)) + out << p.trimmed(); + return out; + }; + m_item.genres = splitCsv(m_genres->text()); + m_item.tags = splitCsv(m_tags->text()); + m_item.franchise = m_franchise->text().trimmed(); + + // localized + m_item.localizedTitles.clear(); + for (int i = 0; i < m_localized->rowCount(); ++i) { + const QString lang = m_localized->item(i, 0) ? m_localized->item(i, 0)->text().trimmed() : QString(); + const QString t = m_localized->item(i, 1) ? m_localized->item(i, 1)->text().trimmed() : QString(); + if (!lang.isEmpty() && !t.isEmpty()) + m_item.localizedTitles.insert(lang, t); + } + // custom + m_item.customFields.clear(); + for (int i = 0; i < m_custom->rowCount(); ++i) { + const QString k = m_custom->item(i, 0) ? m_custom->item(i, 0)->text().trimmed() : QString(); + const QString v = m_custom->item(i, 1) ? m_custom->item(i, 1)->text().trimmed() : QString(); + if (!k.isEmpty()) m_item.customFields.insert(k, v); + } + + // structure: rebuild segments, preserving watched flags by (segIdx, unitNo) + QVector old = m_item.segments; + QVector rebuilt; + for (int i = 0; i < m_segments->rowCount(); ++i) { + Segment seg; + seg.number = i + 1; + seg.title = m_segments->item(i, 0) ? m_segments->item(i, 0)->text() : QString(); + auto *spin = qobject_cast(m_segments->cellWidget(i, 1)); + const int count = spin ? spin->value() : 0; + for (int u = 1; u <= count; ++u) { + Unit unit; + unit.number = u; + // carry over previous watched state if available + if (i < old.size() && (u - 1) < old[i].units.size()) + unit.watched = old[i].units[u - 1].watched; + seg.units.push_back(unit); + } + rebuilt.push_back(seg); + } + m_item.segments = rebuilt; + + if (!m_item.dateAdded.isValid()) + m_item.dateAdded = QDateTime::currentDateTime(); + + // Persist the remote cover URL too, so the card can render it even if the + // permanent download is disabled or still running. + if (!m_pendingCoverUrl.isEmpty() && m_item.coverPath.isEmpty()) + m_item.coverUrl = m_pendingCoverUrl; + + // Persist now; if a remote cover is pending, download then update path. + if (!m_db->saveItem(m_item)) { + QMessageBox::critical(this, windowTitle(), + QStringLiteral("Speichern fehlgeschlagen:\n%1").arg(m_db->lastError())); + return; + } + + if (!m_pendingCoverUrl.isEmpty() && m_settings->autoFetchCovers()) { + const int id = m_item.id; + Database *db = m_db; + // Bind the context to the long-lived database, NOT to this dialog: + // the dialog is destroyed right after accept(), well before the async + // download finishes, which previously dropped the cover update. + connect(m_cache, &ImageCache::saved, db, + [db, id](const QString &localName){ + if (auto opt = db->loadItem(id)) { + MediaItem mi = *opt; + mi.coverPath = localName; + mi.coverUrl.clear(); + db->saveItem(mi); + } + }, Qt::SingleShotConnection); + m_cache->downloadToLibrary(m_pendingCoverUrl); + } + + QDialog::accept(); +} + +} // namespace umt diff --git a/src/ui/EditDialog.h b/src/ui/EditDialog.h new file mode 100644 index 0000000..7ffb752 --- /dev/null +++ b/src/ui/EditDialog.h @@ -0,0 +1,83 @@ +#pragma once + +#include "core/MediaItem.h" +#include "providers/MetadataProvider.h" + +#include + +class QLineEdit; +class QSpinBox; +class QComboBox; +class QPlainTextEdit; +class QCheckBox; +class QLabel; +class QTableWidget; + +namespace umt { + +class Database; +class ImageCache; +class ProviderManager; +class AppSettings; +class StarRating; + +// Add/Edit form for a media item. Supports importing metadata from an online +// provider, editing localized titles, custom fields and the season/episode +// (or volume/chapter) structure. +class EditDialog : public QDialog { + Q_OBJECT +public: + // Create mode. + EditDialog(MediaType type, Database *db, ImageCache *cache, + ProviderManager *providers, AppSettings *settings, + QWidget *parent = nullptr); + // Edit mode. + EditDialog(const MediaItem &existing, Database *db, ImageCache *cache, + ProviderManager *providers, AppSettings *settings, + QWidget *parent = nullptr); + + int savedItemId() const { return m_item.id; } + +private slots: + void importFromOnline(); + void chooseCoverFile(); + void accept() override; + +private: + void buildUi(); + void loadIntoForm(); + void applyResult(const SearchResult &r); + void setCoverPreview(const QPixmap &pm); + void addLocalizedRow(const QString &lang = {}, const QString &title = {}); + void addCustomRow(const QString &key = {}, const QString &value = {}); + void addSegmentRow(const QString &name = {}, int count = 0); + + Database *m_db; + ImageCache *m_cache; + ProviderManager *m_providers; + AppSettings *m_settings; + + MediaItem m_item; // working copy + bool m_editMode; + QString m_pendingCoverUrl; // download to library on save + + QComboBox *m_type; + QLineEdit *m_title; + QLineEdit *m_original; + QSpinBox *m_year; + QComboBox *m_status; + StarRating *m_rating; + QCheckBox *m_favorite; + QLineEdit *m_genres; + QLineEdit *m_tags; + QLineEdit *m_franchise; + QPlainTextEdit*m_overview; + QPlainTextEdit*m_notes; + QLabel *m_cover; + QTableWidget *m_localized; + QTableWidget *m_custom; + QTableWidget *m_segments; + QLabel *m_segHint; +}; + +} // namespace umt diff --git a/src/ui/FilterPanel.cpp b/src/ui/FilterPanel.cpp new file mode 100644 index 0000000..ca6f6f3 --- /dev/null +++ b/src/ui/FilterPanel.cpp @@ -0,0 +1,163 @@ +#include "ui/FilterPanel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +FilterPanel::FilterPanel(QWidget *parent) : QWidget(parent) +{ + setFixedWidth(230); + // Paint our own background (like the sidebar/content do). Without this the + // panel is transparent and, on some styles (KDE/Breeze on Wayland), a 1px + // frame/compositor seam shows through along its top edge. + setObjectName(QStringLiteral("FilterPanel")); + setAttribute(Qt::WA_StyledBackground, true); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(10, 10, 10, 10); + root->setSpacing(12); + + auto *title = new QLabel(QStringLiteral("Filter"), this); + title->setObjectName(QStringLiteral("H2")); + root->addWidget(title); + + auto *form = new QFormLayout; + form->setSpacing(8); + + m_status = new QComboBox(this); + m_status->addItem(QStringLiteral("Alle"), -1); + const WatchStatus all[] = {WatchStatus::PlanToWatch, WatchStatus::InProgress, + WatchStatus::Completed, WatchStatus::OnHold, + WatchStatus::Dropped}; + for (WatchStatus s : all) + m_status->addItem(statusLabel(s), int(s)); + form->addRow(QStringLiteral("Status"), m_status); + + m_franchise = new QComboBox(this); + m_franchise->addItem(QStringLiteral("Alle"), QString()); + form->addRow(QStringLiteral("Franchise"), m_franchise); + + m_minRating = new QComboBox(this); + m_minRating->addItem(QStringLiteral("Egal"), 0); + for (int i = 1; i <= 5; ++i) + m_minRating->addItem(QStringLiteral("\u2605 %1+").arg(i), i * 2); + form->addRow(QStringLiteral("Bewertung"), m_minRating); + + m_yearFrom = new QSpinBox(this); + m_yearFrom->setRange(0, 2100); + m_yearFrom->setSpecialValueText(QStringLiteral("—")); + m_yearTo = new QSpinBox(this); + m_yearTo->setRange(0, 2100); + m_yearTo->setSpecialValueText(QStringLiteral("—")); + auto *yearRow = new QHBoxLayout; + yearRow->addWidget(m_yearFrom); + yearRow->addWidget(new QLabel(QStringLiteral("–"), this)); + yearRow->addWidget(m_yearTo); + form->addRow(QStringLiteral("Jahr"), yearRow); + + root->addLayout(form); + + m_favorites = new QCheckBox(QStringLiteral("Nur Favoriten"), this); + root->addWidget(m_favorites); + + root->addWidget(new QLabel(QStringLiteral("Genres"), this)); + m_genres = new QListWidget(this); + m_genres->setMaximumHeight(180); + root->addWidget(m_genres); + + root->addWidget(new QLabel(QStringLiteral("Tags"), this)); + m_tags = new QListWidget(this); + m_tags->setMaximumHeight(120); + root->addWidget(m_tags); + + root->addStretch(1); + + auto *resetBtn = new QPushButton(QStringLiteral("Zurücksetzen"), this); + root->addWidget(resetBtn); + + connect(resetBtn, &QPushButton::clicked, this, [this]{ reset(); emit changed(); }); + connect(m_status, &QComboBox::currentIndexChanged, this, &FilterPanel::changed); + connect(m_franchise, &QComboBox::currentIndexChanged, this, &FilterPanel::changed); + connect(m_minRating, &QComboBox::currentIndexChanged, this, &FilterPanel::changed); + connect(m_yearFrom, &QSpinBox::editingFinished, this, &FilterPanel::changed); + connect(m_yearTo, &QSpinBox::editingFinished, this, &FilterPanel::changed); + connect(m_favorites, &QCheckBox::toggled, this, &FilterPanel::changed); + connect(m_genres, &QListWidget::itemChanged, this, &FilterPanel::changed); + connect(m_tags, &QListWidget::itemChanged, this, &FilterPanel::changed); +} + +static void repopulate(QListWidget *list, const QStringList &values) +{ + QStringList checked; + for (int i = 0; i < list->count(); ++i) + if (list->item(i)->checkState() == Qt::Checked) + checked << list->item(i)->text(); + + list->blockSignals(true); + list->clear(); + for (const QString &v : values) { + auto *it = new QListWidgetItem(v, list); + it->setFlags(it->flags() | Qt::ItemIsUserCheckable); + it->setCheckState(checked.contains(v) ? Qt::Checked : Qt::Unchecked); + } + list->blockSignals(false); +} + +void FilterPanel::setAvailableGenres(const QStringList &genres) { repopulate(m_genres, genres); } +void FilterPanel::setAvailableTags(const QStringList &tags) { repopulate(m_tags, tags); } + +void FilterPanel::setAvailableFranchises(const QStringList &franchises) +{ + const QString current = m_franchise->currentData().toString(); + m_franchise->blockSignals(true); + m_franchise->clear(); + m_franchise->addItem(QStringLiteral("Alle"), QString()); + for (const QString &f : franchises) + m_franchise->addItem(f, f); + const int idx = current.isEmpty() ? 0 : m_franchise->findData(current); + m_franchise->setCurrentIndex(idx < 0 ? 0 : idx); + m_franchise->blockSignals(false); +} + +void FilterPanel::applyTo(FilterCriteria &c) const +{ + const int st = m_status->currentData().toInt(); + if (st >= 0) c.status = WatchStatus(st); + + c.franchise = m_franchise->currentData().toString(); + c.minRating = m_minRating->currentData().toInt(); + c.yearFrom = m_yearFrom->value(); + c.yearTo = m_yearTo->value(); + c.favoritesOnly = m_favorites->isChecked(); + + for (int i = 0; i < m_genres->count(); ++i) + if (m_genres->item(i)->checkState() == Qt::Checked) + c.genres << m_genres->item(i)->text(); + for (int i = 0; i < m_tags->count(); ++i) + if (m_tags->item(i)->checkState() == Qt::Checked) + c.tags << m_tags->item(i)->text(); +} + +void FilterPanel::reset() +{ + m_status->setCurrentIndex(0); + m_franchise->setCurrentIndex(0); + m_minRating->setCurrentIndex(0); + m_yearFrom->setValue(0); + m_yearTo->setValue(0); + m_favorites->setChecked(false); + for (int i = 0; i < m_genres->count(); ++i) + m_genres->item(i)->setCheckState(Qt::Unchecked); + for (int i = 0; i < m_tags->count(); ++i) + m_tags->item(i)->setCheckState(Qt::Unchecked); +} + +} // namespace umt diff --git a/src/ui/FilterPanel.h b/src/ui/FilterPanel.h new file mode 100644 index 0000000..e592bef --- /dev/null +++ b/src/ui/FilterPanel.h @@ -0,0 +1,47 @@ +#pragma once + +#include "core/Database.h" + +#include + +class QComboBox; +class QListWidget; +class QSpinBox; +class QCheckBox; + +namespace umt { + +// Left-side advanced filter panel. Emits changed() whenever any control moves; +// the owner then reads criteria() and re-queries. +class FilterPanel : public QWidget { + Q_OBJECT +public: + explicit FilterPanel(QWidget *parent = nullptr); + + // Populate genre/tag lists from the DB (keeps current selection where possible). + void setAvailableGenres(const QStringList &genres); + void setAvailableTags(const QStringList &tags); + // Populate the franchise dropdown (keeps the current selection if still present). + void setAvailableFranchises(const QStringList &franchises); + + // Fills status/genres/tags/rating/year/favorites into an existing criteria + // object (type, search and sort are owned by the toolbar). + void applyTo(FilterCriteria &c) const; + + void reset(); + +signals: + void changed(); + +private: + QComboBox *m_status; + QComboBox *m_franchise; + QComboBox *m_minRating; + QSpinBox *m_yearFrom; + QSpinBox *m_yearTo; + QCheckBox *m_favorites; + QListWidget *m_genres; + QListWidget *m_tags; +}; + +} // namespace umt diff --git a/src/ui/FlowLayout.cpp b/src/ui/FlowLayout.cpp new file mode 100644 index 0000000..75e321b --- /dev/null +++ b/src/ui/FlowLayout.cpp @@ -0,0 +1,109 @@ +#include "ui/FlowLayout.h" + +#include + +namespace umt { + +FlowLayout::FlowLayout(QWidget *parent, int margin, int hSpacing, int vSpacing) + : QLayout(parent), m_hSpace(hSpacing), m_vSpace(vSpacing) +{ + setContentsMargins(margin, margin, margin, margin); +} + +FlowLayout::~FlowLayout() +{ + QLayoutItem *item; + while ((item = takeAt(0))) + delete item; +} + +void FlowLayout::addItem(QLayoutItem *item) { m_items.append(item); } + +int FlowLayout::horizontalSpacing() const +{ + return m_hSpace >= 0 ? m_hSpace : smartSpacing(QStyle::PM_LayoutHorizontalSpacing); +} +int FlowLayout::verticalSpacing() const +{ + return m_vSpace >= 0 ? m_vSpace : smartSpacing(QStyle::PM_LayoutVerticalSpacing); +} + +int FlowLayout::count() const { return m_items.size(); } + +QLayoutItem *FlowLayout::itemAt(int index) const { return m_items.value(index); } + +QLayoutItem *FlowLayout::takeAt(int index) +{ + if (index >= 0 && index < m_items.size()) + return m_items.takeAt(index); + return nullptr; +} + +Qt::Orientations FlowLayout::expandingDirections() const { return {}; } + +bool FlowLayout::hasHeightForWidth() const { return true; } + +int FlowLayout::heightForWidth(int width) const +{ + return doLayout(QRect(0, 0, width, 0), true); +} + +void FlowLayout::setGeometry(const QRect &rect) +{ + QLayout::setGeometry(rect); + doLayout(rect, false); +} + +QSize FlowLayout::sizeHint() const { return minimumSize(); } + +QSize FlowLayout::minimumSize() const +{ + QSize size; + for (const QLayoutItem *item : m_items) + size = size.expandedTo(item->minimumSize()); + const QMargins m = contentsMargins(); + size += QSize(m.left() + m.right(), m.top() + m.bottom()); + return size; +} + +int FlowLayout::doLayout(const QRect &rect, bool testOnly) const +{ + int left, top, right, bottom; + getContentsMargins(&left, &top, &right, &bottom); + const QRect effectiveRect = rect.adjusted(left, top, -right, -bottom); + int x = effectiveRect.x(); + int y = effectiveRect.y(); + int lineHeight = 0; + + for (QLayoutItem *item : m_items) { + const QSize sz = item->sizeHint(); + int spaceX = horizontalSpacing(); + int spaceY = verticalSpacing(); + int nextX = x + sz.width() + spaceX; + if (nextX - spaceX > effectiveRect.right() && lineHeight > 0) { + x = effectiveRect.x(); + y = y + lineHeight + spaceY; + nextX = x + sz.width() + spaceX; + lineHeight = 0; + } + if (!testOnly) + item->setGeometry(QRect(QPoint(x, y), sz)); + x = nextX; + lineHeight = qMax(lineHeight, sz.height()); + } + return y + lineHeight - rect.y() + bottom; +} + +int FlowLayout::smartSpacing(QStyle::PixelMetric pm) const +{ + QObject *parent = this->parent(); + if (!parent) + return -1; + if (parent->isWidgetType()) { + QWidget *pw = static_cast(parent); + return pw->style()->pixelMetric(pm, nullptr, pw); + } + return static_cast(parent)->spacing(); +} + +} // namespace umt diff --git a/src/ui/FlowLayout.h b/src/ui/FlowLayout.h new file mode 100644 index 0000000..c8d2171 --- /dev/null +++ b/src/ui/FlowLayout.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +namespace umt { + +// A wrapping layout (like CSS flex-wrap) used for the responsive card grid. +// Adapted from the classic Qt FlowLayout example. +class FlowLayout : public QLayout { + Q_OBJECT +public: + explicit FlowLayout(QWidget *parent = nullptr, int margin = -1, + int hSpacing = 12, int vSpacing = 12); + ~FlowLayout() override; + + void addItem(QLayoutItem *item) override; + int horizontalSpacing() const; + int verticalSpacing() const; + Qt::Orientations expandingDirections() const override; + bool hasHeightForWidth() const override; + int heightForWidth(int width) const override; + int count() const override; + QLayoutItem *itemAt(int index) const override; + QSize minimumSize() const override; + void setGeometry(const QRect &rect) override; + QSize sizeHint() const override; + QLayoutItem *takeAt(int index) override; + +private: + int doLayout(const QRect &rect, bool testOnly) const; + int smartSpacing(QStyle::PixelMetric pm) const; + + QList m_items; + int m_hSpace; + int m_vSpace; +}; + +} // namespace umt diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp new file mode 100644 index 0000000..322212d --- /dev/null +++ b/src/ui/MainWindow.cpp @@ -0,0 +1,379 @@ +#include "ui/MainWindow.h" +#include "ui/ThemeManager.h" +#include "ui/FilterPanel.h" +#include "ui/FlowLayout.h" +#include "ui/MediaCard.h" +#include "ui/DetailDialog.h" +#include "ui/EditDialog.h" +#include "ui/SettingsDialog.h" +#include "core/Settings.h" +#include "providers/ProviderManager.h" +#include "providers/ImageCache.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +MainWindow::MainWindow(Database *db, AppSettings *settings, ThemeManager *theme, + ProviderManager *providers, ImageCache *cache, QWidget *parent) + : QMainWindow(parent) + , m_db(db), m_settings(settings), m_theme(theme) + , m_providers(providers), m_cache(cache) +{ + setWindowTitle(QStringLiteral("Ultimate Media Tracker")); + resize(1280, 820); + + m_searchDebounce = new QTimer(this); + m_searchDebounce->setSingleShot(true); + m_searchDebounce->setInterval(250); + connect(m_searchDebounce, &QTimer::timeout, this, &MainWindow::refresh); + + buildUi(); + + connect(m_db, &Database::changed, this, [this]{ + rebuildFilterLists(); + }); + + rebuildFilterLists(); + refresh(); +} + +void MainWindow::buildUi() +{ + auto *central = new QWidget(this); + auto *outer = new QHBoxLayout(central); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + + // ----- sidebar ----- + auto *sidebar = new QWidget(central); + sidebar->setObjectName(QStringLiteral("Sidebar")); + sidebar->setFixedWidth(220); + buildSidebar(sidebar); + outer->addWidget(sidebar); + + // ----- filter panel ----- + m_filters = new FilterPanel(central); + connect(m_filters, &FilterPanel::changed, this, &MainWindow::refresh); + outer->addWidget(m_filters); + + // ----- main content (stacked: library / detail / edit) ----- + auto *content = new QWidget(central); + content->setObjectName(QStringLiteral("ContentArea")); + auto *cl = new QVBoxLayout(content); + cl->setContentsMargins(0, 0, 0, 0); + cl->setSpacing(0); + + m_stack = new QStackedWidget(content); + cl->addWidget(m_stack, 1); + + // library page + m_libraryPage = new QWidget(m_stack); + auto *libLayout = new QVBoxLayout(m_libraryPage); + libLayout->setContentsMargins(16, 14, 16, 8); + libLayout->setSpacing(12); + + // top bar + auto *topBar = new QWidget(m_libraryPage); + topBar->setObjectName(QStringLiteral("TopBar")); + auto *tb = new QHBoxLayout(topBar); + tb->setContentsMargins(0, 0, 0, 0); + tb->setSpacing(8); + + // Uniform height so search, sort and the icon buttons line up regardless of + // their glyphs (e.g. the gear ⚙ is taller than ◐ and would otherwise grow). + const int rowH = 38; + + m_search = new QLineEdit(topBar); + m_search->setPlaceholderText(QStringLiteral("🔍 Suchen…")); + m_search->setClearButtonEnabled(true); + m_search->setMinimumWidth(260); + m_search->setFixedHeight(rowH); + connect(m_search, &QLineEdit::textChanged, this, [this]{ m_searchDebounce->start(); }); + tb->addWidget(m_search, 1); + + m_sort = new QComboBox(topBar); + m_sort->addItem(QStringLiteral("Zuletzt hinzugefügt"), QStringLiteral("dateAdded")); + m_sort->addItem(QStringLiteral("Titel"), QStringLiteral("title")); + m_sort->addItem(QStringLiteral("Bewertung"), QStringLiteral("rating")); + m_sort->addItem(QStringLiteral("Jahr"), QStringLiteral("year")); + m_sort->addItem(QStringLiteral("Fortschritt"), QStringLiteral("progress")); + m_sort->setFixedHeight(rowH); + connect(m_sort, &QComboBox::currentIndexChanged, this, &MainWindow::refresh); + tb->addWidget(m_sort); + + m_sortDir = new QPushButton(QStringLiteral("↓"), topBar); + m_sortDir->setObjectName(QStringLiteral("IconButton")); + m_sortDir->setCheckable(true); + m_sortDir->setChecked(true); + m_sortDir->setFixedSize(40, rowH); + m_sortDir->setToolTip(QStringLiteral("Sortierrichtung")); + connect(m_sortDir, &QPushButton::toggled, this, [this](bool desc){ + m_sortDir->setText(desc ? QStringLiteral("↓") : QStringLiteral("↑")); + refresh(); + }); + tb->addWidget(m_sortDir); + + auto *addBtn = new QPushButton(QStringLiteral("+ Hinzufügen"), topBar); + addBtn->setObjectName(QStringLiteral("Primary")); + addBtn->setFixedHeight(rowH); + connect(addBtn, &QPushButton::clicked, this, &MainWindow::openEditNew); + tb->addWidget(addBtn); + + auto *themeBtn = new QPushButton(QStringLiteral("◐"), topBar); + themeBtn->setObjectName(QStringLiteral("IconButton")); + themeBtn->setFixedSize(40, rowH); + themeBtn->setToolTip(QStringLiteral("Hell/Dunkel umschalten")); + connect(themeBtn, &QPushButton::clicked, this, &MainWindow::toggleTheme); + tb->addWidget(themeBtn); + + auto *settingsBtn = new QPushButton(QStringLiteral("⚙"), topBar); + settingsBtn->setObjectName(QStringLiteral("IconButton")); + settingsBtn->setFixedSize(40, rowH); + connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings); + tb->addWidget(settingsBtn); + + libLayout->addWidget(topBar); + + // grid scroll area + m_scroll = new QScrollArea(m_libraryPage); + m_scroll->setWidgetResizable(true); + m_scroll->setFrameShape(QFrame::NoFrame); + m_grid = new QWidget; + m_flow = new FlowLayout(m_grid, 4, 16, 18); + m_grid->setLayout(m_flow); + m_scroll->setWidget(m_grid); + libLayout->addWidget(m_scroll, 1); + + m_emptyLabel = new QLabel( + QStringLiteral("Noch nichts hier. Füge mit „+ Hinzufügen“ deinen ersten Eintrag hinzu."), + m_libraryPage); + m_emptyLabel->setObjectName(QStringLiteral("Subtle")); + m_emptyLabel->setAlignment(Qt::AlignCenter); + libLayout->addWidget(m_emptyLabel); + + m_stack->addWidget(m_libraryPage); + + outer->addWidget(content, 1); + setCentralWidget(central); + + // react to live setting changes + connect(m_settings, &AppSettings::cardSizeChanged, this, [this]{ refresh(); }); +} + +void MainWindow::buildSidebar(QWidget *parent) +{ + auto *l = new QVBoxLayout(parent); + l->setContentsMargins(10, 16, 10, 16); + l->setSpacing(4); + + auto *brand = new QLabel(QStringLiteral("🎬 Media Tracker"), parent); + brand->setStyleSheet(QStringLiteral("font-size:18px; font-weight:700; padding:6px 8px 12px;")); + l->addWidget(brand); + + m_sidebarGroup = new QButtonGroup(this); + m_sidebarGroup->setExclusive(true); + + auto addBtn = [&](const QString &text, int id) { + auto *b = new QPushButton(text, parent); + b->setCheckable(true); + m_sidebarGroup->addButton(b, id); + l->addWidget(b); + return b; + }; + + auto *lib = new QLabel(QStringLiteral("BIBLIOTHEK"), parent); + lib->setObjectName(QStringLiteral("SidebarTitle")); + l->addWidget(lib); + + addBtn(QStringLiteral("🗂 Alle"), 0)->setChecked(true); + addBtn(QStringLiteral("🎬 Filme"), 1); + addBtn(QStringLiteral("📺 Serien"), 2); + addBtn(QStringLiteral("📚 Mangas"), 3); + addBtn(QStringLiteral("📖 Bücher"), 4); + addBtn(QStringLiteral("🎮 Spiele"), 6); + + auto *coll = new QLabel(QStringLiteral("SAMMLUNGEN"), parent); + coll->setObjectName(QStringLiteral("SidebarTitle")); + l->addWidget(coll); + addBtn(QStringLiteral("⭐ Favoriten"), 5); + + connect(m_sidebarGroup, &QButtonGroup::idClicked, this, [this](int id){ + m_favoritesSection = (id == 5); + switch (id) { + case 1: m_section = MediaType::Movie; break; + case 2: m_section = MediaType::Series; break; + case 3: m_section = MediaType::Manga; break; + case 4: m_section = MediaType::Book; break; + case 6: m_section = MediaType::Game; break; + default: m_section.reset(); break; + } + refresh(); + }); + + l->addStretch(1); + + m_statsLabel = new QLabel(parent); + m_statsLabel->setObjectName(QStringLiteral("Subtle")); + m_statsLabel->setWordWrap(true); + m_statsLabel->setStyleSheet(QStringLiteral("padding:8px;")); + l->addWidget(m_statsLabel); +} + +FilterCriteria MainWindow::currentCriteria() const +{ + FilterCriteria c; + c.type = m_section; + c.favoritesOnly = m_favoritesSection; + c.searchText = m_search->text(); + c.sortMode = m_sort->currentData().toString(); + c.sortDescending = m_sortDir->isChecked(); + m_filters->applyTo(c); + if (m_favoritesSection) c.favoritesOnly = true; + return c; +} + +void MainWindow::rebuildFilterLists() +{ + m_filters->setAvailableGenres(m_db->allGenres()); + m_filters->setAvailableTags(m_db->allTags()); + m_filters->setAvailableFranchises(m_db->allFranchises()); + + const Database::Stats s = m_db->stats(); + m_statsLabel->setText( + QStringLiteral("%1 Einträge\n%2 abgeschlossen · %3 laufend · %4 geplant") + .arg(s.total).arg(s.completed).arg(s.inProgress).arg(s.planned)); +} + +void MainWindow::refresh() +{ + // clear existing cards + QLayoutItem *child; + while ((child = m_flow->takeAt(0)) != nullptr) { + if (child->widget()) child->widget()->deleteLater(); + delete child; + } + + const QVector items = m_db->queryItems(currentCriteria()); + const int cardW = m_settings->cardWidth(); + const QString lang = m_settings->preferredLanguage(); + + for (const MediaItem &it : items) { + auto *card = new MediaCard(it, m_cache, cardW, lang, m_grid); + connect(card, &MediaCard::activated, this, &MainWindow::openDetail); + connect(card, &MediaCard::editRequested, this, &MainWindow::openEditExisting); + connect(card, &MediaCard::deleteRequested, this, &MainWindow::deleteItem); + connect(card, &MediaCard::favoriteToggled, this, [this](int id, bool fav){ + m_db->setFavorite(id, fav); + refresh(); + }); + connect(card, &MediaCard::statusChangeRequested, this, + [this](int id, WatchStatus s){ + m_db->setItemStatus(id, s); + refresh(); + }); + m_flow->addWidget(card); + } + + m_emptyLabel->setVisible(items.isEmpty()); + m_scroll->setVisible(!items.isEmpty()); +} + +void MainWindow::showPanel(QDialog *panel) +{ + closeCurrentPanel(); + m_currentPanel = panel; + m_filters->hide(); + m_stack->addWidget(panel); + m_stack->setCurrentWidget(panel); + // accept()/reject() (Save/Cancel/Back) emit finished -> return to library + connect(panel, &QDialog::finished, this, [this]{ backToLibrary(); }); +} + +void MainWindow::closeCurrentPanel() +{ + if (!m_currentPanel) return; + QDialog *p = m_currentPanel; + m_currentPanel = nullptr; + m_stack->removeWidget(p); + p->deleteLater(); +} + +void MainWindow::backToLibrary() +{ + m_stack->setCurrentWidget(m_libraryPage); + closeCurrentPanel(); + m_filters->show(); + rebuildFilterLists(); + refresh(); +} + +void MainWindow::openDetail(int id) +{ + auto *dlg = new DetailDialog(id, m_db, m_cache, m_providers, m_settings, this); + connect(dlg, &DetailDialog::itemModified, this, &MainWindow::refresh); + connect(dlg, &DetailDialog::editRequested, this, &MainWindow::openEditExisting); + showPanel(dlg); +} + +void MainWindow::openEditNew() +{ + MediaType t = m_section.value_or(MediaType::Movie); + auto *dlg = new EditDialog(t, m_db, m_cache, m_providers, m_settings, this); + showPanel(dlg); +} + +void MainWindow::openEditExisting(int id) +{ + auto opt = m_db->loadItem(id); + if (!opt) return; + auto *dlg = new EditDialog(*opt, m_db, m_cache, m_providers, m_settings, this); + showPanel(dlg); +} + +void MainWindow::deleteItem(int id) +{ + auto opt = m_db->loadItem(id); + const QString title = opt ? opt->title : QStringLiteral("diesen Eintrag"); + if (QMessageBox::question(this, QStringLiteral("Löschen"), + QStringLiteral("„%1“ wirklich löschen?").arg(title)) + != QMessageBox::Yes) + return; + m_db->deleteItem(id); + rebuildFilterLists(); + refresh(); +} + +void MainWindow::openSettings() +{ + SettingsDialog dlg(m_settings, this); + connect(&dlg, &SettingsDialog::providersConfigChanged, this, [this]{ + m_providers->refreshConfig(); + m_theme->apply(); + refresh(); + }); + dlg.exec(); +} + +void MainWindow::toggleTheme() +{ + m_settings->setDarkMode(!m_settings->darkMode()); + m_theme->apply(); + refresh(); +} + +} // namespace umt diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h new file mode 100644 index 0000000..5a2b88c --- /dev/null +++ b/src/ui/MainWindow.h @@ -0,0 +1,82 @@ +#pragma once + +#include "core/Enums.h" +#include "core/Database.h" + +#include +#include + +class QLineEdit; +class QComboBox; +class QPushButton; +class QScrollArea; +class QButtonGroup; +class QLabel; +class QTimer; +class QStackedWidget; +class QDialog; + +namespace umt { + +class AppSettings; +class ThemeManager; +class ProviderManager; +class ImageCache; +class FilterPanel; +class FlowLayout; + +// Top-level window: sidebar (media-type sections + favorites + stats), +// toolbar (search, sort, add, settings, theme) and the card grid. +class MainWindow : public QMainWindow { + Q_OBJECT +public: + MainWindow(Database *db, AppSettings *settings, ThemeManager *theme, + ProviderManager *providers, ImageCache *cache, + QWidget *parent = nullptr); + +private slots: + void refresh(); + void openDetail(int id); + void openEditNew(); + void openEditExisting(int id); + void deleteItem(int id); + void openSettings(); + void toggleTheme(); + +private: + void buildUi(); + void buildSidebar(QWidget *parent); + FilterCriteria currentCriteria() const; + void rebuildFilterLists(); + // Presents a dialog-derived panel as a full page inside the main window + // (no separate window) and returns to the library when it finishes. + void showPanel(QDialog *panel); + void backToLibrary(); + void closeCurrentPanel(); + + Database *m_db; + AppSettings *m_settings; + ThemeManager *m_theme; + ProviderManager *m_providers; + ImageCache *m_cache; + + std::optional m_section; // nullopt = "Alle" + bool m_favoritesSection = false; + + QLineEdit *m_search; + QComboBox *m_sort; + QPushButton *m_sortDir; + FilterPanel *m_filters; + QStackedWidget*m_stack; + QWidget *m_libraryPage; + QScrollArea *m_scroll; + QWidget *m_grid; + FlowLayout *m_flow; + QButtonGroup*m_sidebarGroup; + QLabel *m_statsLabel; + QLabel *m_emptyLabel; + QTimer *m_searchDebounce; + QDialog *m_currentPanel = nullptr; +}; + +} // namespace umt diff --git a/src/ui/MediaCard.cpp b/src/ui/MediaCard.cpp new file mode 100644 index 0000000..82dc895 --- /dev/null +++ b/src/ui/MediaCard.cpp @@ -0,0 +1,199 @@ +#include "ui/MediaCard.h" +#include "providers/ImageCache.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +static QPixmap roundedCover(const QPixmap &src, int w, int h, int radius) +{ + QPixmap out(w, h); + out.fill(Qt::transparent); + QPainter p(&out); + p.setRenderHint(QPainter::Antialiasing); + p.setRenderHint(QPainter::SmoothPixmapTransform); + QPainterPath path; + path.addRoundedRect(0, 0, w, h, radius, radius); + p.setClipPath(path); + if (!src.isNull()) { + QPixmap scaled = src.scaled(w, h, Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + const int x = (w - scaled.width()) / 2; + const int y = (h - scaled.height()) / 2; + p.drawPixmap(x, y, scaled); + } else { + p.fillPath(path, QColor("#2c2c2e")); + } + return out; +} + +MediaCard::MediaCard(const MediaItem &item, ImageCache *cache, int width, + const QString &preferredLang, QWidget *parent) + : QFrame(parent) + , m_item(item) + , m_cache(cache) + , m_width(width) + , m_lang(preferredLang) +{ + setObjectName(QStringLiteral("MediaCard")); + setCursor(Qt::PointingHandCursor); + setFixedWidth(width); + setAttribute(Qt::WA_StyledBackground, true); + + const int coverH = int(width * 1.45); + + auto *lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(6); + + // ---- cover with overlaid badge + favorite ---- + auto *coverWrap = new QWidget(this); + coverWrap->setFixedSize(width, coverH); + m_cover = new QLabel(coverWrap); + m_cover->setGeometry(0, 0, width, coverH); + + m_badge = new QLabel(coverWrap); + m_badge->setText(statusLabel(m_item.status)); + m_badge->move(8, 8); + m_badge->setStyleSheet(QStringLiteral( + "background:%1; color:white; border-radius:8px; padding:3px 8px; " + "font-size:11px; font-weight:600;") + .arg(statusColor(m_item.status).name())); + m_badge->adjustSize(); + + m_fav = new QLabel(QStringLiteral("\u2605"), coverWrap); + m_fav->setStyleSheet(QStringLiteral( + "color:#ffd60a; font-size:20px; background:rgba(0,0,0,0.35);" + "border-radius:11px; padding:1px 4px;")); + m_fav->adjustSize(); + m_fav->move(width - m_fav->width() - 8, 8); + m_fav->setVisible(m_item.favorite); + + // category chip (bottom-left) so types stay distinguishable in "Alle" + auto *typeChip = new QLabel(mediaTypeSingular(m_item.type), coverWrap); + typeChip->setStyleSheet(QStringLiteral( + "background:%1; color:white; border-radius:8px; padding:3px 8px; " + "font-size:11px; font-weight:700;") + .arg(mediaTypeColor(m_item.type).name())); + typeChip->adjustSize(); + typeChip->move(8, coverH - typeChip->height() - 8); + + lay->addWidget(coverWrap); + + // ---- progress bar (only for multi-unit media) ---- + m_progress = new QProgressBar(this); + m_progress->setTextVisible(false); + m_progress->setRange(0, 100); + m_progress->setValue(int(m_item.progress() * 100)); + m_progress->setFixedHeight(6); + m_progress->setVisible(m_item.totalUnits() > 0); + lay->addWidget(m_progress); + + // ---- title ---- + m_title = new QLabel(m_item.bestTitleFor(m_lang), this); + m_title->setWordWrap(true); + m_title->setStyleSheet(QStringLiteral("font-weight:600; font-size:13px;")); + m_title->setMaximumHeight(38); + lay->addWidget(m_title); + + // ---- subtitle: year + progress + rating ---- + QString detail; + if (m_item.year > 0) detail = QString::number(m_item.year); + if (m_item.totalUnits() > 0) + detail += (detail.isEmpty() ? QString() : QStringLiteral(" · ")) + + QStringLiteral("%1/%2").arg(m_item.watchedUnits()).arg(m_item.totalUnits()); + if (m_item.rating > 0) + detail += (detail.isEmpty() ? QString() : QStringLiteral(" · ")) + + QStringLiteral("\u2605 %1").arg(m_item.rating / 2.0, 0, 'g', 2); + m_sub = new QLabel(detail, this); + m_sub->setObjectName(QStringLiteral("Subtle")); + m_sub->setStyleSheet(QStringLiteral("color:#8e8e93; font-size:11px;")); + lay->addWidget(m_sub); + + // initial cover + applyCover(QPixmap()); + if (!m_item.coverPath.isEmpty()) { + applyCover(ImageCache::loadLocal(m_item.coverPath)); + } else if (!m_item.coverUrl.isEmpty() && m_cache) { + connect(m_cache, &ImageCache::ready, this, &MediaCard::onCoverReady); + QPixmap pm = m_cache->get(m_item.coverUrl); + if (!pm.isNull()) applyCover(pm); + } + + auto *shadow = new QGraphicsDropShadowEffect(this); + shadow->setBlurRadius(18); + shadow->setOffset(0, 4); + shadow->setColor(QColor(0, 0, 0, 70)); + setGraphicsEffect(shadow); +} + +void MediaCard::applyCover(const QPixmap &pm) +{ + const int coverH = int(m_width * 1.45); + QPixmap use = pm; + if (use.isNull()) { + QPixmap ph(QStringLiteral(":/icons/placeholder.svg")); + use = ph; + } + m_cover->setPixmap(roundedCover(use, m_width, coverH, 12)); +} + +void MediaCard::onCoverReady(const QString &url, const QPixmap &pm) +{ + if (url == m_item.coverUrl) + applyCover(pm); +} + +void MediaCard::setHovered(bool on) +{ + setStyleSheet(on ? QStringLiteral("#MediaCard{background:rgba(127,127,127,0.10);" + "border-radius:14px;}") + : QString()); +} + +void MediaCard::enterEvent(QEnterEvent *) { setHovered(true); } +void MediaCard::leaveEvent(QEvent *) { setHovered(false); } + +void MediaCard::mousePressEvent(QMouseEvent *e) +{ + if (e->button() == Qt::LeftButton) + emit activated(m_item.id); + QFrame::mousePressEvent(e); +} + +void MediaCard::mouseDoubleClickEvent(QMouseEvent *) +{ + emit activated(m_item.id); +} + +void MediaCard::contextMenuEvent(QContextMenuEvent *e) +{ + QMenu menu(this); + menu.addAction(QStringLiteral("Öffnen"), this, [this]{ emit activated(m_item.id); }); + menu.addAction(QStringLiteral("Bearbeiten"), this, [this]{ emit editRequested(m_item.id); }); + menu.addAction(m_item.favorite ? QStringLiteral("Favorit entfernen") + : QStringLiteral("Als Favorit"), + this, [this]{ emit favoriteToggled(m_item.id, !m_item.favorite); }); + + auto *statusMenu = menu.addMenu(QStringLiteral("Status setzen")); + const WatchStatus all[] = {WatchStatus::PlanToWatch, WatchStatus::InProgress, + WatchStatus::Completed, WatchStatus::OnHold, + WatchStatus::Dropped}; + for (WatchStatus s : all) + statusMenu->addAction(statusLabel(s), this, + [this, s]{ emit statusChangeRequested(m_item.id, s); }); + + menu.addSeparator(); + menu.addAction(QStringLiteral("Löschen"), this, [this]{ emit deleteRequested(m_item.id); }); + menu.exec(e->globalPos()); +} + +} // namespace umt diff --git a/src/ui/MediaCard.h b/src/ui/MediaCard.h new file mode 100644 index 0000000..d8d2ff2 --- /dev/null +++ b/src/ui/MediaCard.h @@ -0,0 +1,58 @@ +#pragma once + +#include "core/MediaItem.h" + +#include + +class QLabel; +class QProgressBar; + +namespace umt { + +class ImageCache; + +// A poster-style card for one media item shown in the grid. +class MediaCard : public QFrame { + Q_OBJECT +public: + MediaCard(const MediaItem &item, ImageCache *cache, int width, + const QString &preferredLang, QWidget *parent = nullptr); + + int itemId() const { return m_item.id; } + const MediaItem &item() const { return m_item; } + +signals: + void activated(int id); + void editRequested(int id); + void deleteRequested(int id); + void favoriteToggled(int id, bool fav); + void statusChangeRequested(int id, umt::WatchStatus status); + +protected: + void mousePressEvent(QMouseEvent *) override; + void mouseDoubleClickEvent(QMouseEvent *) override; + void contextMenuEvent(QContextMenuEvent *) override; + void enterEvent(QEnterEvent *) override; + void leaveEvent(QEvent *) override; + +private slots: + void onCoverReady(const QString &url, const QPixmap &pm); + +private: + void applyCover(const QPixmap &pm); + void setHovered(bool on); + + MediaItem m_item; + ImageCache *m_cache; + int m_width; + QString m_lang; + + QLabel *m_cover; + QLabel *m_title; + QLabel *m_sub; + QLabel *m_badge; + QLabel *m_fav; + QProgressBar *m_progress; +}; + +} // namespace umt diff --git a/src/ui/OnlineSearchDialog.cpp b/src/ui/OnlineSearchDialog.cpp new file mode 100644 index 0000000..a3e349c --- /dev/null +++ b/src/ui/OnlineSearchDialog.cpp @@ -0,0 +1,154 @@ +#include "ui/OnlineSearchDialog.h" +#include "providers/ProviderManager.h" +#include "providers/ImageCache.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +OnlineSearchDialog::OnlineSearchDialog(ProviderManager *providers, ImageCache *cache, + MediaType type, QWidget *parent) + : QDialog(parent) + , m_providers(providers), m_cache(cache), m_type(type) +{ + setWindowTitle(QStringLiteral("Online-Suche · %1").arg(mediaTypeLabel(type))); + resize(620, 560); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(16, 16, 16, 16); + root->setSpacing(10); + + auto *searchRow = new QHBoxLayout; + m_query = new QLineEdit(this); + m_query->setPlaceholderText(QStringLiteral("Titel suchen…")); + m_searchBtn = new QPushButton(QStringLiteral("Suchen"), this); + m_searchBtn->setObjectName(QStringLiteral("Primary")); + searchRow->addWidget(m_query, 1); + searchRow->addWidget(m_searchBtn); + root->addLayout(searchRow); + + m_status = new QLabel(this); + m_status->setObjectName(QStringLiteral("Subtle")); + root->addWidget(m_status); + + m_list = new QListWidget(this); + m_list->setIconSize(QSize(56, 82)); + m_list->setSpacing(2); + root->addWidget(m_list, 1); + + auto *bb = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + m_okBtn = bb->button(QDialogButtonBox::Ok); + m_okBtn->setText(QStringLiteral("Übernehmen")); + m_okBtn->setEnabled(false); + root->addWidget(bb); + + connect(m_searchBtn, &QPushButton::clicked, this, &OnlineSearchDialog::doSearch); + connect(m_query, &QLineEdit::returnPressed, this, &OnlineSearchDialog::doSearch); + connect(m_list, &QListWidget::currentRowChanged, this, [this](int row){ + m_okBtn->setEnabled(row >= 0); + }); + connect(m_list, &QListWidget::itemDoubleClicked, this, [this]{ accformat(); }); + connect(bb, &QDialogButtonBox::accepted, this, &OnlineSearchDialog::accformat); + connect(bb, &QDialogButtonBox::rejected, this, &QDialog::reject); + + connect(m_providers, &ProviderManager::searchFinished, this, &OnlineSearchDialog::onResults); + connect(m_providers, &ProviderManager::detailsFinished, this, &OnlineSearchDialog::onDetails); + connect(m_providers, &ProviderManager::failed, this, &OnlineSearchDialog::onFailed); + connect(m_cache, &ImageCache::ready, this, &OnlineSearchDialog::onCoverReady); +} + +void OnlineSearchDialog::setQuery(const QString &q) +{ + m_query->setText(q); + if (!q.trimmed().isEmpty()) + doSearch(); +} + +void OnlineSearchDialog::doSearch() +{ + const QString q = m_query->text().trimmed(); + if (q.isEmpty()) return; + m_list->clear(); + m_results.clear(); + m_okBtn->setEnabled(false); + m_status->setText(QStringLiteral("Suche läuft…")); + m_searchBtn->setEnabled(false); + m_providers->search(q, m_type); +} + +void OnlineSearchDialog::onResults(const QVector &results) +{ + m_searchBtn->setEnabled(true); + m_results = results; + m_list->clear(); + if (results.isEmpty()) { + m_status->setText(QStringLiteral("Keine Treffer.")); + return; + } + m_status->setText(QStringLiteral("%1 Treffer").arg(results.size())); + + for (const SearchResult &r : results) { + QString label = r.title; + if (r.year > 0) label += QStringLiteral(" (%1)").arg(r.year); + if (!r.originalTitle.isEmpty() && r.originalTitle != r.title) + label += QStringLiteral("\n%1").arg(r.originalTitle); + auto *it = new QListWidgetItem(label, m_list); + it->setIcon(QIcon(QStringLiteral(":/icons/placeholder.svg"))); + if (!r.coverUrl.isEmpty()) { + QPixmap pm = m_cache->get(r.coverUrl); // async; updated in onCoverReady + if (!pm.isNull()) + it->setIcon(QIcon(pm)); + } + } +} + +void OnlineSearchDialog::onCoverReady(const QString &url, const QPixmap &pm) +{ + for (int i = 0; i < m_results.size() && i < m_list->count(); ++i) { + if (m_results[i].coverUrl == url) + m_list->item(i)->setIcon(QIcon(pm)); + } +} + +void OnlineSearchDialog::accformat() +{ + const int row = m_list->currentRow(); + if (row < 0 || row >= m_results.size()) return; + m_selected = m_results[row]; + + // Enrich Series/Manga with seasons/chapters before returning. + if (!m_waitingDetails && + (m_selected.type == MediaType::Series || m_selected.type == MediaType::Manga)) { + m_waitingDetails = true; + m_status->setText(QStringLiteral("Details werden geladen…")); + m_okBtn->setEnabled(false); + m_providers->fetchDetails(m_selected); + return; + } + accept(); +} + +void OnlineSearchDialog::onDetails(const SearchResult &result) +{ + if (!m_waitingDetails) return; + m_waitingDetails = false; + m_selected = result; + accept(); +} + +void OnlineSearchDialog::onFailed(const QString &msg) +{ + m_searchBtn->setEnabled(true); + m_waitingDetails = false; + m_okBtn->setEnabled(m_list->currentRow() >= 0); + m_status->setText(msg); +} + +} // namespace umt diff --git a/src/ui/OnlineSearchDialog.h b/src/ui/OnlineSearchDialog.h new file mode 100644 index 0000000..bc8232a --- /dev/null +++ b/src/ui/OnlineSearchDialog.h @@ -0,0 +1,55 @@ +#pragma once + +#include "providers/MetadataProvider.h" + +#include +#include + +class QLineEdit; +class QListWidget; +class QLabel; +class QPushButton; + +namespace umt { + +class ProviderManager; +class ImageCache; + +// Modal online metadata search. Lets the user query a provider, browse hits +// (with thumbnails) and pick one. On accept it enriches the chosen result +// with details (seasons/chapters) before returning. +class OnlineSearchDialog : public QDialog { + Q_OBJECT +public: + OnlineSearchDialog(ProviderManager *providers, ImageCache *cache, + MediaType type, QWidget *parent = nullptr); + + void setQuery(const QString &q); + SearchResult selectedResult() const { return m_selected; } + +private slots: + void doSearch(); + void onResults(const QVector &results); + void onDetails(const SearchResult &result); + void onFailed(const QString &msg); + void onCoverReady(const QString &url, const QPixmap &pm); + +private: + void accformat(); + + ProviderManager *m_providers; + ImageCache *m_cache; + MediaType m_type; + + QLineEdit *m_query; + QPushButton *m_searchBtn; + QListWidget *m_list; + QLabel *m_status; + QPushButton *m_okBtn; + + QVector m_results; + SearchResult m_selected; + bool m_waitingDetails = false; +}; + +} // namespace umt diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp new file mode 100644 index 0000000..30bdc40 --- /dev/null +++ b/src/ui/SettingsDialog.cpp @@ -0,0 +1,253 @@ +#include "ui/SettingsDialog.h" +#include "core/Settings.h" +#include "providers/ProxyLogin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace umt { + +SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent) + : QDialog(parent), m_settings(settings) +{ + setWindowTitle(QStringLiteral("Einstellungen")); + resize(520, 540); + + auto *root = new QVBoxLayout(this); + auto *form = new QFormLayout; + form->setSpacing(10); + + // Builds a row holding a field plus a small "?" button that pops up a guide. + auto withHelp = [this](QWidget *field, const QString &title, const QString &help) { + auto *btn = new QPushButton(QStringLiteral("?"), this); + btn->setObjectName(QStringLiteral("IconButton")); + btn->setFixedWidth(34); + btn->setToolTip(QStringLiteral("Anleitung anzeigen")); + connect(btn, &QPushButton::clicked, this, [this, title, help]() { + QMessageBox box(this); + box.setIcon(QMessageBox::Information); + box.setWindowTitle(title); + box.setTextFormat(Qt::RichText); + box.setText(help); + box.exec(); + }); + auto *row = new QHBoxLayout; + row->setContentsMargins(0, 0, 0, 0); + row->addWidget(field, 1); + row->addWidget(btn); + return row; + }; + + m_theme = new QComboBox(this); + m_theme->addItem(QStringLiteral("Dunkel"), true); + m_theme->addItem(QStringLiteral("Hell"), false); + m_theme->setCurrentIndex(m_settings->darkMode() ? 0 : 1); + form->addRow(QStringLiteral("Design"), m_theme); + + m_accent = new QPushButton(this); + m_accent->setFixedWidth(120); + form->addRow(QStringLiteral("Akzentfarbe"), m_accent); + + m_language = new QComboBox(this); + m_language->addItem(QStringLiteral("Deutsch"), QStringLiteral("de")); + m_language->addItem(QStringLiteral("English"), QStringLiteral("en")); + m_language->addItem(QStringLiteral("日本語"), QStringLiteral("ja")); + m_language->setCurrentIndex( + qMax(0, m_language->findData(m_settings->preferredLanguage()))); + form->addRow(QStringLiteral("Bevorzugte Sprache"), m_language); + + m_tmdbMode = new QComboBox(this); + m_tmdbMode->addItem(QStringLiteral("Eigener TMDB API-Key"), QStringLiteral("key")); + m_tmdbMode->addItem(QStringLiteral("Proxy-Server (OIDC)"), QStringLiteral("proxy")); + m_tmdbMode->setCurrentIndex( + qMax(0, m_tmdbMode->findData(m_settings->tmdbMode()))); + form->addRow(QStringLiteral("Filme/Serien über"), m_tmdbMode); + + m_tmdbKey = new QLineEdit(this); + m_tmdbKey->setText(m_settings->tmdbApiKey()); + m_tmdbKey->setPlaceholderText(QStringLiteral("TMDB API-Key (v3) für Filme/Serien")); + form->addRow(QStringLiteral("TMDB API-Key"), withHelp(m_tmdbKey, + QStringLiteral("TMDB API-Key holen"), + QStringLiteral( + "So bekommst du einen kostenlosen TMDB API-Key:" + "
    " + "
  1. Auf themoviedb.org " + "ein kostenloses Konto erstellen.
  2. " + "
  3. Oben rechts auf dein Profil → Einstellungen → " + "API gehen (" + "themoviedb.org/settings/api).
  4. " + "
  5. Einen API-Key (v3 auth) beantragen. Als Verwendungszweck reicht " + "„privat“ bzw. „Hobby“.
  6. " + "
  7. Den angezeigten API Key (v3 auth) kopieren und hier einfügen.
  8. " + "
" + "Alternativ kannst du oben auf „Proxy-Server (OIDC)“ umstellen und " + "dir den Key über euren gemeinsamen Proxy teilen."))); + + m_proxyUrl = new QLineEdit(this); + m_proxyUrl->setText(m_settings->proxyUrl()); + m_proxyUrl->setPlaceholderText(QStringLiteral("https://tmdb.example.com")); + m_proxyLogin = new QPushButton(QStringLiteral("Am Proxy anmelden…"), this); + auto *proxyRow = new QHBoxLayout; + proxyRow->setContentsMargins(0, 0, 0, 0); + proxyRow->addWidget(m_proxyUrl, 1); + proxyRow->addWidget(m_proxyLogin); + form->addRow(QStringLiteral("Proxy-URL"), proxyRow); + + m_proxyToken = new QLineEdit(this); + m_proxyToken->setText(m_settings->proxyToken()); + m_proxyToken->setEchoMode(QLineEdit::Password); + m_proxyToken->setPlaceholderText(QStringLiteral("Zugriffstoken von der Login-Seite")); + form->addRow(QStringLiteral("Proxy-Token"), withHelp(m_proxyToken, + QStringLiteral("Proxy-Token erhalten"), + QStringLiteral( + "So meldest du dich am Proxy-Server an:" + "
    " + "
  1. Oben „Filme/Serien über“ auf Proxy-Server (OIDC) " + "stellen.
  2. " + "
  3. Die Proxy-URL deines Servers eintragen " + "(z. B. https://tmdb.example.com).
  4. " + "
  5. Auf Am Proxy anmelden… klicken – dein Browser öffnet die " + "Anmeldung (Authentik). Nach erfolgreichem Login wird das Token " + "automatisch hier eingetragen.
  6. " + "
" + "Falls die automatische Übernahme nicht klappt, kannst du das Token auf " + "der Login-Seite im Browser auch manuell kopieren und hier einfügen."))); + + m_rawgKey = new QLineEdit(this); + m_rawgKey->setText(m_settings->rawgApiKey()); + m_rawgKey->setPlaceholderText(QStringLiteral("RAWG API-Key für Spiele (rawg.io)")); + form->addRow(QStringLiteral("RAWG API-Key"), withHelp(m_rawgKey, + QStringLiteral("RAWG API-Key holen"), + QStringLiteral( + "So bekommst du einen kostenlosen RAWG API-Key (für Spiele):" + "
    " + "
  1. Auf rawg.io ein kostenloses " + "Konto erstellen.
  2. " + "
  3. Die Seite rawg.io/apidocs " + "öffnen und auf Get API Key klicken.
  4. " + "
  5. Das Formular ausfüllen (für privaten Gebrauch genügen einfache " + "Angaben).
  6. " + "
  7. Den angezeigten Key kopieren und hier einfügen.
  8. " + "
" + "Ohne Key funktionieren Filme, Serien, Mangas und Bücher weiterhin – nur " + "die Spiele-Suche braucht ihn."))); + + m_autoCovers = new QCheckBox(QStringLiteral("Cover automatisch laden"), this); + m_autoCovers->setChecked(m_settings->autoFetchCovers()); + form->addRow(QString(), m_autoCovers); + + m_cardSize = new QSlider(Qt::Horizontal, this); + m_cardSize->setRange(130, 260); + m_cardSize->setValue(m_settings->cardWidth()); + form->addRow(QStringLiteral("Kartengröße"), m_cardSize); + + root->addLayout(form); + + auto *hint = new QLabel( + QStringLiteral("Bücher (OpenLibrary) und Mangas (AniList) benötigen keinen Key.\n" + "Für Filme & Serien entweder einen kostenlosen TMDB-Key eintragen " + "oder einen Proxy-Server nutzen (dann teilt sich die Gruppe einen Key).\n" + "Für Spiele wird ein kostenloser RAWG-Key benötigt (rawg.io)."), this); + hint->setObjectName(QStringLiteral("Subtle")); + hint->setWordWrap(true); + root->addWidget(hint); + root->addStretch(1); + + auto *bb = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, this); + root->addWidget(bb); + + updateAccentSwatch(); + updateTmdbModeUi(); + connect(m_accent, &QPushButton::clicked, this, &SettingsDialog::pickAccent); + connect(m_proxyLogin, &QPushButton::clicked, this, &SettingsDialog::startProxyLogin); + connect(m_tmdbMode, &QComboBox::currentIndexChanged, + this, &SettingsDialog::updateTmdbModeUi); + connect(bb, &QDialogButtonBox::accepted, this, [this]{ + m_settings->setDarkMode(m_theme->currentData().toBool()); + m_settings->setPreferredLanguage(m_language->currentData().toString()); + m_settings->setTmdbMode(m_tmdbMode->currentData().toString()); + m_settings->setTmdbApiKey(m_tmdbKey->text().trimmed()); + m_settings->setProxyUrl(m_proxyUrl->text().trimmed()); + m_settings->setProxyToken(m_proxyToken->text().trimmed()); + m_settings->setRawgApiKey(m_rawgKey->text().trimmed()); + m_settings->setAutoFetchCovers(m_autoCovers->isChecked()); + m_settings->setCardWidth(m_cardSize->value()); + emit providersConfigChanged(); + accept(); + }); + connect(bb, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +void SettingsDialog::updateTmdbModeUi() +{ + const bool proxy = m_tmdbMode->currentData().toString() == QLatin1String("proxy"); + m_tmdbKey->setEnabled(!proxy); + m_proxyUrl->setEnabled(proxy); + m_proxyLogin->setEnabled(proxy); + m_proxyToken->setEnabled(proxy); +} + +void SettingsDialog::startProxyLogin() +{ + const QString url = m_proxyUrl->text().trimmed(); + if (url.isEmpty()) { + QMessageBox::information(this, QStringLiteral("Proxy-Anmeldung"), + QStringLiteral("Bitte zuerst die Proxy-URL eintragen.")); + return; + } + + auto *login = new ProxyLogin(this); + m_proxyLogin->setEnabled(false); + m_proxyLogin->setText(QStringLiteral("Warte auf Browser…")); + + auto restore = [this, login]() { + m_proxyLogin->setText(QStringLiteral("Am Proxy anmelden…")); + m_proxyLogin->setEnabled(true); + login->deleteLater(); + }; + + connect(login, &ProxyLogin::succeeded, this, [this, restore](const QString &token) { + m_proxyToken->setText(token); + restore(); + QMessageBox::information(this, QStringLiteral("Proxy-Anmeldung"), + QStringLiteral("Anmeldung erfolgreich – das Token wurde übernommen. " + "Zum Speichern auf „Speichern“ klicken.")); + }); + connect(login, &ProxyLogin::failed, this, [this, restore](const QString &message) { + restore(); + QMessageBox::warning(this, QStringLiteral("Proxy-Anmeldung"), message); + }); + + login->start(url); +} + +void SettingsDialog::updateAccentSwatch() +{ + const QColor c = m_settings->accentColor(); + m_accent->setText(c.name()); + m_accent->setStyleSheet(QStringLiteral( + "background:%1; color:%2; border:none; border-radius:8px; padding:7px;") + .arg(c.name(), c.lightness() > 140 ? QStringLiteral("#000") : QStringLiteral("#fff"))); +} + +void SettingsDialog::pickAccent() +{ + const QColor c = QColorDialog::getColor(m_settings->accentColor(), this, + QStringLiteral("Akzentfarbe wählen")); + if (c.isValid()) { + m_settings->setAccentColor(c); + updateAccentSwatch(); + } +} + +} // namespace umt diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h new file mode 100644 index 0000000..955dffb --- /dev/null +++ b/src/ui/SettingsDialog.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +class QLineEdit; +class QComboBox; +class QCheckBox; +class QSlider; +class QPushButton; + +namespace umt { + +class AppSettings; + +// Preferences: theme, accent colour, language, TMDB key, cover auto-fetch, +// card size. Writes straight through to AppSettings (which emits change signals). +class SettingsDialog : public QDialog { + Q_OBJECT +public: + SettingsDialog(AppSettings *settings, QWidget *parent = nullptr); + +signals: + void providersConfigChanged(); + +private: + void pickAccent(); + void updateAccentSwatch(); + void startProxyLogin(); + + AppSettings *m_settings; + + void updateTmdbModeUi(); + + QComboBox *m_theme; + QPushButton *m_accent; + QComboBox *m_language; + QComboBox *m_tmdbMode; + QLineEdit *m_tmdbKey; + QLineEdit *m_proxyUrl; + QPushButton *m_proxyLogin; + QLineEdit *m_proxyToken; + QLineEdit *m_rawgKey; + QCheckBox *m_autoCovers; + QSlider *m_cardSize; +}; + +} // namespace umt diff --git a/src/ui/StarRating.cpp b/src/ui/StarRating.cpp new file mode 100644 index 0000000..2cab397 --- /dev/null +++ b/src/ui/StarRating.cpp @@ -0,0 +1,120 @@ +#include "ui/StarRating.h" + +#include +#include +#include +#include +#include + +namespace umt { + +StarRating::StarRating(QWidget *parent) : QWidget(parent) +{ + setMouseTracking(true); + setCursor(Qt::PointingHandCursor); +} + +void StarRating::setRating(int r) +{ + r = qBound(0, r, 10); + if (r == m_rating) return; + m_rating = r; + update(); +} + +QSize StarRating::sizeHint() const +{ + return QSize(m_star * 5 + 4 * 4, m_star); +} + +int StarRating::ratingAt(const QPointF &pos) const +{ + const double step = m_star + 4; // star width + spacing + const double x = pos.x(); + for (int i = 0; i < 5; ++i) { + const double left = i * step; + if (x < left + step) { + const bool firstHalf = x < left + step / 2.0; + return i * 2 + (firstHalf ? 1 : 2); + } + } + return 10; +} + +static QPainterPath starPath(qreal cx, qreal cy, qreal r) +{ + QPainterPath p; + const qreal inner = r * 0.42; + for (int i = 0; i < 10; ++i) { + const qreal ang = -M_PI / 2 + i * M_PI / 5; + const qreal rad = (i % 2 == 0) ? r : inner; + const qreal x = cx + rad * std::cos(ang); + const qreal y = cy + rad * std::sin(ang); + if (i == 0) p.moveTo(x, y); else p.lineTo(x, y); + } + p.closeSubpath(); + return p; +} + +void StarRating::paintEvent(QPaintEvent *) +{ + QPainter pr(this); + pr.setRenderHint(QPainter::Antialiasing); + + const int shown = (m_hover >= 0 && !m_readOnly) ? m_hover : m_rating; + const QColor on = palette().highlight().color(); + const QColor off = QColor(0, 0, 0, 0); + const QColor border = palette().color(QPalette::PlaceholderText); + + const double step = m_star + 4; + const qreal r = m_star / 2.0; + + for (int i = 0; i < 5; ++i) { + const qreal cx = i * step + r; + const qreal cy = height() / 2.0; + QPainterPath path = starPath(cx, cy, r); + + pr.setPen(QPen(border, 1.2)); + pr.setBrush(off); + pr.drawPath(path); + + const int filledHalves = shown - i * 2; // 0,1,>=2 + if (filledHalves <= 0) continue; + + pr.save(); + pr.setClipPath(path); + QRectF fill(cx - r, cy - r, m_star, m_star); + if (filledHalves == 1) + fill.setWidth(m_star / 2.0); // left half only + pr.setPen(Qt::NoPen); + pr.setBrush(on); + pr.drawRect(fill); + pr.restore(); + } +} + +void StarRating::mouseMoveEvent(QMouseEvent *e) +{ + if (m_readOnly) return; + m_hover = ratingAt(e->position()); + update(); +} + +void StarRating::mousePressEvent(QMouseEvent *e) +{ + if (m_readOnly || e->button() != Qt::LeftButton) return; + int r = ratingAt(e->position()); + if (r == m_rating) r = 0; // click same -> clear + m_rating = r; + m_hover = -1; + update(); + emit ratingChanged(m_rating); +} + +void StarRating::leaveEvent(QEvent *) +{ + m_hover = -1; + update(); +} + +} // namespace umt diff --git a/src/ui/StarRating.h b/src/ui/StarRating.h new file mode 100644 index 0000000..3c8fa9b --- /dev/null +++ b/src/ui/StarRating.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +namespace umt { + +// Interactive 0..10 rating widget rendered as 5 half-clickable stars. +class StarRating : public QWidget { + Q_OBJECT +public: + explicit StarRating(QWidget *parent = nullptr); + + int rating() const { return m_rating; } // 0..10 (in half-stars) + void setRating(int r); + void setReadOnly(bool ro) { m_readOnly = ro; } + void setStarSize(int px) { m_star = px; updateGeometry(); } + + QSize sizeHint() const override; + +signals: + void ratingChanged(int rating); + +protected: + void paintEvent(QPaintEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void leaveEvent(QEvent *) override; + +private: + int ratingAt(const QPointF &pos) const; // -> 0..10 + + int m_rating = 0; + int m_hover = -1; + int m_star = 22; + bool m_readOnly = false; +}; + +} // namespace umt diff --git a/src/ui/ThemeManager.cpp b/src/ui/ThemeManager.cpp new file mode 100644 index 0000000..2051177 --- /dev/null +++ b/src/ui/ThemeManager.cpp @@ -0,0 +1,304 @@ +#include "ui/ThemeManager.h" +#include "core/Settings.h" + +#include +#include + +namespace umt { + +ThemeManager::ThemeManager(AppSettings *settings, QObject *parent) + : QObject(parent), m_settings(settings) +{ +} + +bool ThemeManager::isDark() const { return m_settings->darkMode(); } +QColor ThemeManager::accent() const { return m_settings->accentColor(); } + +QColor ThemeManager::mix(const QColor &a, const QColor &b, double t) { + return QColor::fromRgbF( + a.redF() * (1 - t) + b.redF() * t, + a.greenF() * (1 - t) + b.greenF() * t, + a.blueF() * (1 - t) + b.blueF() * t); +} + +void ThemeManager::apply() { + const bool dark = isDark(); + const QColor accent = this->accent(); + + QPalette pal; + if (dark) { + const QColor base(QStringLiteral("#1c1c1e")); + const QColor window(QStringLiteral("#161618")); + const QColor text(QStringLiteral("#f2f2f7")); + pal.setColor(QPalette::Window, window); + pal.setColor(QPalette::WindowText, text); + pal.setColor(QPalette::Base, base); + pal.setColor(QPalette::AlternateBase, QColor(QStringLiteral("#2c2c2e"))); + pal.setColor(QPalette::Text, text); + pal.setColor(QPalette::Button, QColor(QStringLiteral("#2c2c2e"))); + pal.setColor(QPalette::ButtonText, text); + pal.setColor(QPalette::ToolTipBase, base); + pal.setColor(QPalette::ToolTipText, text); + pal.setColor(QPalette::Highlight, accent); + pal.setColor(QPalette::HighlightedText, Qt::white); + pal.setColor(QPalette::PlaceholderText, QColor(QStringLiteral("#8e8e93"))); + pal.setColor(QPalette::Disabled, QPalette::Text, QColor(QStringLiteral("#5a5a5e"))); + } else { + const QColor base(QStringLiteral("#ffffff")); + const QColor window(QStringLiteral("#f2f2f7")); + const QColor text(QStringLiteral("#1c1c1e")); + pal.setColor(QPalette::Window, window); + pal.setColor(QPalette::WindowText, text); + pal.setColor(QPalette::Base, base); + pal.setColor(QPalette::AlternateBase, QColor(QStringLiteral("#eaeaef"))); + pal.setColor(QPalette::Text, text); + pal.setColor(QPalette::Button, QColor(QStringLiteral("#ffffff"))); + pal.setColor(QPalette::ButtonText, text); + pal.setColor(QPalette::ToolTipBase, base); + pal.setColor(QPalette::ToolTipText, text); + pal.setColor(QPalette::Highlight, accent); + pal.setColor(QPalette::HighlightedText, Qt::white); + pal.setColor(QPalette::PlaceholderText, QColor(QStringLiteral("#8e8e93"))); + pal.setColor(QPalette::Disabled, QPalette::Text, QColor(QStringLiteral("#b0b0b5"))); + } + qApp->setPalette(pal); + qApp->setStyleSheet(buildStyleSheet()); +} + +QString ThemeManager::buildStyleSheet() const { + const bool dark = isDark(); + const QColor accent = this->accent(); + const QColor accentHover = mix(accent, dark ? Qt::white : Qt::black, 0.15); + const QColor accentDim = mix(accent, dark ? QColor("#1c1c1e") : QColor("#ffffff"), 0.55); + + const QString bg = dark ? QStringLiteral("#161618") : QStringLiteral("#f2f2f7"); + const QString surface = dark ? QStringLiteral("#1c1c1e") : QStringLiteral("#ffffff"); + const QString surface2 = dark ? QStringLiteral("#2c2c2e") : QStringLiteral("#eaeaef"); + const QString surface3 = dark ? QStringLiteral("#3a3a3c") : QStringLiteral("#dcdce2"); + const QString border = dark ? QStringLiteral("#3a3a3c") : QStringLiteral("#d1d1d6"); + const QString text = dark ? QStringLiteral("#f2f2f7") : QStringLiteral("#1c1c1e"); + const QString subtle = dark ? QStringLiteral("#8e8e93") : QStringLiteral("#6c6c70"); + const QString a = accent.name(); + const QString aHover = accentHover.name(); + const QString aDim = accentDim.name(); + + return QStringLiteral(R"( +* { outline: none; } + +QWidget { + color: %TEXT%; + font-size: 13px; +} + +QMainWindow, QDialog { background: %BG%; } + +QToolTip { + background: %SURFACE2%; + color: %TEXT%; + border: 1px solid %BORDER%; + border-radius: 6px; + padding: 4px 8px; +} + +/* ---- Sidebar ---- */ +#Sidebar { + background: %SURFACE%; + border-right: 1px solid %BORDER%; +} +#Sidebar QPushButton { + text-align: left; + padding: 9px 14px; + border: none; + border-radius: 8px; + background: transparent; + color: %TEXT%; + font-size: 14px; +} +#Sidebar QPushButton:hover { background: %SURFACE2%; } +#Sidebar QPushButton:checked { + background: %ADIM%; + color: %ACCENT%; + font-weight: 600; +} +#SidebarTitle { color: %SUBTLE%; font-size: 11px; font-weight: 700; padding: 6px 14px; } + +/* ---- Filter panel ---- */ +/* Explicit background so the panel paints its own top edge (prevents a 1px + frame/compositor seam from showing through on KDE/Breeze under Wayland). */ +#FilterPanel { background: %BG%; } + +/* ---- Top bar ---- */ +#TopBar { background: %BG%; } + +QLineEdit { + background: %SURFACE%; + border: 1px solid %BORDER%; + border-radius: 9px; + padding: 7px 12px; + selection-background-color: %ACCENT%; +} +QLineEdit:focus { border: 1px solid %ACCENT%; } + +QPushButton { + background: %SURFACE2%; + border: 1px solid %BORDER%; + border-radius: 9px; + padding: 7px 14px; + color: %TEXT%; +} +QPushButton:hover { background: %SURFACE3%; } +QPushButton:pressed { background: %BORDER%; } + +/* Square icon-only buttons (sort direction, theme, settings): give the glyph + the full width so round/wide symbols like ◐ are not clipped horizontally. */ +QPushButton#IconButton { + padding: 6px 0; + font-size: 16px; +} +QPushButton#IconButton:checked { + background: %ADIM%; + color: %ACCENT%; + border: 1px solid %ACCENT%; +} + +QPushButton#Primary { + background: %ACCENT%; + border: none; + color: white; + font-weight: 600; +} +QPushButton#Primary:hover { background: %AHOVER%; } + +QPushButton:disabled { color: %SUBTLE%; } + +QComboBox { + background: %SURFACE2%; + border: 1px solid %BORDER%; + border-radius: 9px; + padding: 6px 12px; + min-width: 90px; +} +QComboBox:hover { border: 1px solid %ACCENT%; } +QComboBox::drop-down { border: none; width: 22px; } +QComboBox QAbstractItemView { + background: %SURFACE%; + border: 1px solid %BORDER%; + border-radius: 8px; + selection-background-color: %ACCENT%; + selection-color: white; + padding: 4px; +} + +QSpinBox, QDateEdit { + background: %SURFACE2%; + border: 1px solid %BORDER%; + border-radius: 8px; + padding: 5px 8px; +} + +QPlainTextEdit, QTextEdit { + background: %SURFACE%; + border: 1px solid %BORDER%; + border-radius: 9px; + padding: 8px; + selection-background-color: %ACCENT%; +} + +QCheckBox::indicator, QTreeView::indicator { + width: 18px; height: 18px; + border: 1px solid %BORDER%; + border-radius: 5px; + background: %SURFACE%; +} +QCheckBox::indicator:checked, QTreeView::indicator:checked { + background: %ACCENT%; + border: 1px solid %ACCENT%; + image: url(:/icons/check.svg); +} +QCheckBox::indicator:indeterminate, QTreeView::indicator:indeterminate { + background: %ADIM%; + border: 1px solid %ACCENT%; +} + +QScrollArea, #ContentArea { background: %BG%; border: none; } +QScrollArea > QWidget > QWidget { background: transparent; } + +QScrollBar:vertical { + background: transparent; width: 12px; margin: 2px; +} +QScrollBar::handle:vertical { + background: %SURFACE3%; border-radius: 5px; min-height: 30px; +} +QScrollBar::handle:vertical:hover { background: %SUBTLE%; } +QScrollBar::add-line, QScrollBar::sub-line { height: 0; } +QScrollBar:horizontal { background: transparent; height: 12px; margin: 2px; } +QScrollBar::handle:horizontal { background: %SURFACE3%; border-radius: 5px; min-width: 30px; } + +QTreeView, QListWidget, QTableWidget { + background: %SURFACE%; + border: 1px solid %BORDER%; + border-radius: 10px; + padding: 4px; +} +QTreeView::item, QListWidget::item { padding: 5px; border-radius: 6px; } +QTreeView::item:selected, QListWidget::item:selected { + background: %ADIM%; color: %TEXT%; +} +QHeaderView::section { + background: %SURFACE2%; + border: none; + padding: 6px; + color: %SUBTLE%; +} + +QProgressBar { + background: %SURFACE2%; + border: none; + border-radius: 5px; + height: 7px; + text-align: center; +} +QProgressBar::chunk { background: %ACCENT%; border-radius: 5px; } + +QTabWidget::pane { border: 1px solid %BORDER%; border-radius: 10px; top: -1px; } +QTabBar::tab { + background: transparent; + padding: 8px 16px; + border-radius: 8px; + color: %SUBTLE%; +} +QTabBar::tab:selected { background: %SURFACE2%; color: %ACCENT%; font-weight: 600; } + +QMenu { + background: %SURFACE%; + border: 1px solid %BORDER%; + border-radius: 10px; + padding: 6px; +} +QMenu::item { padding: 7px 24px; border-radius: 6px; } +QMenu::item:selected { background: %ACCENT%; color: white; } +QMenu::separator { height: 1px; background: %BORDER%; margin: 4px 8px; } + +QLabel#Subtle { color: %SUBTLE%; } +QLabel#H1 { font-size: 26px; font-weight: 700; } +QLabel#H2 { font-size: 18px; font-weight: 600; } + +#StatChip { + background: %SURFACE2%; + border-radius: 10px; + padding: 8px 14px; +} +)") + .replace(QStringLiteral("%BG%"), bg) + .replace(QStringLiteral("%SURFACE3%"), surface3) + .replace(QStringLiteral("%SURFACE2%"), surface2) + .replace(QStringLiteral("%SURFACE%"), surface) + .replace(QStringLiteral("%BORDER%"), border) + .replace(QStringLiteral("%TEXT%"), text) + .replace(QStringLiteral("%SUBTLE%"), subtle) + .replace(QStringLiteral("%AHOVER%"), aHover) + .replace(QStringLiteral("%ADIM%"), aDim) + .replace(QStringLiteral("%ACCENT%"), a); +} + +} // namespace umt diff --git a/src/ui/ThemeManager.h b/src/ui/ThemeManager.h new file mode 100644 index 0000000..517bc6f --- /dev/null +++ b/src/ui/ThemeManager.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +class QApplication; + +namespace umt { + +class AppSettings; + +// Generates and applies a full Qt Style Sheet (QSS) for the whole app based on +// the current dark/light preference and the user-chosen accent colour. +class ThemeManager : public QObject { + Q_OBJECT +public: + explicit ThemeManager(AppSettings *settings, QObject *parent = nullptr); + + // (Re)applies the palette + stylesheet to the running application. + void apply(); + + bool isDark() const; + QColor accent() const; + +private: + QString buildStyleSheet() const; + static QColor mix(const QColor &a, const QColor &b, double t); + + AppSettings *m_settings; +}; + +} // namespace umt