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:
commit
3f07c78316
63 changed files with 6547 additions and 0 deletions
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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
104
CMakeLists.txt
Normal 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
83
README.md
Normal 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
BIN
resources/icons/appicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
BIN
resources/icons/appicon.png
Normal file
BIN
resources/icons/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
16
resources/icons/appicon.svg
Normal file
16
resources/icons/appicon.svg
Normal 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 |
1
resources/icons/check.svg
Normal file
1
resources/icons/check.svg
Normal 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 |
6
resources/icons/placeholder.svg
Normal file
6
resources/icons/placeholder.svg
Normal 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 |
8
resources/linux/ultimate-media-tracker.desktop
Normal file
8
resources/linux/ultimate-media-tracker.desktop
Normal 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
7
resources/resources.qrc
Normal 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
1
resources/win/app.rc
Normal file
|
|
@ -0,0 +1 @@
|
|||
IDI_ICON1 ICON "../icons/appicon.ico"
|
||||
33
server/.env.example
Normal file
33
server/.env.example
Normal 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
14
server/Dockerfile
Normal 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
69
server/README.md
Normal 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
89
server/config.go
Normal 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
13
server/go.mod
Normal 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
18
server/go.sum
Normal 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
318
server/main.go
Normal 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 – TMDB-Proxy</h1>
|
||||
<p class="muted">Melde dich mit deinem Konto an, um ein Zugriffstoken für die
|
||||
Desktop-App zu erhalten. Damit nutzt du TMDB, ohne einen eigenen API-Key zu brauchen.</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 → Filme/Serien über → 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 – dann hier erneut anmelden.</p>
|
||||
</body></html>`)
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// handleProxy forwards an authenticated /3/* request to TMDB with the shared key.
|
||||
func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
|
||||
if _, err := verifyToken(s.cfg.SessionKey, bearer); err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Build upstream URL: /3/<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
68
server/token.go
Normal 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
677
src/core/Database.cpp
Normal 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
96
src/core/Database.h
Normal 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
148
src/core/Enums.h
Normal 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
8
src/core/MediaItem.cpp
Normal 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
234
src/core/MediaItem.h
Normal 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
107
src/core/Settings.cpp
Normal 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
71
src/core/Settings.h
Normal 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
73
src/main.cpp
Normal 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();
|
||||
}
|
||||
130
src/providers/AniListProvider.cpp
Normal file
130
src/providers/AniListProvider.cpp
Normal 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
|
||||
25
src/providers/AniListProvider.h
Normal file
25
src/providers/AniListProvider.h
Normal 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
|
||||
125
src/providers/ImageCache.cpp
Normal file
125
src/providers/ImageCache.cpp
Normal 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
|
||||
50
src/providers/ImageCache.h
Normal file
50
src/providers/ImageCache.h
Normal 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
|
||||
6
src/providers/MetadataProvider.cpp
Normal file
6
src/providers/MetadataProvider.cpp
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "providers/MetadataProvider.h"
|
||||
|
||||
// Interface translation unit. Concrete providers live in their own files.
|
||||
|
||||
namespace umt {
|
||||
} // namespace umt
|
||||
62
src/providers/MetadataProvider.h
Normal file
62
src/providers/MetadataProvider.h
Normal 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
|
||||
75
src/providers/OpenLibraryProvider.cpp
Normal file
75
src/providers/OpenLibraryProvider.cpp
Normal 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
|
||||
24
src/providers/OpenLibraryProvider.h
Normal file
24
src/providers/OpenLibraryProvider.h
Normal 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
|
||||
74
src/providers/ProviderManager.cpp
Normal file
74
src/providers/ProviderManager.cpp
Normal 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
|
||||
45
src/providers/ProviderManager.h
Normal file
45
src/providers/ProviderManager.h
Normal 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
|
||||
155
src/providers/ProxyLogin.cpp
Normal file
155
src/providers/ProxyLogin.cpp
Normal 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
|
||||
43
src/providers/ProxyLogin.h
Normal file
43
src/providers/ProxyLogin.h
Normal 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
|
||||
75
src/providers/RawgProvider.cpp
Normal file
75
src/providers/RawgProvider.cpp
Normal 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
|
||||
30
src/providers/RawgProvider.h
Normal file
30
src/providers/RawgProvider.h
Normal 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
|
||||
133
src/providers/TmdbProvider.cpp
Normal file
133
src/providers/TmdbProvider.cpp
Normal 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>> ¶ms,
|
||||
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
|
||||
56
src/providers/TmdbProvider.h
Normal file
56
src/providers/TmdbProvider.h
Normal 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>> ¶ms,
|
||||
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
404
src/ui/DetailDialog.cpp
Normal 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
78
src/ui/DetailDialog.h
Normal 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
494
src/ui/EditDialog.cpp
Normal 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
83
src/ui/EditDialog.h
Normal 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
163
src/ui/FilterPanel.cpp
Normal 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
47
src/ui/FilterPanel.h
Normal 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
109
src/ui/FlowLayout.cpp
Normal 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
40
src/ui/FlowLayout.h
Normal 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
379
src/ui/MainWindow.cpp
Normal 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
82
src/ui/MainWindow.h
Normal 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
199
src/ui/MediaCard.cpp
Normal 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
58
src/ui/MediaCard.h
Normal 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
|
||||
154
src/ui/OnlineSearchDialog.cpp
Normal file
154
src/ui/OnlineSearchDialog.cpp
Normal 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
|
||||
55
src/ui/OnlineSearchDialog.h
Normal file
55
src/ui/OnlineSearchDialog.h
Normal 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
253
src/ui/SettingsDialog.cpp
Normal 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 → <i>Einstellungen</i> → "
|
||||
"<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“ bzw. „Hobby“.</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)“ 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“ 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
47
src/ui/SettingsDialog.h
Normal 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
120
src/ui/StarRating.cpp
Normal 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
38
src/ui/StarRating.h
Normal 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
304
src/ui/ThemeManager.cpp
Normal 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
33
src/ui/ThemeManager.h
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue