feat: initial commit for media tracking software

- Setup C++/Qt6 desktop application structure
- Added initial Go proxy backend for TMDB/IGDB integration
This commit is contained in:
Tronax 2026-06-15 02:02:25 +02:00
commit 3f07c78316
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
63 changed files with 6547 additions and 0 deletions

37
.gitignore vendored Normal file
View file

@ -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

104
CMakeLists.txt Normal file
View file

@ -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()

83
README.md Normal file
View file

@ -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`.

BIN
resources/icons/appicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
resources/icons/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View file

@ -0,0 +1,16 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0a84ff"/>
<stop offset="0.5" stop-color="#bf5af2"/>
<stop offset="1" stop-color="#ff375f"/>
</linearGradient>
</defs>
<rect x="32" y="32" width="448" height="448" rx="112" fill="url(#bg)"/>
<!-- play triangle -->
<path d="M196 168 L196 344 L344 256 Z" fill="#ffffff"/>
<!-- checklist tick badge -->
<circle cx="356" cy="356" r="86" fill="#ffffff"/>
<path d="M318 356 L346 384 L398 326" fill="none" stroke="#30d158"
stroke-width="26" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 717 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>

After

Width:  |  Height:  |  Size: 187 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 260">
<rect width="180" height="260" rx="10" fill="#2c2c2e"/>
<path d="M90 95a26 26 0 1 0 0 52 26 26 0 0 0 0-52zm0 12a14 14 0 1 1 0 28 14 14 0 0 1 0-28z" fill="#5a5a5e"/>
<rect x="40" y="170" width="100" height="10" rx="5" fill="#3a3a3c"/>
<rect x="55" y="190" width="70" height="8" rx="4" fill="#3a3a3c"/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

View file

@ -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;

7
resources/resources.qrc Normal file
View file

@ -0,0 +1,7 @@
<RCC>
<qresource prefix="/icons">
<file alias="check.svg">icons/check.svg</file>
<file alias="placeholder.svg">icons/placeholder.svg</file>
<file alias="appicon.svg">icons/appicon.svg</file>
</qresource>
</RCC>

1
resources/win/app.rc Normal file
View file

@ -0,0 +1 @@
IDI_ICON1 ICON "../icons/appicon.ico"

33
server/.env.example Normal file
View file

@ -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

14
server/Dockerfile Normal file
View file

@ -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"]

69
server/README.md Normal file
View file

@ -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 |

89
server/config.go Normal file
View file

@ -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
}

13
server/go.mod Normal file
View file

@ -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
)

18
server/go.sum Normal file
View file

@ -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=

318
server/main.go Normal file
View file

@ -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, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Media Tracker TMDB-Proxy</title>
<style>body{font-family:system-ui,sans-serif;max-width:640px;margin:8vh auto;padding:0 20px;
background:#161618;color:#f2f2f7}a.btn{display:inline-block;background:#0a84ff;color:#fff;
text-decoration:none;padding:12px 22px;border-radius:10px;font-weight:600}
.muted{color:#8e8e93}</style></head><body>
<h1>Ultimate Media Tracker &ndash; TMDB-Proxy</h1>
<p class="muted">Melde dich mit deinem Konto an, um ein Zugriffstoken f&uuml;r die
Desktop-App zu erhalten. Damit nutzt du TMDB, ohne einen eigenen API-Key zu brauchen.</p>
<p><a class="btn" href="/login">Mit Authentik anmelden</a></p>
</body></html>`)
}
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, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dein Zugriffstoken</title>
<style>body{font-family:system-ui,sans-serif;max-width:680px;margin:6vh auto;padding:0 20px;
background:#161618;color:#f2f2f7}code,textarea{font-family:ui-monospace,monospace}
textarea{width:100%;height:120px;background:#1c1c1e;color:#30d158;border:1px solid #3a3a3c;
border-radius:10px;padding:12px;font-size:13px}.muted{color:#8e8e93}
ol{line-height:1.7}</style></head><body>
<h1>Angemeldet als ` + htmlEscape(who) + `</h1>
<p>Kopiere dieses Token und trage es in der Desktop-App unter
<b>Einstellungen &rarr; Filme/Serien &uuml;ber &rarr; Proxy-Server</b> ein:</p>
<textarea readonly onclick="this.select()">` + htmlEscape(token) + `</textarea>
<ol class="muted">
<li>Proxy-URL: <code>` + htmlEscape(s.cfg.PublicURL) + `</code></li>
<li>Proxy-Token: das Feld oben</li>
</ol>
<p class="muted">Das Token ist persönlich und läuft nach einiger Zeit ab &ndash; dann hier erneut anmelden.</p>
</body></html>`)
}
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
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/<path> -> <TMDBBase>/<path>, 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)
}

68
server/token.go Normal file
View file

@ -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))
}

677
src/core/Database.cpp Normal file
View file

@ -0,0 +1,677 @@
#include "core/Database.h"
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlError>
#include <QVariant>
#include <QDateTime>
#include <QFileInfo>
#include <QDir>
#include <QUuid>
#include <QVariantList>
#include <QDebug>
#include <algorithm>
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<MediaItem> 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<MediaItem> 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<MediaItem> 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<QString, QString> autoMap = titleFranchiseMap();
QVector<MediaItem> 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<QString, QString> 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<QString, int> Database::franchiseCounts() const {
const QHash<QString, QString> autoMap = titleFranchiseMap();
QHash<QString, int> 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<QString, int> 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

96
src/core/Database.h Normal file
View file

@ -0,0 +1,96 @@
#pragma once
#include "core/MediaItem.h"
#include <QObject>
#include <QString>
#include <QStringList>
#include <QVector>
#include <optional>
namespace umt {
// Describes an active set of filters applied to the library listing.
struct FilterCriteria {
std::optional<MediaType> type; // restrict to one media type
std::optional<WatchStatus> 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<MediaItem> loadItem(int id);
// Returns items (with full segment trees) matching the criteria.
QVector<MediaItem> 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<int, int> perType; // MediaType(int) -> count
};
Stats stats() const;
signals:
void changed();
private:
bool exec(const QString &sql);
void initSchema();
QHash<QString, int> franchiseCounts() const;
// Auto-detected franchise label per distinct title (explicit overrides not
// applied here; callers layer those on top).
QHash<QString, QString> 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

148
src/core/Enums.h Normal file
View file

@ -0,0 +1,148 @@
#pragma once
#include <QString>
#include <QColor>
#include <QMetaType>
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)

8
src/core/MediaItem.cpp Normal file
View file

@ -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

234
src/core/MediaItem.h Normal file
View file

@ -0,0 +1,234 @@
#pragma once
#include "core/Enums.h"
#include <QString>
#include <QStringList>
#include <QDateTime>
#include <QVector>
#include <QHash>
#include <QMap>
#include <QSet>
#include <QVector>
#include <QRegularExpression>
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<Unit> 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<QString, QString> 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<Segment> segments;
// Arbitrary user-defined fields (high customizability).
QMap<QString, QString> 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<QString> &franchiseStopWords() {
static const QSet<QString> 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<QString, QString> clusterFranchises(const QStringList &titlesIn) {
static const QRegularExpression nonWord(QStringLiteral("[^\\p{L}\\p{N}]+"));
// De-duplicate while preserving order.
QStringList titles;
{
QSet<QString> seen;
for (const QString &t : titlesIn)
if (!t.trimmed().isEmpty() && !seen.contains(t)) {
seen.insert(t);
titles << t;
}
}
const int n = titles.size();
QVector<QStringList> 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<QString, int> 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<QString> &stop = franchiseStopWords();
QHash<QString, QString> labelForKey; // normalized prefix -> display label
QHash<QString, QString> 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

107
src/core/Settings.cpp Normal file
View file

@ -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

71
src/core/Settings.h Normal file
View file

@ -0,0 +1,71 @@
#pragma once
#include <QObject>
#include <QString>
#include <QColor>
#include <QSettings>
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

73
src/main.cpp Normal file
View file

@ -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 <QApplication>
#include <QIcon>
#include <QStandardPaths>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMessageBox>
#include <QFontDatabase>
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<MediaType>();
qRegisterMetaType<WatchStatus>();
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();
}

View file

@ -0,0 +1,130 @@
#include "providers/AniListProvider.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QRegularExpression>
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<SearchResult> 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("<br>"), 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

View file

@ -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

View file

@ -0,0 +1,125 @@
#include "providers/ImageCache.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStandardPaths>
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QUuid>
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

View file

@ -0,0 +1,50 @@
#pragma once
#include <QObject>
#include <QString>
#include <QPixmap>
#include <QHash>
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<QString, QPixmap> m_mem;
QHash<QNetworkReply *, QString> m_pending; // reply -> url
QHash<QNetworkReply *, bool> m_toLibrary; // reply -> permanent save
};
} // namespace umt

View file

@ -0,0 +1,6 @@
#include "providers/MetadataProvider.h"
// Interface translation unit. Concrete providers live in their own files.
namespace umt {
} // namespace umt

View file

@ -0,0 +1,62 @@
#pragma once
#include "core/Enums.h"
#include <QObject>
#include <QString>
#include <QStringList>
#include <QMap>
#include <QVector>
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<QString, QString> localizedTitles; // lang -> title
QVector<ResultSegment> 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<SearchResult> &results);
void detailsFinished(const SearchResult &result);
void failed(const QString &message);
};
} // namespace umt

View file

@ -0,0 +1,75 @@
#include "providers/OpenLibraryProvider.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrlQuery>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
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<SearchResult> 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

View file

@ -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

View file

@ -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<MetadataProvider *> 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

View file

@ -0,0 +1,45 @@
#pragma once
#include "providers/MetadataProvider.h"
#include <QObject>
#include <QVector>
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<SearchResult> &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

View file

@ -0,0 +1,155 @@
#include "providers/ProxyLogin.h"
#include <QDesktopServices>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTimer>
#include <QUrl>
#include <QUrlQuery>
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(
"<!doctype html><html lang=\"de\"><head><meta charset=\"utf-8\">"
"<title>Anmeldung erfolgreich</title>"
"<style>body{font-family:system-ui,sans-serif;max-width:520px;"
"margin:12vh auto;padding:0 20px;background:#161618;color:#f2f2f7;"
"text-align:center}h1{color:#30d158}.muted{color:#8e8e93}</style>"
"</head><body><h1>Anmeldung erfolgreich</h1>"
"<p class=\"muted\">Du kannst dieses Fenster jetzt schließen und zur "
"App zurückkehren.</p></body></html>")));
sock->flush();
sock->disconnectFromHost();
m_server->close();
emit succeeded(token);
});
}
} // namespace umt

View file

@ -0,0 +1,43 @@
#pragma once
#include <QObject>
#include <QString>
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:<random port>, then launches
// the system browser at <proxyUrl>/login?app_redirect=http://127.0.0.1:<port>/.
// 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

View file

@ -0,0 +1,75 @@
#include "providers/RawgProvider.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrlQuery>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
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<SearchResult> 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

View file

@ -0,0 +1,30 @@
#pragma once
#include "providers/MetadataProvider.h"
#include <QString>
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

View file

@ -0,0 +1,133 @@
#include "providers/TmdbProvider.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrlQuery>
#include <QJsonObject>
#include <QJsonArray>
namespace umt {
TmdbProvider::TmdbProvider(QObject *parent)
: MetadataProvider(parent)
, m_nam(new QNetworkAccessManager(this))
{
}
void TmdbProvider::getJson(const QString &path,
const QList<QPair<QString,QString>> &params,
std::function<void(const QJsonDocument &)> 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<SearchResult> 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

View file

@ -0,0 +1,56 @@
#pragma once
#include "providers/MetadataProvider.h"
#include <QList>
#include <QPair>
#include <QJsonDocument>
#include <functional>
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<QPair<QString,QString>> &params,
std::function<void(const QJsonDocument &)> 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

404
src/ui/DetailDialog.cpp Normal file
View file

@ -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 <QHBoxLayout>
#include <QVBoxLayout>
#include <QLabel>
#include <QTreeWidget>
#include <QHeaderView>
#include <QComboBox>
#include <QPlainTextEdit>
#include <QProgressBar>
#include <QPushButton>
#include <QScrollArea>
#include <QSpacerItem>
#include <QSizePolicy>
#include <QPainter>
#include <QPainterPath>
#include <QCloseEvent>
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

78
src/ui/DetailDialog.h Normal file
View file

@ -0,0 +1,78 @@
#pragma once
#include "core/MediaItem.h"
#include <QDialog>
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

494
src/ui/EditDialog.cpp Normal file
View file

@ -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 <QFormLayout>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
#include <QLineEdit>
#include <QCompleter>
#include <QSpinBox>
#include <QComboBox>
#include <QPlainTextEdit>
#include <QCheckBox>
#include <QLabel>
#include <QPushButton>
#include <QTableWidget>
#include <QHeaderView>
#include <QTabWidget>
#include <QDialogButtonBox>
#include <QScrollArea>
#include <QFileDialog>
#include <QMessageBox>
#include <QPainter>
#include <QPainterPath>
#include <QFileInfo>
#include <QFile>
#include <QUuid>
#include <QDateTime>
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<Segment> old = m_item.segments;
QVector<Segment> 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<QSpinBox *>(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

83
src/ui/EditDialog.h Normal file
View file

@ -0,0 +1,83 @@
#pragma once
#include "core/MediaItem.h"
#include "providers/MetadataProvider.h"
#include <QDialog>
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

163
src/ui/FilterPanel.cpp Normal file
View file

@ -0,0 +1,163 @@
#include "ui/FilterPanel.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFormLayout>
#include <QLabel>
#include <QComboBox>
#include <QListWidget>
#include <QSpinBox>
#include <QCheckBox>
#include <QPushButton>
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

47
src/ui/FilterPanel.h Normal file
View file

@ -0,0 +1,47 @@
#pragma once
#include "core/Database.h"
#include <QWidget>
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

109
src/ui/FlowLayout.cpp Normal file
View file

@ -0,0 +1,109 @@
#include "ui/FlowLayout.h"
#include <QWidget>
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<QWidget *>(parent);
return pw->style()->pixelMetric(pm, nullptr, pw);
}
return static_cast<QLayout *>(parent)->spacing();
}
} // namespace umt

40
src/ui/FlowLayout.h Normal file
View file

@ -0,0 +1,40 @@
#pragma once
#include <QLayout>
#include <QList>
#include <QStyle>
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<QLayoutItem *> m_items;
int m_hSpace;
int m_vSpace;
};
} // namespace umt

379
src/ui/MainWindow.cpp Normal file
View file

@ -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 <QWidget>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QLineEdit>
#include <QComboBox>
#include <QPushButton>
#include <QScrollArea>
#include <QButtonGroup>
#include <QLabel>
#include <QTimer>
#include <QMessageBox>
#include <QMenu>
#include <QToolButton>
#include <QStackedWidget>
#include <QDialog>
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<MediaItem> 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

82
src/ui/MainWindow.h Normal file
View file

@ -0,0 +1,82 @@
#pragma once
#include "core/Enums.h"
#include "core/Database.h"
#include <QMainWindow>
#include <optional>
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<MediaType> 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

199
src/ui/MediaCard.cpp Normal file
View file

@ -0,0 +1,199 @@
#include "ui/MediaCard.h"
#include "providers/ImageCache.h"
#include <QVBoxLayout>
#include <QLabel>
#include <QProgressBar>
#include <QMouseEvent>
#include <QContextMenuEvent>
#include <QMenu>
#include <QPainter>
#include <QPainterPath>
#include <QGraphicsDropShadowEffect>
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

58
src/ui/MediaCard.h Normal file
View file

@ -0,0 +1,58 @@
#pragma once
#include "core/MediaItem.h"
#include <QFrame>
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

View file

@ -0,0 +1,154 @@
#include "ui/OnlineSearchDialog.h"
#include "providers/ProviderManager.h"
#include "providers/ImageCache.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QListWidget>
#include <QLabel>
#include <QPushButton>
#include <QDialogButtonBox>
#include <QIcon>
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<SearchResult> &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

View file

@ -0,0 +1,55 @@
#pragma once
#include "providers/MetadataProvider.h"
#include <QDialog>
#include <QVector>
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<SearchResult> &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<SearchResult> m_results;
SearchResult m_selected;
bool m_waitingDetails = false;
};
} // namespace umt

253
src/ui/SettingsDialog.cpp Normal file
View file

@ -0,0 +1,253 @@
#include "ui/SettingsDialog.h"
#include "core/Settings.h"
#include "providers/ProxyLogin.h"
#include <QFormLayout>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QLineEdit>
#include <QComboBox>
#include <QCheckBox>
#include <QSlider>
#include <QPushButton>
#include <QLabel>
#include <QDialogButtonBox>
#include <QColorDialog>
#include <QMessageBox>
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(
"<b>So bekommst du einen kostenlosen TMDB API-Key:</b>"
"<ol>"
"<li>Auf <a href=\"https://www.themoviedb.org/signup\">themoviedb.org</a> "
"ein kostenloses Konto erstellen.</li>"
"<li>Oben rechts auf dein Profil &rarr; <i>Einstellungen</i> &rarr; "
"<i>API</i> gehen (<a href=\"https://www.themoviedb.org/settings/api\">"
"themoviedb.org/settings/api</a>).</li>"
"<li>Einen <i>API-Key (v3 auth)</i> beantragen. Als Verwendungszweck reicht "
"„privat&ldquo; bzw. „Hobby&ldquo;.</li>"
"<li>Den angezeigten <i>API Key (v3 auth)</i> kopieren und hier einfügen.</li>"
"</ol>"
"Alternativ kannst du oben auf „Proxy-Server (OIDC)&ldquo; 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(
"<b>So meldest du dich am Proxy-Server an:</b>"
"<ol>"
"<li>Oben „Filme/Serien über&ldquo; auf <i>Proxy-Server (OIDC)</i> "
"stellen.</li>"
"<li>Die <i>Proxy-URL</i> deines Servers eintragen "
"(z. B. https://tmdb.example.com).</li>"
"<li>Auf <i>Am Proxy anmelden…</i> klicken dein Browser öffnet die "
"Anmeldung (Authentik). Nach erfolgreichem Login wird das Token "
"<b>automatisch</b> hier eingetragen.</li>"
"</ol>"
"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(
"<b>So bekommst du einen kostenlosen RAWG API-Key (für Spiele):</b>"
"<ol>"
"<li>Auf <a href=\"https://rawg.io/signup\">rawg.io</a> ein kostenloses "
"Konto erstellen.</li>"
"<li>Die Seite <a href=\"https://rawg.io/apidocs\">rawg.io/apidocs</a> "
"öffnen und auf <i>Get API Key</i> klicken.</li>"
"<li>Das Formular ausfüllen (für privaten Gebrauch genügen einfache "
"Angaben).</li>"
"<li>Den angezeigten Key kopieren und hier einfügen.</li>"
"</ol>"
"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

47
src/ui/SettingsDialog.h Normal file
View file

@ -0,0 +1,47 @@
#pragma once
#include <QDialog>
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

120
src/ui/StarRating.cpp Normal file
View file

@ -0,0 +1,120 @@
#include "ui/StarRating.h"
#include <QPainter>
#include <QPainterPath>
#include <QMouseEvent>
#include <QPalette>
#include <QtMath>
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

38
src/ui/StarRating.h Normal file
View file

@ -0,0 +1,38 @@
#pragma once
#include <QWidget>
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

304
src/ui/ThemeManager.cpp Normal file
View file

@ -0,0 +1,304 @@
#include "ui/ThemeManager.h"
#include "core/Settings.h"
#include <QApplication>
#include <QPalette>
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

33
src/ui/ThemeManager.h Normal file
View file

@ -0,0 +1,33 @@
#pragma once
#include <QObject>
#include <QColor>
#include <QString>
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