diff --git a/lib/app.dart b/lib/app.dart index b7b1618..7b76d8c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'features/movies/presentation/movie_list_screen.dart'; import 'features/series/presentation/series_list_screen.dart'; import 'features/import/import_screen.dart'; +import 'features/games/presentation/games_screen.dart'; class MultimediaApp extends StatelessWidget { const MultimediaApp({super.key}); @@ -31,7 +32,7 @@ class _HomeTabsState extends State<_HomeTabs> @override void initState() { super.initState(); - _controller = TabController(length: 3, vsync: this); + _controller = TabController(length: 4, vsync: this); } @override @@ -50,6 +51,7 @@ class _HomeTabsState extends State<_HomeTabs> tabs: const [ Tab(text: 'Filme'), Tab(text: 'Serien'), + Tab(text: 'Spiele'), Tab(text: 'Import'), ], ), @@ -59,6 +61,7 @@ class _HomeTabsState extends State<_HomeTabs> children: [ const MovieListScreen(), SeriesListScreen(), + const GamesScreen(), const ImportScreen(), ], ), diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart index c845274..46419ee 100644 --- a/lib/core/api/backend_api.dart +++ b/lib/core/api/backend_api.dart @@ -94,7 +94,7 @@ class BackendApi { if (map == null) { throw Exception('Backend: Unexpected response format'); } - if (map['ok'] != true) { + if (map['ok'] != true && map['success'] != true) { // ignore: avoid_print print('Backend logical error. URL: ${_dio.options.baseUrl}, payload: $payload, data: $map'); throw Exception('Backend responded with error: ${map['error']}'); @@ -115,6 +115,20 @@ class BackendApi { return (map['items'] as List).cast>(); } + Future>> getGames( + {String? status, String? q, int offset = 0, int limit = 50, String? lang}) async { + final map = await _post({ + 'action': 'get_list', + 'type': 'game', + if (status != null) 'status': status, + if (q != null && q.isNotEmpty) 'q': q, + if (lang != null && lang.isNotEmpty) 'lang': lang, + 'offset': offset, + 'limit': limit, + }); + return (map['items'] as List).cast>(); + } + Future setStatus({ required String type, // 'movie' | 'episode' required int refId, @@ -264,4 +278,32 @@ class BackendApi { Future deleteShow(int showId) async { await _post({'action': 'delete_show', 'show_id': showId}); } + + Future> setGameStatus({ + required int igdbId, + required String name, + String? originalName, + required int status, // 0=Init,1=Progress,2=Done + String? note, + String? lang, + String? title, + String? summary, + String? storyline, + String? coverUrl, + }) async { + final map = await _post({ + 'action': 'set_game_status', + 'igdb_id': igdbId, + 'name': name, + if (originalName != null) 'original_name': originalName, + 'status': status, + if (note != null) 'note': note, + if (lang != null && lang.isNotEmpty) 'lang': lang, + if (title != null) 'title': title, + if (summary != null) 'summary': summary, + if (storyline != null) 'storyline': storyline, + if (coverUrl != null) 'cover_url': coverUrl, + }); + return (map['game'] as Map).cast(); + } } diff --git a/lib/core/api/igdb_api.dart b/lib/core/api/igdb_api.dart new file mode 100644 index 0000000..00a3f05 --- /dev/null +++ b/lib/core/api/igdb_api.dart @@ -0,0 +1,37 @@ +import 'package:dio/dio.dart'; + +import '../../core/config.dart'; + +class IgdbApi { + final Dio _dio; + + IgdbApi() + : _dio = Dio( + BaseOptions( + baseUrl: AppConfig.backendBaseUrl, + headers: const {'Accept': 'application/json'}, + ), + ); + + Future>> searchGames(String query, + {int limit = 20, String lang = 'de'}) async { + final res = await _dio.get( + '', + queryParameters: { + 'action': 'search', + 'query': query, + 'limit': limit, + 'lang': lang, + 'check_external': 1, + }, + ); + if (res.statusCode != 200) { + throw Exception('IGDB search HTTP ${res.statusCode}'); + } + final data = res.data; + if (data is Map && data['results'] is List) { + return (data['results'] as List).cast>(); + } + throw Exception('IGDB search: unerwartetes Antwortformat'); + } +} diff --git a/lib/features/games/data/game_model.dart b/lib/features/games/data/game_model.dart new file mode 100644 index 0000000..20652bd --- /dev/null +++ b/lib/features/games/data/game_model.dart @@ -0,0 +1,41 @@ +import '../../../core/status.dart'; + +class Game { + final int id; // DB id + final int igdbId; + final String name; + final String? localizedTitle; + final String? originalName; + final ItemStatus status; + final String? note; + final String? summary; + final String? coverUrl; + + Game({ + required this.id, + required this.igdbId, + required this.name, + this.localizedTitle, + this.originalName, + this.status = ItemStatus.Init, + this.note, + this.summary, + this.coverUrl, + }); + + factory Game.fromJson(Map j) { + return Game( + id: (j['id'] as num).toInt(), + igdbId: (j['igdb_id'] as num).toInt(), + name: (j['name'] ?? '') as String, + localizedTitle: j['loc_title'] as String?, + originalName: j['original_name'] as String?, + status: j['status'] != null ? statusFromString(j['status'] as String) : ItemStatus.Init, + note: j['note'] as String?, + summary: j['loc_summary'] as String?, + coverUrl: j['cover_url'] as String?, + ); + } + + String get displayName => (localizedTitle != null && localizedTitle!.isNotEmpty) ? localizedTitle! : name; +} diff --git a/lib/features/games/data/game_repository.dart b/lib/features/games/data/game_repository.dart new file mode 100644 index 0000000..14c9317 --- /dev/null +++ b/lib/features/games/data/game_repository.dart @@ -0,0 +1,42 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/status.dart'; +import '../../shared/providers.dart'; +import 'game_model.dart'; + +final gameFilterProvider = StateProvider((_) => null); + +final gamesProvider = FutureProvider.autoDispose>((ref) async { + final backend = ref.watch(backendApiProvider); + final st = ref.watch(gameFilterProvider); + const pageSize = 500; + var offset = 0; + final all = >[]; + while (true) { + final page = await backend.getGames(status: st?.name, offset: offset, limit: pageSize, lang: 'de'); + all.addAll(page); + if (page.length < pageSize) break; + offset += pageSize; + } + return all.map(Game.fromJson).toList(); +}); + +final gamesStreamProvider = StreamProvider.autoDispose>((ref) async* { + final backend = ref.watch(backendApiProvider); + final st = ref.watch(gameFilterProvider); + const pageSize = 300; + var offset = 0; + var agg = []; + while (true) { + final page = await backend.getGames(status: st?.name, offset: offset, limit: pageSize, lang: 'de'); + final mapped = page.map(Game.fromJson).toList(); + if (mapped.isEmpty) { + if (agg.isEmpty) yield const []; + break; + } + agg = [...agg, ...mapped]; + yield agg; + if (mapped.length < pageSize) break; + offset += pageSize; + } +}); diff --git a/lib/features/games/presentation/games_add_screen.dart b/lib/features/games/presentation/games_add_screen.dart new file mode 100644 index 0000000..46b6221 --- /dev/null +++ b/lib/features/games/presentation/games_add_screen.dart @@ -0,0 +1,194 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../shared/providers.dart'; +import '../data/game_repository.dart'; + +class GamesAddScreen extends ConsumerStatefulWidget { + const GamesAddScreen({super.key}); + + @override + ConsumerState createState() => _GamesAddScreenState(); +} + +class _GamesAddScreenState extends ConsumerState { + final _queryCtrl = TextEditingController(); + bool _loading = false; + List> _results = const []; + final Set _selected = {}; + + Future _search() async { + final q = _queryCtrl.text.trim(); + if (q.isEmpty) return; + setState(() { + _loading = true; + _results = const []; + _selected.clear(); + }); + try { + final igdb = ref.read(igdbApiProvider); + final items = await igdb.searchGames(q, limit: 30, lang: 'de'); + setState(() => _results = items); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Suche fehlgeschlagen: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _addSelected() async { + if (_selected.isEmpty) return; + setState(() => _loading = true); + final messenger = ScaffoldMessenger.of(context); + try { + final backend = ref.read(backendApiProvider); + int ok = 0; + for (final id in _selected) { + final game = _results.firstWhere((g) => (g['id'] as num).toInt() == id); + final name = (game['name'] ?? '') as String; + final original = (game['original_name'] ?? '') as String; + final summary = (game['summary'] ?? '') as String; + final title = (game['name'] ?? '') as String; + final cover = game['cover_url'] as String?; + if (name.isEmpty) continue; + await backend.setGameStatus( + igdbId: id, + name: name, + originalName: original.isNotEmpty ? original : null, + status: 0, + note: null, + lang: 'de', + title: title.isNotEmpty ? title : null, + summary: summary.isNotEmpty ? summary : null, + coverUrl: cover, + ); + ok++; + } + // ignore: unused_result + ref.invalidate(gamesStreamProvider); + // ignore: unused_result + ref.invalidate(gamesProvider); + messenger.showSnackBar(SnackBar(content: Text('$ok Spiel(e) hinzugefügt'))); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Hinzufügen fehlgeschlagen: $e'))); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Neue Spiele hinzufügen (IGDB)'), + actions: [ + if (_selected.isNotEmpty) + TextButton.icon( + onPressed: _loading ? null : _addSelected, + icon: const Icon(Icons.add), + label: Text('Hinzufügen (${_selected.length})'), + ) + ], + ), + body: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _queryCtrl, + decoration: const InputDecoration( + hintText: 'Spiele suchen …', + prefixIcon: Icon(Icons.search), + ), + onSubmitted: (_) => _search(), + ), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _loading ? null : _search, + icon: const Icon(Icons.search), + label: const Text('Suchen'), + ), + ], + ), + const SizedBox(height: 12), + if (_loading) const LinearProgressIndicator(), + const SizedBox(height: 8), + Expanded( + child: _results.isEmpty + ? const Center(child: Text('Keine Ergebnisse')) + : ListView.separated( + itemCount: _results.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + final g = _results[i]; + final id = (g['id'] as num).toInt(); + final sel = _selected.contains(id); + final title = (g['name'] ?? '') as String; + final summary = (g['summary'] ?? '') as String; + final cover = g['cover_url'] as String?; + return ListTile( + onTap: () { + setState(() { + if (sel) { + _selected.remove(id); + } else { + _selected.add(id); + } + }); + }, + leading: cover != null + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: cover, + width: 60, + height: 80, + fit: BoxFit.cover, + ), + ) + : const SizedBox(width: 60, height: 80), + title: Text(title), + subtitle: summary.isNotEmpty + ? Text( + summary, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : null, + isThreeLine: summary.isNotEmpty, + trailing: Checkbox( + value: sel, + onChanged: (v) { + setState(() { + if (v == true) { + _selected.add(id); + } else { + _selected.remove(id); + } + }); + }, + ), + ); + }, + ), + ), + ], + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _selected.isEmpty || _loading ? null : _addSelected, + icon: const Icon(Icons.add), + label: Text('Hinzufügen (${_selected.length})'), + ), + ); + } +} diff --git a/lib/features/games/presentation/games_screen.dart b/lib/features/games/presentation/games_screen.dart new file mode 100644 index 0000000..0b224ca --- /dev/null +++ b/lib/features/games/presentation/games_screen.dart @@ -0,0 +1,110 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/status.dart'; +import '../data/game_repository.dart'; +import 'games_add_screen.dart'; +import '../../movies/presentation/widgets/status_chip.dart'; + +class GamesScreen extends ConsumerWidget { + const GamesScreen({super.key}); + + Color _statusBg(ItemStatus status, BuildContext context) { + switch (status) { + case ItemStatus.Progress: + return Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.3); + case ItemStatus.Done: + return Theme.of(context).colorScheme.primaryContainer.withOpacity(0.35); + case ItemStatus.Init: + default: + return Colors.transparent; + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(gameFilterProvider); + final gamesAsync = ref.watch(gamesStreamProvider); + + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StatusChip( + selected: filter, + onChanged: (f) => ref.read(gameFilterProvider.notifier).state = f, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('Neue Spiele hinzufügen'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const GamesAddScreen()), + ); + }, + ), + ), + const SizedBox(height: 12), + Expanded( + child: gamesAsync.when( + data: (items) { + final sorted = [...items] + ..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); + if (sorted.isEmpty) { + return const Center(child: Text('Keine Spiele in der Sammlung')); + } + return RefreshIndicator( + onRefresh: () async { + // ignore: unused_result + ref.invalidate(gamesStreamProvider); + // ignore: unused_result + ref.invalidate(gamesProvider); + }, + child: ListView.separated( + itemCount: sorted.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + final g = sorted[i]; + return ListTile( + tileColor: _statusBg(g.status, context), + leading: g.coverUrl != null && g.coverUrl!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: g.coverUrl!, + width: 50, + height: 70, + fit: BoxFit.cover, + ), + ) + : CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + child: const Icon(Icons.sports_esports), + ), + title: Text(g.displayName), + subtitle: Text( + (g.summary?.isNotEmpty ?? false) + ? g.summary! + : (g.note ?? ''), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + trailing: Text(g.status.name), + ); + }, + ), + ); + }, + error: (e, st) => Center(child: Text('Fehler: $e')), + loading: () => const Center(child: CircularProgressIndicator()), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/shared/providers.dart b/lib/features/shared/providers.dart index 82cef69..aa090dd 100644 --- a/lib/features/shared/providers.dart +++ b/lib/features/shared/providers.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/backend_api.dart'; import '../../core/api/tmdb_api.dart'; +import '../../core/api/igdb_api.dart'; final backendApiProvider = Provider((ref) => BackendApi()); -final tmdbApiProvider = Provider((ref) => TmdbApi()); \ No newline at end of file +final tmdbApiProvider = Provider((ref) => TmdbApi()); +final igdbApiProvider = Provider((ref) => IgdbApi()); diff --git a/lib/php/igdb.php b/lib/php/igdb.php index c07375d..354fe40 100644 --- a/lib/php/igdb.php +++ b/lib/php/igdb.php @@ -165,7 +165,7 @@ function saveLocalization( } // ===================================================== -// GAMES IN MULTIMEDIAFLUTTER (Tabelle games) +// GAMES IN MULTIMEDIAFLUTTER (Tabelle game) // ===================================================== function saveGameStatus( @@ -178,7 +178,7 @@ function saveGameStatus( $pdo = getDb(); $sql = ' - INSERT INTO games (igdb_id, name, original_name, status, note) + INSERT INTO game (igdb_id, name, original_name, status, note) VALUES (:igdb_id, :name, :original_name, :status, :note) ON DUPLICATE KEY UPDATE name = VALUES(name), @@ -197,7 +197,7 @@ function saveGameStatus( ':note' => $note, ]); - $stmt = $pdo->prepare('SELECT * FROM games WHERE igdb_id = :id LIMIT 1'); + $stmt = $pdo->prepare('SELECT * FROM game WHERE igdb_id = :id LIMIT 1'); $stmt->execute([':id' => $igdbId]); return $stmt->fetch() ?: []; } @@ -205,7 +205,7 @@ function saveGameStatus( function getGameByIgdbId(int $igdbId): ?array { $pdo = getDb(); - $stmt = $pdo->prepare('SELECT * FROM games WHERE igdb_id = :id LIMIT 1'); + $stmt = $pdo->prepare('SELECT * FROM game WHERE igdb_id = :id LIMIT 1'); $stmt->execute([':id' => $igdbId]); return $stmt->fetch() ?: null; } @@ -493,12 +493,13 @@ switch ($action) { $offset = ($page - 1) * $limit; + $safeQuery = str_replace('"', '\\"', $query); $body = sprintf( 'fields id,name,summary,first_release_date,cover.image_id; ' . 'limit %d; offset %d; search "%s";', $limit, $offset, - addslashes($query) + $safeQuery ); $results = igdbRequest('/games', $body); @@ -509,7 +510,7 @@ switch ($action) { ? igdbImageUrl($g['cover']['image_id'], 't_cover_big') : null; - // eigener Status aus games + // eigener Status aus game-Tabelle $localGame = getGameByIgdbId($g['id']); $g['my_status'] = $localGame['status'] ?? null; diff --git a/lib/php/multimedia.php b/lib/php/multimedia.php index f2a6f71..3da17e6 100644 --- a/lib/php/multimedia.php +++ b/lib/php/multimedia.php @@ -79,6 +79,101 @@ function getDb(): PDO { return $pdo; } +// Shared game helpers (usable for both GET and POST actions) +function saveGameStatus(int $igdbId, string $name, ?string $originalName, int $status, ?string $note = null, ?string $coverUrl = null): array { + $pdo = getDb(); + static $hasCover = null; + if ($hasCover === null) { + try { + $pdo->query("SHOW COLUMNS FROM game LIKE 'cover_url'"); + $hasCover = true; + } catch (Throwable $e) { + $hasCover = false; + } + } + + $sql = $hasCover + ? ' + INSERT INTO game (igdb_id, name, original_name, status, note, cover_url) + VALUES (:igdb_id, :name, :original_name, :status, :note, :cover_url) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + original_name = VALUES(original_name), + status = VALUES(status), + note = VALUES(note), + cover_url = VALUES(cover_url), + updated_at = CURRENT_TIMESTAMP + ' + : ' + INSERT INTO game (igdb_id, name, original_name, status, note) + VALUES (:igdb_id, :name, :original_name, :status, :note) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + original_name = VALUES(original_name), + status = VALUES(status), + note = VALUES(note), + updated_at = CURRENT_TIMESTAMP + '; + + $stmt = $pdo->prepare($sql); + $params = [ + ':igdb_id' => $igdbId, + ':name' => $name, + ':original_name' => $originalName, + ':status' => $status, + ':note' => $note, + ]; + if ($hasCover) { + $params[':cover_url'] = $coverUrl; + } + $stmt->execute($params); + $stmt = $pdo->prepare('SELECT * FROM game WHERE igdb_id = :id LIMIT 1'); + $stmt->execute([':id' => $igdbId]); + return $stmt->fetch() ?: []; +} + +function getGameByIgdbId(int $igdbId): ?array { + $pdo = getDb(); + $stmt = $pdo->prepare('SELECT * FROM game WHERE igdb_id = :id LIMIT 1'); + $stmt->execute([':id' => $igdbId]); + return $stmt->fetch() ?: null; +} + +function getLocalization(int $igdbId, string $lang = 'de'): ?array { + $pdo = getDb(); + $stmt = $pdo->prepare('SELECT * FROM igdb_localizations WHERE igdb_id = :id AND lang = :lang LIMIT 1'); + $stmt->execute([ + ':id' => $igdbId, + ':lang' => $lang, + ]); + $row = $stmt->fetch(); + return $row ?: null; +} + +function saveLocalization(int $igdbId, string $lang, ?string $title, ?string $summary, ?string $storyline, ?int $userId): array { + $pdo = getDb(); + $sql = ' + INSERT INTO igdb_localizations (igdb_id, lang, title, summary, storyline, user_id) + VALUES (:id, :lang, :title, :summary, :storyline, :user_id) + ON DUPLICATE KEY UPDATE + title = VALUES(title), + summary = VALUES(summary), + storyline = VALUES(storyline), + user_id = VALUES(user_id), + updated_at = CURRENT_TIMESTAMP + '; + $stmt = $pdo->prepare($sql); + $stmt->execute([ + ':id' => $igdbId, + ':lang' => $lang, + ':title' => $title, + ':summary' => $summary, + ':storyline' => $storyline, + ':user_id' => $userId, + ]); + return getLocalization($igdbId, $lang); +} + // ===================================================== // IGDB + Wikipedia (GET-basiert) // ===================================================== @@ -176,70 +271,6 @@ if (isset($_GET['action'])) { return $data; } - function getLocalization(int $igdbId, string $lang = 'de'): ?array { - $pdo = getDb(); - $stmt = $pdo->prepare('SELECT * FROM igdb_localizations WHERE igdb_id = :id AND lang = :lang LIMIT 1'); - $stmt->execute([':id' => $igdbId, ':lang' => $lang]); - $row = $stmt->fetch(); - return $row ?: null; - } - - function saveLocalization(int $igdbId, string $lang, ?string $title, ?string $summary, ?string $storyline, ?int $userId): array { - $pdo = getDb(); - $sql = ' - INSERT INTO igdb_localizations (igdb_id, lang, title, summary, storyline, user_id) - VALUES (:id, :lang, :title, :summary, :storyline, :user_id) - ON DUPLICATE KEY UPDATE - title = VALUES(title), - summary = VALUES(summary), - storyline = VALUES(storyline), - user_id = VALUES(user_id), - updated_at = CURRENT_TIMESTAMP - '; - $stmt = $pdo->prepare($sql); - $stmt->execute([ - ':id' => $igdbId, - ':lang' => $lang, - ':title' => $title, - ':summary' => $summary, - ':storyline' => $storyline, - ':user_id' => $userId, - ]); - return getLocalization($igdbId, $lang); - } - - function saveGameStatus(int $igdbId, string $name, ?string $originalName, int $status, ?string $note = null): array { - $pdo = getDb(); - $sql = ' - INSERT INTO games (igdb_id, name, original_name, status, note) - VALUES (:igdb_id, :name, :original_name, :status, :note) - ON DUPLICATE KEY UPDATE - name = VALUES(name), - original_name = VALUES(original_name), - status = VALUES(status), - note = VALUES(note), - updated_at = CURRENT_TIMESTAMP - '; - $stmt = $pdo->prepare($sql); - $stmt->execute([ - ':igdb_id' => $igdbId, - ':name' => $name, - ':original_name' => $originalName, - ':status' => $status, - ':note' => $note, - ]); - $stmt = $pdo->prepare('SELECT * FROM games WHERE igdb_id = :id LIMIT 1'); - $stmt->execute([':id' => $igdbId]); - return $stmt->fetch() ?: []; - } - - function getGameByIgdbId(int $igdbId): ?array { - $pdo = getDb(); - $stmt = $pdo->prepare('SELECT * FROM games WHERE igdb_id = :id LIMIT 1'); - $stmt->execute([':id' => $igdbId]); - return $stmt->fetch() ?: null; - } - function wikiApiSearchWithExtract(string $searchQuery): ?array { $params = [ 'action' => 'query', @@ -340,12 +371,13 @@ if (isset($_GET['action'])) { $offset = ($page - 1) * $limit; + $safeQuery = str_replace('"', '\\"', $query); $body = sprintf( 'fields id,name,summary,first_release_date,cover.image_id; ' . 'limit %d; offset %d; search "%s";', $limit, $offset, - addslashes($query) + $safeQuery ); $results = igdbRequest('/games', $body); @@ -356,7 +388,7 @@ if (isset($_GET['action'])) { ? igdbImageUrl($g['cover']['image_id'], 't_cover_big') : null; - // eigener Status aus games + // eigener Status aus game-Tabelle $localGame = getGameByIgdbId($g['id']); $g['my_status'] = $localGame['status'] ?? null; @@ -530,10 +562,11 @@ if (isset($_GET['action'])) { $orig = isset($data['original_name']) ? trim($data['original_name']) : ''; $status = (int)($data['status'] ?? 0); $note = isset($data['note']) ? trim($data['note']) : ''; + $coverUrl = isset($data['cover_url']) ? trim($data['cover_url']) : null; if ($igdbId <= 0) error_response('Invalid \"igdb_id\"', 400); if ($name === '') error_response('Missing \"name\"', 400); if ($status < 0 || $status > 2) error_response('Invalid \"status\" (0, 1, 2 allowed)', 400); - $game = saveGameStatus($igdbId, $name, $orig !== '' ? $orig : null, $status, $note !== '' ? $note : null); + $game = saveGameStatus($igdbId, $name, $orig !== '' ? $orig : null, $status, $note !== '' ? $note : null, $coverUrl ?: null); respond(['success' => true, 'game' => $game]); break; @@ -891,6 +924,7 @@ try { case 'get_list': { $type=$in['type'] ?? 'movie'; $status=$in['status'] ?? null; + $lang=$in['lang'] ?? 'de'; $q=$in['q'] ?? ''; $limit=max(1,(int)($in['limit']??50)); $offset=max(0,(int)($in['offset']??0)); @@ -967,6 +1001,30 @@ try { $stmt=$pdo->prepare($sql); $i=1; foreach($params as $p){$stmt->bindValue($i++,$p,is_int($p)?PDO::PARAM_INT:PDO::PARAM_STR);} $stmt->execute(); resp(['ok'=>true,'items'=>$stmt->fetchAll()]); } + if ($type==='game') { + $statusVal = null; + if ($status) { + $s = strtolower($status); + if ($s==='init') $statusVal = 0; elseif ($s==='progress') $statusVal = 1; elseif ($s==='done') $statusVal = 2; + } + $sql = "SELECT g.*, + CASE g.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, + loc.title AS loc_title, + loc.summary AS loc_summary, + loc.storyline AS loc_storyline + FROM game g + LEFT JOIN igdb_localizations loc + ON loc.igdb_id = g.igdb_id AND loc.lang = ? + WHERE 1=1"; + $params = [$lang]; + if ($statusVal !== null) { $sql .= " AND g.status=?"; $params[] = $statusVal; } + if ($q) { $sql .= " AND g.name LIKE ?"; $params[] = '%'.$q.'%'; } + $sql .= " ORDER BY g.name ASC LIMIT ?,?"; $params[] = $offset; $params[] = $limit; + $stmt = $pdo->prepare($sql); + $i = 1; foreach ($params as $p) { $stmt->bindValue($i++, $p, is_int($p)?PDO::PARAM_INT:PDO::PARAM_STR); } + $stmt->execute(); + resp(['ok'=>true,'items'=>$stmt->fetchAll()]); + } fail('unsupported type'); } @@ -1208,6 +1266,34 @@ try { ]); } + case 'set_game_status': { + $igdbId = (int)($in['igdb_id'] ?? 0); + $name = isset($in['name']) ? trim($in['name']) : ''; + $orig = isset($in['original_name']) ? trim($in['original_name']) : ''; + $status = (int)($in['status'] ?? 0); + $note = isset($in['note']) ? trim($in['note']) : ''; + $lang = isset($in['lang']) && $in['lang'] !== '' ? $in['lang'] : 'de'; + $locTitle = isset($in['title']) ? trim($in['title']) : ''; + $locSummary = isset($in['summary']) ? trim($in['summary']) : ''; + $locStory = isset($in['storyline']) ? trim($in['storyline']) : ''; + $coverUrl = isset($in['cover_url']) ? trim($in['cover_url']) : null; + if ($igdbId <= 0) fail('Invalid "igdb_id"'); + if ($name === '') fail('Missing "name"'); + if ($status < 0 || $status > 2) fail('Invalid "status" (0,1,2)'); + $game = saveGameStatus($igdbId, $name, $orig !== '' ? $orig : null, $status, $note !== '' ? $note : null, $coverUrl ?: null); + if ($locTitle !== '' || $locSummary !== '' || $locStory !== '') { + saveLocalization($igdbId, $lang, $locTitle ?: null, $locSummary ?: null, $locStory ?: null, null); + } + resp(['ok' => true, 'game' => $game]); + } + + case 'get_game': { + $igdbId = (int)($in['igdb_id'] ?? 0); + if ($igdbId <= 0) fail('Invalid "igdb_id"'); + $game = getGameByIgdbId($igdbId); + resp(['ok' => true, 'found' => (bool)$game, 'game' => $game]); + } + default: fail('unknown action'); } } catch (Throwable $e) {