From d3a2618dd93f87e3d43c4c99e6bb09a5fb74cc44 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Mon, 10 Nov 2025 15:05:15 +0100 Subject: [PATCH] speed up series update --- lib/core/api/backend_api.dart | 33 ++ lib/core/async_utils.dart | 30 ++ lib/core/config.dart | 11 + lib/features/import/import_screen.dart | 72 +++- .../presentation/series_detail_screen.dart | 176 ++++++--- .../presentation/series_list_screen.dart | 333 +++++++++++++----- lib/php/multimedia.php | 132 ++++++- 7 files changed, 618 insertions(+), 169 deletions(-) create mode 100644 lib/core/async_utils.dart diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart index fd7eb28..c845274 100644 --- a/lib/core/api/backend_api.dart +++ b/lib/core/api/backend_api.dart @@ -185,6 +185,39 @@ class BackendApi { return (map['id'] as num).toInt(); } + Future upsertEpisodesBulk(int seasonId, List> episodes) async { + final map = await _post({ + 'action': 'upsert_episodes_bulk', + 'season_id': seasonId, + 'episodes': episodes, + }); + return (map['count'] as num?)?.toInt() ?? 0; + } + + Future> upsertSeasonsBulk(int showId, List> seasons) async { + final map = await _post({ + 'action': 'upsert_seasons_bulk', + 'show_id': showId, + 'seasons': seasons, + }); + final m = {}; + final any = map['map']; + if (any is Map) { + for (final e in any.entries) { + final k = int.tryParse(e.key.toString()); + final v = (e.value as num?)?.toInt(); + if (k != null && v != null) m[k] = v; + } + } + return m; + } + + Future> getCapabilities() async { + final map = await _post({'action': 'get_capabilities'}); + final caps = map['capabilities']; + return caps is Map ? caps : (caps as Map).cast(); + } + Future getShowDbIdByTmdbId(int tmdbId) async { final map = await _post({'action': 'get_show_by_tmdb', 'tmdb_id': tmdbId}); final v = map['id']; diff --git a/lib/core/async_utils.dart b/lib/core/async_utils.dart new file mode 100644 index 0000000..3f52ccd --- /dev/null +++ b/lib/core/async_utils.dart @@ -0,0 +1,30 @@ +typedef CancelCheck = bool Function(); + +Iterable> _chunked(List list, int size) sync* { + if (size <= 0) size = 1; + for (var i = 0; i < list.length; i += size) { + final end = i + size; + yield list.sublist(i, end > list.length ? list.length : end); + } +} + +Future runChunked( + Iterable items, + int chunkSize, + Future Function(T item) task, { + CancelCheck? isCancelled, +}) async { + final list = items.toList(); + for (final chunk in _chunked(list, chunkSize)) { + if (isCancelled?.call() == true) break; + await Future.wait( + chunk.map((e) async { + if (isCancelled?.call() == true) return; + await task(e); + }), + eagerError: false, + ); + if (isCancelled?.call() == true) break; + } +} + diff --git a/lib/core/config.dart b/lib/core/config.dart index 6a21366..50105dc 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -22,4 +22,15 @@ class AppConfig { ///defaultValue: 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJhMzMyNzFiOWU1NGNkY2I5YTgwNjgwZWFmNTUyMmYxYiIsIm5iZiI6MTM0ODc2NTY2MS4wLCJzdWIiOiI1MDY0ODdkZDE5YzI5NTY2M2MwMDBhOGIiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.m26QybYBGQVY8OuL87FFae3ThPqAnOqEwgbLMtnH0wo' ); + + // Concurrency caps + static const tmdbSeasonFetchConcurrency = int.fromEnvironment( + 'TMDB_SEASON_CONCURRENCY', + defaultValue: 3, + ); + + static const dbEpisodeUpsertConcurrency = int.fromEnvironment( + 'DB_EPISODE_UPSERT_CONCURRENCY', + defaultValue: 12, + ); } diff --git a/lib/features/import/import_screen.dart b/lib/features/import/import_screen.dart index 99bc1bd..b925c95 100644 --- a/lib/features/import/import_screen.dart +++ b/lib/features/import/import_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart'; import '../../core/api/backend_api.dart'; +import '../../core/config.dart'; +import '../../core/async_utils.dart'; import '../shared/providers.dart'; class ImportScreen extends ConsumerStatefulWidget { @@ -18,6 +20,7 @@ class _ImportScreenState extends ConsumerState { TextEditingController(text: '1396, 1399'); // Breaking Bad, GoT String _log = ''; bool _busy = false; + bool _cancel = false; void _append(String msg) => setState(() => _log += '$msg\n'); @@ -44,7 +47,7 @@ class _ImportScreenState extends ConsumerState { } Future _importShows() async { - setState(() => _busy = true); + setState(() { _busy = true; _cancel = false; }); final tmdb = ref.read(tmdbApiProvider); final backend = ref.read(backendApiProvider); @@ -54,6 +57,7 @@ class _ImportScreenState extends ConsumerState { .map(int.parse); for (final showId in ids) { + if (_cancel) break; try { _append('Serie $showId: TMDB laden …'); final showJson = await tmdb.getShow(showId); @@ -62,27 +66,51 @@ class _ImportScreenState extends ConsumerState { await backend.upsertShow(showJson); _append('Serie $showId: Show OK ✓'); - final seasons = (showJson['seasons'] as List? ?? const []) + final seasonNos = (showJson['seasons'] as List? ?? const []) .where((s) => (s['season_number'] ?? 0) is int) - .cast>(); - - for (final s in seasons) { - final seasonNo = (s['season_number'] as num).toInt(); - if (seasonNo < 0) continue; - _append(' S$seasonNo: TMDB Season laden …'); - - final seasonJson = await tmdb.getSeason(showId, seasonNo); - final dbShowId = await _getDbShowIdByTmdb(backend, showId); - final dbSeasonId = await backend.upsertSeason(dbShowId, seasonJson); - - _append(' S$seasonNo: Season OK (db:$dbSeasonId)'); + .map((s) => (s as Map)['season_number'] as int) + .where((n) => n >= 0) + .toList(); + + // Fetch all seasons (limited concurrency) + final seasonsJson = >{}; + await runChunked( + seasonNos, + AppConfig.tmdbSeasonFetchConcurrency, + (sNo) async { + if (_cancel) return; + final sj = await tmdb.getSeason(showId, sNo); + seasonsJson[sNo] = sj; + }, + isCancelled: () => _cancel, + ); + + final dbShowId = await _getDbShowIdByTmdb(backend, showId); + // Bulk upsert seasons and get IDs + Map seasonIds = const {}; + try { + seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList()); + } catch (_) { + final m = {}; + for (final entry in seasonsJson.entries) { final id = await backend.upsertSeason(dbShowId, entry.value); m[entry.key] = id; } + seasonIds = m; + } - final eps = (seasonJson['episodes'] as List? ?? const []) - .cast>(); - for (final e in eps) { - await backend.upsertEpisode(dbSeasonId, e); + for (final entry in seasonsJson.entries) { + if (_cancel) break; + final sNo = entry.key; final seasonJson = entry.value; final dbSeasonId = seasonIds[sNo]; if (dbSeasonId == null) continue; + _append(' S$sNo: upserting episodes …'); + final eps = (seasonJson['episodes'] as List? ?? const []).cast>(); + try { await backend.upsertEpisodesBulk(dbSeasonId, eps); } + catch (_) { + await runChunked>( + eps, + AppConfig.dbEpisodeUpsertConcurrency, + (e) async { if (_cancel) return; await backend.upsertEpisode(dbSeasonId, e); }, + isCancelled: () => _cancel, + ); } - _append(' S$seasonNo: ${eps.length} Episoden OK ✓'); + _append(' S$sNo: ${eps.length} Episoden OK ✓'); } } catch (e) { // 👇 Hier kommt der erweiterte Catch hin! @@ -139,6 +167,12 @@ class _ImportScreenState extends ConsumerState { onPressed: _busy ? null : _importShows, child: const Text('Import Serien'), ), + const SizedBox(width: 8), + if (_busy) + TextButton( + onPressed: () => setState(() => _cancel = true), + child: const Text('Abbrechen'), + ), ], ), const SizedBox(height: 12), diff --git a/lib/features/series/presentation/series_detail_screen.dart b/lib/features/series/presentation/series_detail_screen.dart index 6d7beb5..6943518 100644 --- a/lib/features/series/presentation/series_detail_screen.dart +++ b/lib/features/series/presentation/series_detail_screen.dart @@ -7,6 +7,8 @@ import '../../../core/status.dart'; import '../../shared/providers.dart'; import '../data/series_repository.dart'; import '../data/episode_model.dart'; +import '../../../core/async_utils.dart'; +import '../../../core/config.dart'; class SeriesDetailScreen extends ConsumerStatefulWidget { // showName is the grouping key (unique). @@ -278,64 +280,126 @@ class _SeriesDetailScreenState extends ConsumerState { ? null : () async { final messenger = ScaffoldMessenger.of(context); - 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(); + int done = 0; + int total = 0; + var cancel = false; + 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); + 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 int tidVal = tid; // non-null now + final showJson = await tmdb.getShow(tidVal); + // Update header meta immediately (overview/cast/crew) + try { + if (mounted) { + 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 sNos = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)['season_number'] as int) + .where((s) => s >= 0) + .toList(); + final seasonsJson = >{}; + await runChunked( + sNos, + AppConfig.tmdbSeasonFetchConcurrency, + (sNo) async { if (cancel) return; final sj = await tmdb.getSeason(tidVal, sNo); seasonsJson[sNo] = sj; }, + isCancelled: () => cancel, + ); + Map seasonIds = const {}; + try { + seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList()); + } catch (_) { + final m = {}; + for (final entry in seasonsJson.entries) { final id = await backend.upsertSeason(dbShowId, entry.value); m[entry.key] = id; } + seasonIds = m; + } + total = 0; for (final sj in seasonsJson.values) { total += ((sj['episodes'] as List? ?? const []).length); } + if (ctx.mounted) setState(() {}); + for (final entry in seasonsJson.entries) { + if (cancel) break; + final sNo = entry.key; final seasonJson = entry.value; final seasonId = seasonIds[sNo]; if (seasonId == null) continue; + final eps = (seasonJson['episodes'] as List? ?? const []).cast>(); + try { await backend.upsertEpisodesBulk(seasonId, eps); done += eps.length; if (ctx.mounted) setState(() {}); } + catch (_) { + await runChunked>( + eps, + AppConfig.dbEpisodeUpsertConcurrency, + (e) async { if (cancel) return; await backend.upsertEpisode(seasonId, e); done++; if (ctx.mounted) setState(() {}); }, + isCancelled: () => cancel, + ); + } + } + // Refresh list + reload local grouped data + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + await _load(); + messenger.showSnackBar( + const SnackBar(content: Text('TMDB Daten aktualisiert')), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')), + ); + } finally { + if (ctx.mounted) Navigator.of(ctx).pop(); + } } + if (done == 0 && total == 0) { + // ignore: discarded_futures + run(); + } + return AlertDialog( + title: const Text('TMDB Update'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(total > 0 ? '$done / $total Episoden' : 'Lade …'), + const SizedBox(height: 12), + LinearProgressIndicator(value: total > 0 ? (done / total).clamp(0, 1) : null), + ], + ), + actions: [ + TextButton( + onPressed: () { cancel = true; }, + child: const Text('Abbrechen'), + ), + ], + ); }); - } 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(); - messenger.showSnackBar( - const SnackBar(content: Text('TMDB Daten aktualisiert')), - ); - } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')), - ); - } + }, + ); }, ), if (dirty) diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart index 01b9aab..b9abff0 100644 --- a/lib/features/series/presentation/series_list_screen.dart +++ b/lib/features/series/presentation/series_list_screen.dart @@ -13,12 +13,29 @@ import '../../shared/providers.dart'; import '../data/episode_model.dart'; import 'widgets/season_status_bar.dart'; import '../../../core/io_open.dart'; +import '../../../core/config.dart'; +import '../../../core/async_utils.dart'; -class SeriesListScreen extends ConsumerWidget { +class SeriesListScreen extends ConsumerStatefulWidget { const SeriesListScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _SeriesListScreenState(); +} + +class _SeriesListScreenState extends ConsumerState { + final ScrollController _groupedVController = ScrollController(); + final ScrollController _summaryVController = ScrollController(); + + @override + void dispose() { + _groupedVController.dispose(); + _summaryVController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final filter = ref.watch(episodeFilterProvider); final groupedAsync = ref.watch(seriesGroupedProvider); final summaryAsync = ref.watch(seriesSummaryProvider); @@ -60,6 +77,7 @@ class SeriesListScreen extends ConsumerWidget { String current = ''; int idx = 0; int total = 0; + var cancel = false; await showDialog( context: context, barrierDismissible: false, @@ -88,46 +106,78 @@ class SeriesListScreen extends ConsumerWidget { final t = (m['id'] as num?)?.toInt(); if (t != null) { showRows.add( - {'tmdb_id': t, 'name': entry.key}); + {'tmdb_id': t, 'name': entry.key.split('##').first}); } } catch (_) {} } } + // Sort by display name (before any '##') for a stable, predictable order + showRows.sort((a, b) { + String an = (a['name'] as String? ?? '').split('##').first.toLowerCase(); + String bn = (b['name'] as String? ?? '').split('##').first.toLowerCase(); + return an.compareTo(bn); + }); total = showRows.length; int success = 0; for (final row in showRows) { + if (cancel) break; idx++; final tmdbId = (row['tmdb_id'] as num?)?.toInt(); - current = (row['name'] as String?) ?? - 'tmdb:$tmdbId'; - setState(() {}); + current = (row['name'] as String?) ?? 'tmdb:$tmdbId'; + if (ctx.mounted) 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) + final sNos = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)['season_number'] as int) + .where((s) => s >= 0) .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); + // Fetch all seasons (limited concurrency) + final seasonsJson = >{}; + await runChunked( + sNos, + AppConfig.tmdbSeasonFetchConcurrency, + (sNo) async { + if (cancel) return; + final sj = await tmdb.getSeason(tmdbId, sNo); + seasonsJson[sNo] = sj; + }, + isCancelled: () => cancel, + ); + // Bulk upsert seasons and get IDs + Map seasonIds = const {}; + try { + seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList()); + } catch (_) { + // Fallback per season + final m = {}; + for (final entry in seasonsJson.entries) { + final id = await backend.upsertSeason(dbShowId, entry.value); + m[entry.key] = id; + } + seasonIds = m; + } + // Upsert episodes per season (prefer bulk) + for (final entry in seasonsJson.entries) { + if (cancel) break; + final sNo = entry.key; + final seasonJson = entry.value; + final seasonId = seasonIds[sNo]; + if (seasonId == null) continue; + final eps = (seasonJson['episodes'] as List? ?? const []) + .cast>(); + try { await backend.upsertEpisodesBulk(seasonId, eps); } + catch (_) { + await runChunked>( + eps, + AppConfig.dbEpisodeUpsertConcurrency, + (e) async { if (cancel) return; await backend.upsertEpisode(seasonId, e); }, + isCancelled: () => cancel, + ); } } success++; @@ -135,6 +185,8 @@ class SeriesListScreen extends ConsumerWidget { } // ignore: unused_result ref.invalidate(seriesGroupedProvider); + // ignore: unused_result + ref.invalidate(seriesSummaryProvider); messenger.showSnackBar( SnackBar( content: Text( @@ -166,6 +218,14 @@ class SeriesListScreen extends ConsumerWidget { value: total > 0 ? idx / total : null), ], ), + actions: [ + TextButton( + onPressed: () { + cancel = true; + }, + child: const Text('Abbrechen'), + ), + ], ); }); }, @@ -324,7 +384,7 @@ class SeriesListScreen extends ConsumerWidget { final cells = [ DataCell( MenuAnchor( - menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath), + menuChildren: [...widget._seriesMenuItems(context, ref, showKey, displayName, downloadPath), MenuItemButton( child: const Text('set all progress to done'), onPressed: () async { @@ -365,37 +425,101 @@ class SeriesListScreen extends ConsumerWidget { messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID'))); return; } - try { - final tmdb = ref.read(tmdbApiProvider); - final backend = ref.read(backendApiProvider); - final tid = await backend.getTmdbIdByShowId(sid); - if (tid == null) { - messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden'))); - return; - } - final showJson = await tmdb.getShow(tid); - 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(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); - } - } - // ignore: unused_result - ref.invalidate(seriesGroupedProvider); - // ignore: unused_result - ref.invalidate(seriesSummaryProvider); - messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen'))); - } catch (e) { - messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e'))); - } + String current = displayName; + int done = 0; + int total = 0; + var cancel = false; + 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 tid = await backend.getTmdbIdByShowId(sid); + if (tid == null) { messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden'))); return; } + final showJson = await tmdb.getShow(tid); + final dbShowId = await backend.upsertShow(showJson); + final sNos = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)['season_number'] as int) + .where((s) => s >= 0) + .toList(); + final seasonsJson = >{}; + await runChunked( + sNos, + AppConfig.tmdbSeasonFetchConcurrency, + (sNo) async { + if (cancel) return; + final sj = await tmdb.getSeason(tid, sNo); + seasonsJson[sNo] = sj; + }, + isCancelled: () => cancel, + ); + Map seasonIds = const {}; + try { + seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList()); + } catch (_) { + final m = {}; + for (final entry in seasonsJson.entries) { + final id = await backend.upsertSeason(dbShowId, entry.value); + m[entry.key] = id; + } + seasonIds = m; + } + // Count total episodes + total = 0; for (final sj in seasonsJson.values) { total += ((sj['episodes'] as List? ?? const []).length); } + if (ctx.mounted) setState(() {}); + for (final entry in seasonsJson.entries) { + if (cancel) break; + final sNo = entry.key; final seasonJson = entry.value; final seasonId = seasonIds[sNo]; if (seasonId == null) continue; + final eps = (seasonJson['episodes'] as List? ?? const []).cast>(); + try { await backend.upsertEpisodesBulk(seasonId, eps); done += eps.length; if (ctx.mounted) setState(() {}); } + catch (_) { + await runChunked>( + eps, + AppConfig.dbEpisodeUpsertConcurrency, + (e) async { if (cancel) return; await backend.upsertEpisode(seasonId, e); done++; if (ctx.mounted) setState(() {}); }, + isCancelled: () => cancel, + ); + } + } + ref.invalidate(seriesGroupedProvider); + ref.invalidate(seriesSummaryProvider); + messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e'))); + } finally { + if (ctx.mounted) Navigator.of(ctx).pop(); + } + } + if (done == 0 && total == 0) { + // ignore: discarded_futures + run(); + } + return AlertDialog( + title: Text('Update: $current'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(total > 0 ? '$done / $total Episoden' : 'Lade …'), + const widgets.SizedBox(height: 12), + LinearProgressIndicator(value: total > 0 ? (done / total).clamp(0, 1) : null), + ], + ), + actions: [ + TextButton( + onPressed: () { cancel = true; }, + child: const Text('Abbrechen'), + ), + ], + ); + }); + }, + ); }, ), MenuItemButton( @@ -495,7 +619,7 @@ class SeriesListScreen extends ConsumerWidget { if (eps == null || eps.isEmpty) { cells.add(DataCell( MenuAnchor( - menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath), + menuChildren: [...widget._seriesMenuItems(context, ref, showKey, displayName, downloadPath), MenuItemButton( child: const Text('set all progress to done'), onPressed: () async { @@ -587,7 +711,7 @@ class SeriesListScreen extends ConsumerWidget { } else { cells.add(DataCell( MenuAnchor( - menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath), + menuChildren: [...widget._seriesMenuItems(context, ref, showKey, displayName, downloadPath), MenuItemButton( child: const Text('set all progress to done'), onPressed: () async { @@ -687,7 +811,21 @@ class SeriesListScreen extends ConsumerWidget { return SingleChildScrollView( scrollDirection: Axis.horizontal, - child: SingleChildScrollView(child: table), + child: RawScrollbar( + controller: _groupedVController, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + thickness: 10, + radius: const Radius.circular(6), + thumbColor: Colors.black54, + trackColor: Colors.black12, + trackBorderColor: Colors.black26, + child: SingleChildScrollView( + controller: _groupedVController, + child: table, + ), + ), ); }, ); @@ -703,7 +841,7 @@ class SeriesListScreen extends ConsumerWidget { BuildContext context, WidgetRef ref, List> items) { final filter = ref.watch(episodeFilterProvider); - bool _includeByFilter(Map m) { + bool includeByFilter(Map m) { int initTotal = 0; int progTotal = 0; int doneTotal = 0; @@ -766,7 +904,7 @@ class SeriesListScreen extends ConsumerWidget { } // Apply series-level filter - items = items.where(_includeByFilter).toList(); + items = items.where(includeByFilter).toList(); // Build and sort keys final keys = []; final byKey = >{}; @@ -969,7 +1107,7 @@ class SeriesListScreen extends ConsumerWidget { children: [ // Left (with context menu) MenuAnchor( - menuChildren: _seriesMenuItems(context, ref, rowKey, name, downloadPath), + menuChildren: widget._seriesMenuItems(context, ref, rowKey, name, downloadPath), builder: (ctx, controller, child) => GestureDetector( onSecondaryTapDown: (_) => controller.open(), onSecondaryTapUp: (_) => controller.open(), @@ -1059,7 +1197,7 @@ class SeriesListScreen extends ConsumerWidget { } if (eps.isNotEmpty) { return MenuAnchor( - menuChildren: [..._seriesMenuItems(context, ref, rowKey, name, downloadPath), + menuChildren: [...widget._seriesMenuItems(context, ref, rowKey, name, downloadPath), MenuItemButton( onPressed: () async { final messenger = ScaffoldMessenger.of(context); @@ -1108,7 +1246,7 @@ class SeriesListScreen extends ConsumerWidget { ); if (match.isEmpty) { return MenuAnchor( - menuChildren: [..._seriesMenuItems(context, ref, rowKey, name, downloadPath), + menuChildren: [...widget._seriesMenuItems(context, ref, rowKey, name, downloadPath), MenuItemButton( onPressed: () async { final messenger = ScaffoldMessenger.of(context); @@ -1195,29 +1333,38 @@ class SeriesListScreen extends ConsumerWidget { } const headerHeight = 32.0; - return Scrollbar( - thumbVisibility: true, - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: widgets.SizedBox( - width: tableWidth, - height: constraints.maxHeight, - child: Stack( - children: [ - // Sticky header at the top; scrolls horizontally with content - Positioned( - top: 0, - left: 0, - right: 0, - child: widgets.SizedBox( - height: headerHeight, child: buildHeader()), - ), - // Vertical list below the header - Positioned.fill( - top: headerHeight, + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: widgets.SizedBox( + width: tableWidth, + height: constraints.maxHeight, + child: Stack( + children: [ + // Sticky header at the top; scrolls horizontally with content + Positioned( + top: 0, + left: 0, + right: 0, + child: + widgets.SizedBox(height: headerHeight, child: buildHeader()), + ), + // Vertical list below the header with its own scrollbar + Positioned.fill( + top: headerHeight, + child: RawScrollbar( + controller: _summaryVController, + thumbVisibility: true, + trackVisibility: true, + interactive: true, + thickness: 10, + radius: const Radius.circular(6), + thumbColor: Colors.black54, + trackColor: Colors.black12, + trackBorderColor: Colors.black26, child: ListView.separated( + controller: _summaryVController, itemCount: keys.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, i) => widgets.SizedBox( @@ -1226,12 +1373,12 @@ class SeriesListScreen extends ConsumerWidget { ), ), ), - ], - ), + ), + ], ), - ); - }, - ), + ), + ); + }, ); } diff --git a/lib/php/multimedia.php b/lib/php/multimedia.php index 7e8d725..5baa961 100644 --- a/lib/php/multimedia.php +++ b/lib/php/multimedia.php @@ -47,7 +47,7 @@ function inputBody(): array { $in = $_POST ?: []; } // Normalize: decode JSON strings for nested payloads (e.g., tmdb) - foreach (['tmdb'] as $k) { + foreach (['tmdb','episodes','seasons'] as $k) { if (isset($in[$k]) && is_string($in[$k])) { $d = json_decode($in[$k], true); if (is_array($d)) $in[$k] = $d; @@ -191,6 +191,136 @@ try { } resp(['ok'=>true,'id'=>(int)$id]); } + + case 'upsert_episodes_bulk': { + $seasonId = (int)($in['season_id'] ?? 0); + $episodes = $in['episodes'] ?? null; + if (!$seasonId || !is_array($episodes)) fail('bad params'); + + // Detect if episodes table has tmdb_id column + $hasTmdbId = false; + try { + $pdo->query('SELECT tmdb_id FROM episodes LIMIT 0'); + $hasTmdbId = true; + } catch (Throwable $e) { + $hasTmdbId = false; + } + + // Prepare statements + $stmtWith = null; $stmtWithout = null; + try { + if ($hasTmdbId) { + $stmtWith = $pdo->prepare("INSERT INTO episodes (season_id, tmdb_id, episode_number, name, runtime, json) + VALUES (?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE episode_number=VALUES(episode_number), name=VALUES(name), runtime=VALUES(runtime), json=VALUES(json)"); + } + } catch (Throwable $_) { + $hasTmdbId = false; $stmtWith = null; + } + try { + $stmtWithout = $pdo->prepare("INSERT INTO episodes (season_id, episode_number, name, runtime, json) + VALUES (?,?,?,?,?) + ON DUPLICATE KEY UPDATE name=VALUES(name), runtime=VALUES(runtime), json=VALUES(json)"); + } catch (Throwable $e) { + fail('episodes table schema not supported: '.$e->getMessage(), 500); + } + + $count = 0; + $pdo->beginTransaction(); + try { + foreach ($episodes as $tmdb) { + if (!is_array($tmdb)) continue; + $epNo = isset($tmdb['episode_number']) ? (int)$tmdb['episode_number'] : null; + if ($epNo === null) continue; + $name = $tmdb['name'] ?? ('Episode '.$epNo); + $runtime = isset($tmdb['runtime']) ? (int)$tmdb['runtime'] : null; + $tmdbId = $tmdb['id'] ?? null; + $json = json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + + if ($hasTmdbId && $tmdbId) { + try { + $stmtWith->execute([$seasonId, $tmdbId, $epNo, $name, $runtime, $json]); + $count++; + continue; + } catch (Throwable $_) { + // Fall back to without tmdb_id + } + } + $stmtWithout->execute([$seasonId, $epNo, $name, $runtime, $json]); + $count++; + } + $pdo->commit(); + } catch (Throwable $e) { + $pdo->rollBack(); + fail(MM_DEBUG ? ('bulk upsert failed: '.$e->getMessage()) : 'bulk upsert failed', 500); + } + resp(['ok'=>true, 'count'=>$count]); + } + + case 'upsert_seasons_bulk': { + $showId = (int)($in['show_id'] ?? 0); + $seasons = $in['seasons'] ?? null; + if (!$showId || !is_array($seasons)) fail('bad params'); + + $map = []; + $count = 0; + $pdo->beginTransaction(); + try { + // Prepare both statement variants to handle older schemas + $stmtWith = null; $stmtWithout = null; + try { + $stmtWith = $pdo->prepare("INSERT INTO seasons (show_id, season_number, name, air_date, json) + VALUES (?,?,?,?,?) + ON DUPLICATE KEY UPDATE name=VALUES(name), air_date=VALUES(air_date), json=VALUES(json)"); + } catch (Throwable $_) {} + try { + $stmtWithout = $pdo->prepare("INSERT INTO seasons (show_id, season_number, name, json) + VALUES (?,?,?,?) + ON DUPLICATE KEY UPDATE name=VALUES(name), json=VALUES(json)"); + } catch (Throwable $e) { fail('seasons table schema not supported: '.$e->getMessage(), 500); } + + foreach ($seasons as $tmdb) { + if (!is_array($tmdb)) continue; + $seasonNo = isset($tmdb['season_number']) ? (int)$tmdb['season_number'] : null; + if ($seasonNo === null) continue; + if ($seasonNo < 0) continue; + $name = $tmdb['name'] ?? ('Season '.$seasonNo); + $airDate = $tmdb['air_date'] ?? null; + $json = json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + $ok = false; + if ($stmtWith) { + try { $stmtWith->execute([$showId, $seasonNo, $name, $airDate, $json]); $ok = true; } + catch (Throwable $_) { /* fallback below */ } + } + if (!$ok && $stmtWithout) { + $stmtWithout->execute([$showId, $seasonNo, $name, $json]); + } + $count++; + // Resolve ID + $id = $pdo->lastInsertId(); + if (!$id) { + $q=$pdo->prepare('SELECT id FROM seasons WHERE show_id=? AND season_number=?'); + $q->execute([$showId,$seasonNo]); + $id=$q->fetchColumn(); + } + if ($id) $map[(int)$seasonNo] = (int)$id; + } + $pdo->commit(); + } catch (Throwable $e) { + $pdo->rollBack(); + fail(MM_DEBUG ? ('bulk seasons upsert failed: '.$e->getMessage()) : 'bulk seasons upsert failed', 500); + } + resp(['ok'=>true, 'count'=>$count, 'map'=>$map]); + } + + case 'get_capabilities': { + // Advertise supported actions; clients can probe this to choose bulk paths + $caps = [ + 'upsert_episodes_bulk' => true, + 'upsert_seasons_bulk' => true, + ]; + resp(['ok'=>true, 'capabilities'=>$caps]); + } case 'upsert_movie': { $tmdb = $in['tmdb'] ?? null; if (!$tmdb || !isset($tmdb['id'])) fail('missing tmdb payload'); $stmt = $pdo->prepare("INSERT INTO movies (tmdb_id, title, original_title, release_year, poster_path, backdrop_path, runtime, json)