From dd21a3ed71a1eaa2c9db1f6a081beab9bfb891d5 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Thu, 30 Oct 2025 20:48:22 +0100 Subject: [PATCH] Film / Serie Update --- lib/core/api/backend_api.dart | 16 +- .../presentation/movie_detail_screen.dart | 29 +++ .../presentation/movie_list_screen.dart | 67 +++++- .../presentation/series_detail_screen.dart | 74 ++++++- .../presentation/series_list_screen.dart | 206 ++++++++++++++++++ lib/php/multimedia.php | 74 ++++++- 6 files changed, 456 insertions(+), 10 deletions(-) diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart index fa5c003..4c99f89 100644 --- a/lib/core/api/backend_api.dart +++ b/lib/core/api/backend_api.dart @@ -161,8 +161,9 @@ class BackendApi { return (map['items'] as List).cast>(); } - Future upsertShow(Map tmdbJson) async { - await _post({'action': 'upsert_show', 'tmdb': tmdbJson}); + Future upsertShow(Map tmdbJson) async { + final map = await _post({'action': 'upsert_show', 'tmdb': tmdbJson}); + return (map['id'] as num).toInt(); } Future upsertSeason(int showId, Map seasonJson) async { @@ -187,6 +188,17 @@ class BackendApi { return v == null ? null : (v as num).toInt(); } + Future>> listShows() async { + final map = await _post({'action': 'list_shows'}); + return (map['items'] as List).cast>(); + } + + Future getTmdbIdByShowId(int showId) async { + final map = await _post({'action': 'get_tmdb_by_show_id', 'show_id': showId}); + final v = map['tmdb_id']; + return v == null ? null : (v as num).toInt(); + } + Future setShowMeta({ required int showId, String? resolution, diff --git a/lib/features/movies/presentation/movie_detail_screen.dart b/lib/features/movies/presentation/movie_detail_screen.dart index 1eaf8df..a39a415 100644 --- a/lib/features/movies/presentation/movie_detail_screen.dart +++ b/lib/features/movies/presentation/movie_detail_screen.dart @@ -90,6 +90,35 @@ class _MovieDetailScreenState extends ConsumerState { backgroundColor: Colors.transparent, elevation: 0, title: Text(m.title), + actions: [ + IconButton( + tooltip: 'TMDB aktualisieren', + icon: const Icon(Icons.sync), + onPressed: () async { + try { + final tmdb = ref.read(tmdbApiProvider); + final json = await tmdb.getMovie(m.tmdbId); + await ref.read(backendApiProvider).upsertMovie(json); + // Update local TMDB data for immediate UI refresh + if (mounted) setState(() => _tmdb = json); + // Refresh movie list so the list reflects new metadata + // ignore: unused_result + ref.invalidate(moviesProvider); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('TMDB Daten aktualisiert')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')), + ); + } + } + }, + ) + ], ), floatingActionButton: _dirty ? FloatingActionButton.extended( diff --git a/lib/features/movies/presentation/movie_list_screen.dart b/lib/features/movies/presentation/movie_list_screen.dart index a46950a..bef8cfe 100644 --- a/lib/features/movies/presentation/movie_list_screen.dart +++ b/lib/features/movies/presentation/movie_list_screen.dart @@ -5,6 +5,7 @@ import '../../../core/status.dart'; import '../data/movie_repository.dart'; import 'widgets/status_chip.dart'; import 'movie_detail_screen.dart'; +import '../../shared/providers.dart'; class MovieListScreen extends ConsumerWidget { const MovieListScreen({super.key}); @@ -23,6 +24,71 @@ class MovieListScreen extends ConsumerWidget { selected: filter, onChanged: (f) => ref.read(movieFilterProvider.notifier).state = f, ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.sync), + label: const Text('TMDB: alle Filme updaten'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + String current = ''; + int idx = 0; + int total = 0; + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + return StatefulBuilder(builder: (ctx, setState) { + Future run() async { + try { + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + final items = await ref.read(moviesProvider.future); + total = items.length; + int ok = 0; + for (final m in items) { + idx++; + current = m.title; + setState(() {}); + try { + final json = await tmdb.getMovie(m.tmdbId); + await backend.upsertMovie(json); + ok++; + } catch (_) {} + } + // Refresh list after update + // ignore: unused_result + ref.invalidate(moviesProvider); + messenger.showSnackBar(SnackBar(content: Text('TMDB Update fertig: $ok/$total Filme'))); + } finally { + if (ctx.mounted) Navigator.of(ctx).pop(); + } + } + if (idx == 0 && total == 0) { + // ignore: discarded_futures + run(); + } + return AlertDialog( + title: const Text('TMDB Update'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Aktualisiere: $idx/$total'), + const SizedBox(height: 8), + Text(current, maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 12), + LinearProgressIndicator(value: total > 0 ? idx / total : null), + ], + ), + ); + }); + }, + ); + }, + ), + ), const SizedBox(height: 12), Expanded( child: moviesAsync.when( @@ -169,4 +235,3 @@ class MovieListScreen extends ConsumerWidget { ); } } - diff --git a/lib/features/series/presentation/series_detail_screen.dart b/lib/features/series/presentation/series_detail_screen.dart index 2918d6b..40b2054 100644 --- a/lib/features/series/presentation/series_detail_screen.dart +++ b/lib/features/series/presentation/series_detail_screen.dart @@ -40,6 +40,7 @@ class _SeriesDetailScreenState extends ConsumerState { String? _overview; List> _cast = const []; List> _crew = const []; + int? _tmdbId; @override void initState() { @@ -76,10 +77,11 @@ class _SeriesDetailScreenState extends ConsumerState { if (_downloadCtrl.text != (_downloadPath ?? '')) { _downloadCtrl.text = _downloadPath ?? ''; } - // parse overview/cast/crew from show_json + // parse overview/cast/crew (and tmdb id) from show_json if (first.showJson != null && first.showJson!.isNotEmpty) { try { final m = jsonDecode(first.showJson!) as Map; + _tmdbId = (m['id'] as num?)?.toInt() ?? _tmdbId; _overview = (m['overview'] as String?) ?? _overview; final credits = m['credits'] as Map?; if (credits != null) { @@ -237,6 +239,76 @@ class _SeriesDetailScreenState extends ConsumerState { elevation: 0, title: Text(widget.showName), actions: [ + IconButton( + tooltip: 'TMDB aktualisieren', + icon: const Icon(Icons.sync), + onPressed: _tmdbId == null + ? null + : () async { + try { + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + int? tid = _tmdbId; + if (tid == null && _showId != null) { + tid = await backend.getTmdbIdByShowId(_showId!); + } + if (tid == null) { + throw 'tmdb_id für diese Serie konnte nicht ermittelt werden'; + } + final showJson = await tmdb.getShow(tid); + // Update header meta immediately (overview/cast/crew) + try { + setState(() { + _overview = (showJson['overview'] as String?) ?? _overview; + final credits = showJson['credits'] as Map?; + if (credits != null) { + final c = (credits['cast'] as List? ?? const []) + .cast() + .map((e) => Map.from(e as Map)) + .toList(); + final cr = (credits['crew'] as List? ?? const []) + .cast() + .map((e) => Map.from(e as Map)) + .toList(); + _cast = c.take(12).toList(); + _crew = cr.take(12).toList(); + } + }); + } catch (_) {} + final dbShowId = await backend.upsertShow(showJson); + // seasons + final seasons = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)['season_number'] as int) + .toList(); + for (final sNo in seasons) { + if (sNo < 0) continue; + final seasonJson = await tmdb.getSeason(tid, sNo); + final seasonId = await backend.upsertSeason(dbShowId, seasonJson); + final eps = (seasonJson['episodes'] as List? ?? const []) + .cast>(); + for (final e in eps) { + await backend.upsertEpisode(seasonId, e); + } + } + // Refresh list + reload local grouped data + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + await _load(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('TMDB Daten aktualisiert')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')), + ); + } + } + }, + ), if (dirty) IconButton( icon: const Icon(Icons.save), diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart index 25db305..f186ef0 100644 --- a/lib/features/series/presentation/series_list_screen.dart +++ b/lib/features/series/presentation/series_list_screen.dart @@ -7,6 +7,7 @@ import '../../movies/presentation/widgets/status_chip.dart'; import '../data/series_repository.dart'; import 'widgets/episode_status_strip.dart'; import 'series_detail_screen.dart'; +import '../../shared/providers.dart'; class SeriesListScreen extends ConsumerWidget { const SeriesListScreen({super.key}); @@ -27,6 +28,211 @@ class SeriesListScreen extends ConsumerWidget { onChanged: (f) => ref.read(episodeFilterProvider.notifier).state = f, ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.sync), + label: const Text('TMDB: alle Serien updaten'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + String current = ''; + int idx = 0; + int total = 0; + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + // Stateful progress dialog + return StatefulBuilder(builder: (ctx, setState) { + Future run() async { + try { + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + List> showRows = []; + try { + showRows = await backend.listShows(); + } catch (_) { + // Fallback: aus der aktuellen grouped-Liste tmdbId parsen + final grouped = await ref.read(seriesGroupedProvider.future); + for (final entry in grouped.data.entries) { + final flat = entry.value.values.expand((e) => e); + if (flat.isEmpty) continue; + final ep = flat.first; + if (ep.showJson == null) continue; + try { + final m = jsonDecode(ep.showJson!); + final t = (m['id'] as num?)?.toInt(); + if (t != null) { + showRows.add({'tmdb_id': t, 'name': entry.key}); + } + } catch (_) {} + } + } + total = showRows.length; + int ok = 0; + for (final row in showRows) { + idx++; + final tmdbId = (row['tmdb_id'] as num?)?.toInt(); + current = (row['name'] as String?) ?? 'tmdb:$tmdbId'; + setState(() {}); + if (tmdbId == null) continue; + try { + final showJson = await tmdb.getShow(tmdbId); + final dbShowId = await backend.upsertShow(showJson); + final seasons = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)['season_number'] as int) + .toList(); + for (final sNo in seasons) { + if (sNo < 0) continue; + final seasonJson = await tmdb.getSeason(tmdbId, sNo); + final seasonId = await backend.upsertSeason(dbShowId, seasonJson); + final eps = (seasonJson['episodes'] as List? ?? const []) + .cast>(); + for (final e in eps) { + await backend.upsertEpisode(seasonId, e); + } + } + ok++; + } catch (_) {} + } + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + messenger.showSnackBar( + SnackBar(content: Text('TMDB Update fertig: $ok/$total Serien')), + ); + } finally { + if (ctx.mounted) Navigator.of(ctx).pop(); + } + } + // kick off once when dialog builds first time + if (idx == 0 && total == 0) { + // ignore: discarded_futures + run(); + } + return AlertDialog( + title: const Text('TMDB Update'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Aktualisiere: $idx/$total'), + const SizedBox(height: 8), + Text(current, maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 12), + LinearProgressIndicator(value: total > 0 ? idx / total : null), + ], + ), + ); + }); + }, + ); + }, + ), + ), + const SizedBox(height: 6), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.build), + label: const Text('TMDB: Serie reparieren (IDs)'), + onPressed: () async { + final ctrl = TextEditingController(); + final ids = await showDialog>( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('TMDB IDs (Komma/Leerzeichen getrennt)'), + content: TextField( + controller: ctrl, + decoration: const InputDecoration(hintText: 'z.B. 1396, 1399'), + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Abbrechen')), + FilledButton( + onPressed: () { + final parts = ctrl.text.split(RegExp(r'[\s,;]+')); + final list = []; + for (final p in parts) { + final v = int.tryParse(p.trim()); + if (v != null) list.add(v); + } + Navigator.of(ctx).pop(list); + }, + child: const Text('Start'), + ), + ], + ), + ); + if (ids == null || ids.isEmpty) return; + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + String current = ''; + int idx = 0; + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + return StatefulBuilder(builder: (ctx, setState) { + Future run() async { + try { + int ok = 0; + for (final tmdbId in ids) { + idx++; + current = 'tmdb:$tmdbId'; + setState(() {}); + try { + final showJson = await tmdb.getShow(tmdbId); + await backend.upsertShow(showJson); + final seasons = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)['season_number'] as int) + .toList(); + final dbShowId = await backend.getShowDbIdByTmdbId(tmdbId); + if (dbShowId == null) continue; + for (final sNo in seasons) { + final seasonJson = await tmdb.getSeason(tmdbId, sNo); + final seasonId = await backend.upsertSeason(dbShowId, seasonJson); + final eps = (seasonJson['episodes'] as List? ?? const []) + .cast>(); + for (final e in eps) { + await backend.upsertEpisode(seasonId, e); + } + } + ok++; + } catch (_) {} + } + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + } finally { + if (ctx.mounted) Navigator.of(ctx).pop(); + } + } + if (idx == 0) { + // ignore: discarded_futures + run(); + } + return AlertDialog( + title: const Text('TMDB Reparatur'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Aktualisiere: $idx/${ids.length}'), + const SizedBox(height: 8), + Text(current, maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 12), + LinearProgressIndicator(value: idx / ids.length), + ], + ), + ); + }); + }, + ); + }, + ), + ), const SizedBox(height: 12), Expanded( child: groupedAsync.when( diff --git a/lib/php/multimedia.php b/lib/php/multimedia.php index 1ec325c..92e38bf 100644 --- a/lib/php/multimedia.php +++ b/lib/php/multimedia.php @@ -170,8 +170,24 @@ try { } $id = $pdo->lastInsertId(); if (!$id) { - if ($tmdbId) { $q=$pdo->prepare('SELECT id FROM episodes WHERE tmdb_id=?'); $q->execute([$tmdbId]); $id=$q->fetchColumn(); } - if (!$id) { $q=$pdo->prepare('SELECT id FROM episodes WHERE season_id=? AND episode_number=?'); $q->execute([$seasonId,$epNo]); $id=$q->fetchColumn(); } + if ($tmdbId) { + try { + $q=$pdo->prepare('SELECT id FROM episodes WHERE tmdb_id=?'); + $q->execute([$tmdbId]); + $id=$q->fetchColumn(); + } catch (Throwable $e) { + if (strpos($e->getMessage(), 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) { + $q=$pdo->prepare('SELECT id FROM episodes WHERE season_id=? AND episode_number=?'); + $q->execute([$seasonId,$epNo]); + $id=$q->fetchColumn(); + } else { throw $e; } + } + } + if (!$id) { + $q=$pdo->prepare('SELECT id FROM episodes WHERE season_id=? AND episode_number=?'); + $q->execute([$seasonId,$epNo]); + $id=$q->fetchColumn(); + } } resp(['ok'=>true,'id'=>(int)$id]); } @@ -359,10 +375,56 @@ try { case 'get_show_by_tmdb': { $tmdbId = (int)($in['tmdb_id'] ?? 0); if (!$tmdbId) fail('bad params'); - $stmt = $pdo->prepare('SELECT id FROM shows WHERE tmdb_id = ?'); - $stmt->execute([$tmdbId]); - $id = $stmt->fetchColumn(); - resp(['ok' => true, 'id' => $id ? (int)$id : null]); + try { + $stmt = $pdo->prepare('SELECT id FROM shows WHERE tmdb_id = ?'); + $stmt->execute([$tmdbId]); + $id = $stmt->fetchColumn(); + resp(['ok' => true, 'id' => $id ? (int)$id : null]); + } catch (Throwable $e) { + // Fallback if column tmdb_id does not exist (older schema): scan JSON + if (strpos($e->getMessage(), 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) { + $stmt = $pdo->query('SELECT id, json FROM shows'); + $rows = $stmt->fetchAll(); + $found = null; + foreach ($rows as $r) { + $j = json_decode($r['json'] ?? 'null', true); + if (is_array($j) && isset($j['id']) && (int)$j['id'] === $tmdbId) { + $found = (int)$r['id']; + break; + } + } + resp(['ok' => true, 'id' => $found]); + } else { + throw $e; + } + } + } + + case 'get_tmdb_by_show_id': { + $showId = (int)($in['show_id'] ?? 0); + if (!$showId) fail('bad params'); + try { + $stmt = $pdo->prepare('SELECT tmdb_id FROM shows WHERE id = ?'); + $stmt->execute([$showId]); + $tm = $stmt->fetchColumn(); + resp(['ok' => true, 'tmdb_id' => $tm !== false ? (int)$tm : null]); + } catch (Throwable $e) { + // Fallback if column tmdb_id does not exist: load from JSON + if (strpos($e->getMessage(), 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) { + $stmt = $pdo->prepare('SELECT json FROM shows WHERE id = ?'); + $stmt->execute([$showId]); + $json = $stmt->fetchColumn(); + $m = json_decode($json ?: 'null', true); + $tid = is_array($m) && isset($m['id']) ? (int)$m['id'] : null; + resp(['ok' => true, 'tmdb_id' => $tid]); + } else { throw $e; } + } + } + + case 'list_shows': { + $stmt = $pdo->query('SELECT id, tmdb_id, name, json FROM shows'); + $rows = $stmt->fetchAll(); + resp(['ok' => true, 'items' => $rows]); } case 'set_show_meta': {