speed up series update
This commit is contained in:
@@ -185,6 +185,39 @@ class BackendApi {
|
|||||||
return (map['id'] as num).toInt();
|
return (map['id'] as num).toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> upsertEpisodesBulk(int seasonId, List<Map<String, dynamic>> episodes) async {
|
||||||
|
final map = await _post({
|
||||||
|
'action': 'upsert_episodes_bulk',
|
||||||
|
'season_id': seasonId,
|
||||||
|
'episodes': episodes,
|
||||||
|
});
|
||||||
|
return (map['count'] as num?)?.toInt() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<int, int>> upsertSeasonsBulk(int showId, List<Map<String, dynamic>> seasons) async {
|
||||||
|
final map = await _post({
|
||||||
|
'action': 'upsert_seasons_bulk',
|
||||||
|
'show_id': showId,
|
||||||
|
'seasons': seasons,
|
||||||
|
});
|
||||||
|
final m = <int,int>{};
|
||||||
|
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<Map<String, dynamic>> getCapabilities() async {
|
||||||
|
final map = await _post({'action': 'get_capabilities'});
|
||||||
|
final caps = map['capabilities'];
|
||||||
|
return caps is Map<String, dynamic> ? caps : (caps as Map).cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
|
||||||
Future<int?> getShowDbIdByTmdbId(int tmdbId) async {
|
Future<int?> getShowDbIdByTmdbId(int tmdbId) async {
|
||||||
final map = await _post({'action': 'get_show_by_tmdb', 'tmdb_id': tmdbId});
|
final map = await _post({'action': 'get_show_by_tmdb', 'tmdb_id': tmdbId});
|
||||||
final v = map['id'];
|
final v = map['id'];
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
typedef CancelCheck = bool Function();
|
||||||
|
|
||||||
|
Iterable<List<T>> _chunked<T>(List<T> 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<void> runChunked<T>(
|
||||||
|
Iterable<T> items,
|
||||||
|
int chunkSize,
|
||||||
|
Future<void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -22,4 +22,15 @@ class AppConfig {
|
|||||||
|
|
||||||
///defaultValue: 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJhMzMyNzFiOWU1NGNkY2I5YTgwNjgwZWFmNTUyMmYxYiIsIm5iZiI6MTM0ODc2NTY2MS4wLCJzdWIiOiI1MDY0ODdkZDE5YzI5NTY2M2MwMDBhOGIiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.m26QybYBGQVY8OuL87FFae3ThPqAnOqEwgbLMtnH0wo'
|
///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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import '../../core/api/backend_api.dart';
|
import '../../core/api/backend_api.dart';
|
||||||
|
import '../../core/config.dart';
|
||||||
|
import '../../core/async_utils.dart';
|
||||||
import '../shared/providers.dart';
|
import '../shared/providers.dart';
|
||||||
|
|
||||||
class ImportScreen extends ConsumerStatefulWidget {
|
class ImportScreen extends ConsumerStatefulWidget {
|
||||||
@@ -18,6 +20,7 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
|
|||||||
TextEditingController(text: '1396, 1399'); // Breaking Bad, GoT
|
TextEditingController(text: '1396, 1399'); // Breaking Bad, GoT
|
||||||
String _log = '';
|
String _log = '';
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
|
bool _cancel = false;
|
||||||
|
|
||||||
void _append(String msg) => setState(() => _log += '$msg\n');
|
void _append(String msg) => setState(() => _log += '$msg\n');
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _importShows() async {
|
Future<void> _importShows() async {
|
||||||
setState(() => _busy = true);
|
setState(() { _busy = true; _cancel = false; });
|
||||||
final tmdb = ref.read(tmdbApiProvider);
|
final tmdb = ref.read(tmdbApiProvider);
|
||||||
final backend = ref.read(backendApiProvider);
|
final backend = ref.read(backendApiProvider);
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
|
|||||||
.map(int.parse);
|
.map(int.parse);
|
||||||
|
|
||||||
for (final showId in ids) {
|
for (final showId in ids) {
|
||||||
|
if (_cancel) break;
|
||||||
try {
|
try {
|
||||||
_append('Serie $showId: TMDB laden …');
|
_append('Serie $showId: TMDB laden …');
|
||||||
final showJson = await tmdb.getShow(showId);
|
final showJson = await tmdb.getShow(showId);
|
||||||
@@ -62,27 +66,51 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
|
|||||||
await backend.upsertShow(showJson);
|
await backend.upsertShow(showJson);
|
||||||
_append('Serie $showId: Show OK ✓');
|
_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)
|
.where((s) => (s['season_number'] ?? 0) is int)
|
||||||
.cast<Map<String, dynamic>>();
|
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
|
||||||
|
.where((n) => n >= 0)
|
||||||
|
.toList();
|
||||||
|
|
||||||
for (final s in seasons) {
|
// Fetch all seasons (limited concurrency)
|
||||||
final seasonNo = (s['season_number'] as num).toInt();
|
final seasonsJson = <int, Map<String, dynamic>>{};
|
||||||
if (seasonNo < 0) continue;
|
await runChunked<int>(
|
||||||
_append(' S$seasonNo: TMDB Season laden …');
|
seasonNos,
|
||||||
|
AppConfig.tmdbSeasonFetchConcurrency,
|
||||||
|
(sNo) async {
|
||||||
|
if (_cancel) return;
|
||||||
|
final sj = await tmdb.getSeason(showId, sNo);
|
||||||
|
seasonsJson[sNo] = sj;
|
||||||
|
},
|
||||||
|
isCancelled: () => _cancel,
|
||||||
|
);
|
||||||
|
|
||||||
final seasonJson = await tmdb.getSeason(showId, seasonNo);
|
final dbShowId = await _getDbShowIdByTmdb(backend, showId);
|
||||||
final dbShowId = await _getDbShowIdByTmdb(backend, showId);
|
// Bulk upsert seasons and get IDs
|
||||||
final dbSeasonId = await backend.upsertSeason(dbShowId, seasonJson);
|
Map<int,int> seasonIds = const {};
|
||||||
|
try {
|
||||||
|
seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList());
|
||||||
|
} catch (_) {
|
||||||
|
final m = <int,int>{};
|
||||||
|
for (final entry in seasonsJson.entries) { final id = await backend.upsertSeason(dbShowId, entry.value); m[entry.key] = id; }
|
||||||
|
seasonIds = m;
|
||||||
|
}
|
||||||
|
|
||||||
_append(' S$seasonNo: Season OK (db:$dbSeasonId)');
|
for (final entry in seasonsJson.entries) {
|
||||||
|
if (_cancel) break;
|
||||||
final eps = (seasonJson['episodes'] as List? ?? const [])
|
final sNo = entry.key; final seasonJson = entry.value; final dbSeasonId = seasonIds[sNo]; if (dbSeasonId == null) continue;
|
||||||
.cast<Map<String, dynamic>>();
|
_append(' S$sNo: upserting episodes …');
|
||||||
for (final e in eps) {
|
final eps = (seasonJson['episodes'] as List? ?? const []).cast<Map<String, dynamic>>();
|
||||||
await backend.upsertEpisode(dbSeasonId, e);
|
try { await backend.upsertEpisodesBulk(dbSeasonId, eps); }
|
||||||
|
catch (_) {
|
||||||
|
await runChunked<Map<String, dynamic>>(
|
||||||
|
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) {
|
} catch (e) {
|
||||||
// 👇 Hier kommt der erweiterte Catch hin!
|
// 👇 Hier kommt der erweiterte Catch hin!
|
||||||
@@ -139,6 +167,12 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
|
|||||||
onPressed: _busy ? null : _importShows,
|
onPressed: _busy ? null : _importShows,
|
||||||
child: const Text('Import Serien'),
|
child: const Text('Import Serien'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_busy)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() => _cancel = true),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import '../../../core/status.dart';
|
|||||||
import '../../shared/providers.dart';
|
import '../../shared/providers.dart';
|
||||||
import '../data/series_repository.dart';
|
import '../data/series_repository.dart';
|
||||||
import '../data/episode_model.dart';
|
import '../data/episode_model.dart';
|
||||||
|
import '../../../core/async_utils.dart';
|
||||||
|
import '../../../core/config.dart';
|
||||||
|
|
||||||
class SeriesDetailScreen extends ConsumerStatefulWidget {
|
class SeriesDetailScreen extends ConsumerStatefulWidget {
|
||||||
// showName is the grouping key (unique).
|
// showName is the grouping key (unique).
|
||||||
@@ -278,64 +280,126 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
try {
|
int done = 0;
|
||||||
final tmdb = ref.read(tmdbApiProvider);
|
int total = 0;
|
||||||
final backend = ref.read(backendApiProvider);
|
var cancel = false;
|
||||||
int? tid = _tmdbId;
|
await showDialog(
|
||||||
if (tid == null && _showId != null) {
|
context: context,
|
||||||
tid = await backend.getTmdbIdByShowId(_showId!);
|
barrierDismissible: false,
|
||||||
}
|
builder: (ctx) {
|
||||||
if (tid == null) {
|
return StatefulBuilder(builder: (ctx, setState) {
|
||||||
throw 'tmdb_id für diese Serie konnte nicht ermittelt werden';
|
Future<void> run() async {
|
||||||
}
|
try {
|
||||||
final showJson = await tmdb.getShow(tid);
|
final tmdb = ref.read(tmdbApiProvider);
|
||||||
// Update header meta immediately (overview/cast/crew)
|
final backend = ref.read(backendApiProvider);
|
||||||
try {
|
int? tid = _tmdbId;
|
||||||
setState(() {
|
if (tid == null && _showId != null) {
|
||||||
_overview = (showJson['overview'] as String?) ?? _overview;
|
tid = await backend.getTmdbIdByShowId(_showId!);
|
||||||
final credits = showJson['credits'] as Map<String, dynamic>?;
|
}
|
||||||
if (credits != null) {
|
if (tid == null) {
|
||||||
final c = (credits['cast'] as List? ?? const [])
|
throw 'tmdb_id für diese Serie konnte nicht ermittelt werden';
|
||||||
.cast()
|
}
|
||||||
.map((e) => Map<String, dynamic>.from(e as Map))
|
final int tidVal = tid; // non-null now
|
||||||
.toList();
|
final showJson = await tmdb.getShow(tidVal);
|
||||||
final cr = (credits['crew'] as List? ?? const [])
|
// Update header meta immediately (overview/cast/crew)
|
||||||
.cast()
|
try {
|
||||||
.map((e) => Map<String, dynamic>.from(e as Map))
|
if (mounted) {
|
||||||
.toList();
|
setState(() {
|
||||||
_cast = c.take(12).toList();
|
_overview = (showJson['overview'] as String?) ?? _overview;
|
||||||
_crew = cr.take(12).toList();
|
final credits = showJson['credits'] as Map<String, dynamic>?;
|
||||||
|
if (credits != null) {
|
||||||
|
final c = (credits['cast'] as List? ?? const [])
|
||||||
|
.cast()
|
||||||
|
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||||
|
.toList();
|
||||||
|
final cr = (credits['crew'] as List? ?? const [])
|
||||||
|
.cast()
|
||||||
|
.map((e) => Map<String, dynamic>.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<String, dynamic>)['season_number'] as int)
|
||||||
|
.where((s) => s >= 0)
|
||||||
|
.toList();
|
||||||
|
final seasonsJson = <int, Map<String, dynamic>>{};
|
||||||
|
await runChunked<int>(
|
||||||
|
sNos,
|
||||||
|
AppConfig.tmdbSeasonFetchConcurrency,
|
||||||
|
(sNo) async { if (cancel) return; final sj = await tmdb.getSeason(tidVal, sNo); seasonsJson[sNo] = sj; },
|
||||||
|
isCancelled: () => cancel,
|
||||||
|
);
|
||||||
|
Map<int,int> seasonIds = const {};
|
||||||
|
try {
|
||||||
|
seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList());
|
||||||
|
} catch (_) {
|
||||||
|
final m = <int,int>{};
|
||||||
|
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<Map<String, dynamic>>();
|
||||||
|
try { await backend.upsertEpisodesBulk(seasonId, eps); done += eps.length; if (ctx.mounted) setState(() {}); }
|
||||||
|
catch (_) {
|
||||||
|
await runChunked<Map<String, dynamic>>(
|
||||||
|
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<String, dynamic>)['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<Map<String, dynamic>>();
|
|
||||||
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)
|
if (dirty)
|
||||||
|
|||||||
@@ -13,12 +13,29 @@ import '../../shared/providers.dart';
|
|||||||
import '../data/episode_model.dart';
|
import '../data/episode_model.dart';
|
||||||
import 'widgets/season_status_bar.dart';
|
import 'widgets/season_status_bar.dart';
|
||||||
import '../../../core/io_open.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});
|
const SeriesListScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<SeriesListScreen> createState() => _SeriesListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
|
||||||
|
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 filter = ref.watch(episodeFilterProvider);
|
||||||
final groupedAsync = ref.watch(seriesGroupedProvider);
|
final groupedAsync = ref.watch(seriesGroupedProvider);
|
||||||
final summaryAsync = ref.watch(seriesSummaryProvider);
|
final summaryAsync = ref.watch(seriesSummaryProvider);
|
||||||
@@ -60,6 +77,7 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
String current = '';
|
String current = '';
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
int total = 0;
|
int total = 0;
|
||||||
|
var cancel = false;
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -88,46 +106,78 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
final t = (m['id'] as num?)?.toInt();
|
final t = (m['id'] as num?)?.toInt();
|
||||||
if (t != null) {
|
if (t != null) {
|
||||||
showRows.add(
|
showRows.add(
|
||||||
{'tmdb_id': t, 'name': entry.key});
|
{'tmdb_id': t, 'name': entry.key.split('##').first});
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} 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;
|
total = showRows.length;
|
||||||
int success = 0;
|
int success = 0;
|
||||||
for (final row in showRows) {
|
for (final row in showRows) {
|
||||||
|
if (cancel) break;
|
||||||
idx++;
|
idx++;
|
||||||
final tmdbId =
|
final tmdbId =
|
||||||
(row['tmdb_id'] as num?)?.toInt();
|
(row['tmdb_id'] as num?)?.toInt();
|
||||||
current = (row['name'] as String?) ??
|
current = (row['name'] as String?) ?? 'tmdb:$tmdbId';
|
||||||
'tmdb:$tmdbId';
|
if (ctx.mounted) setState(() {});
|
||||||
setState(() {});
|
|
||||||
if (tmdbId == null) continue;
|
if (tmdbId == null) continue;
|
||||||
try {
|
try {
|
||||||
final showJson = await tmdb.getShow(tmdbId);
|
final showJson = await tmdb.getShow(tmdbId);
|
||||||
final dbShowId =
|
final dbShowId =
|
||||||
await backend.upsertShow(showJson);
|
await backend.upsertShow(showJson);
|
||||||
final seasons = (showJson['seasons']
|
final sNos = (showJson['seasons'] as List? ?? const [])
|
||||||
as List? ??
|
.where((s) => (s['season_number'] ?? -1) is num)
|
||||||
const [])
|
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
|
||||||
.where((s) =>
|
.where((s) => s >= 0)
|
||||||
(s['season_number'] ?? -1) is num)
|
|
||||||
.map((s) => (s as Map<String, dynamic>)[
|
|
||||||
'season_number'] as int)
|
|
||||||
.toList();
|
.toList();
|
||||||
for (final sNo in seasons) {
|
// Fetch all seasons (limited concurrency)
|
||||||
if (sNo < 0) continue;
|
final seasonsJson = <int, Map<String, dynamic>>{};
|
||||||
final seasonJson =
|
await runChunked<int>(
|
||||||
await tmdb.getSeason(tmdbId, sNo);
|
sNos,
|
||||||
final seasonId = await backend
|
AppConfig.tmdbSeasonFetchConcurrency,
|
||||||
.upsertSeason(dbShowId, seasonJson);
|
(sNo) async {
|
||||||
final eps =
|
if (cancel) return;
|
||||||
(seasonJson['episodes'] as List? ??
|
final sj = await tmdb.getSeason(tmdbId, sNo);
|
||||||
const [])
|
seasonsJson[sNo] = sj;
|
||||||
.cast<Map<String, dynamic>>();
|
},
|
||||||
for (final e in eps) {
|
isCancelled: () => cancel,
|
||||||
await backend.upsertEpisode(
|
);
|
||||||
seasonId, e);
|
// Bulk upsert seasons and get IDs
|
||||||
|
Map<int,int> seasonIds = const {};
|
||||||
|
try {
|
||||||
|
seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList());
|
||||||
|
} catch (_) {
|
||||||
|
// Fallback per season
|
||||||
|
final m = <int,int>{};
|
||||||
|
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<Map<String, dynamic>>();
|
||||||
|
try { await backend.upsertEpisodesBulk(seasonId, eps); }
|
||||||
|
catch (_) {
|
||||||
|
await runChunked<Map<String, dynamic>>(
|
||||||
|
eps,
|
||||||
|
AppConfig.dbEpisodeUpsertConcurrency,
|
||||||
|
(e) async { if (cancel) return; await backend.upsertEpisode(seasonId, e); },
|
||||||
|
isCancelled: () => cancel,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
success++;
|
success++;
|
||||||
@@ -135,6 +185,8 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
// ignore: unused_result
|
// ignore: unused_result
|
||||||
ref.invalidate(seriesGroupedProvider);
|
ref.invalidate(seriesGroupedProvider);
|
||||||
|
// ignore: unused_result
|
||||||
|
ref.invalidate(seriesSummaryProvider);
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
@@ -166,6 +218,14 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
value: total > 0 ? idx / total : null),
|
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>[
|
final cells = <DataCell>[
|
||||||
DataCell(
|
DataCell(
|
||||||
MenuAnchor(
|
MenuAnchor(
|
||||||
menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
|
menuChildren: [...widget._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
child: const Text('set all progress to done'),
|
child: const Text('set all progress to done'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -365,37 +425,101 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID')));
|
messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
String current = displayName;
|
||||||
final tmdb = ref.read(tmdbApiProvider);
|
int done = 0;
|
||||||
final backend = ref.read(backendApiProvider);
|
int total = 0;
|
||||||
final tid = await backend.getTmdbIdByShowId(sid);
|
var cancel = false;
|
||||||
if (tid == null) {
|
await showDialog(
|
||||||
messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden')));
|
context: context,
|
||||||
return;
|
barrierDismissible: false,
|
||||||
}
|
builder: (ctx) {
|
||||||
final showJson = await tmdb.getShow(tid);
|
return StatefulBuilder(builder: (ctx, setState) {
|
||||||
final dbShowId = await backend.upsertShow(showJson);
|
Future<void> run() async {
|
||||||
final seasons = (showJson['seasons'] as List? ?? const [])
|
try {
|
||||||
.where((s) => (s['season_number'] ?? -1) is num)
|
final tmdb = ref.read(tmdbApiProvider);
|
||||||
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
|
final backend = ref.read(backendApiProvider);
|
||||||
.toList();
|
final tid = await backend.getTmdbIdByShowId(sid);
|
||||||
for (final sNo in seasons) {
|
if (tid == null) { messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden'))); return; }
|
||||||
if (sNo < 0) continue;
|
final showJson = await tmdb.getShow(tid);
|
||||||
final seasonJson = await tmdb.getSeason(tid, sNo);
|
final dbShowId = await backend.upsertShow(showJson);
|
||||||
final seasonId = await backend.upsertSeason(dbShowId, seasonJson);
|
final sNos = (showJson['seasons'] as List? ?? const [])
|
||||||
final eps = (seasonJson['episodes'] as List? ?? const []).cast<Map<String, dynamic>>();
|
.where((s) => (s['season_number'] ?? -1) is num)
|
||||||
for (final e in eps) {
|
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
|
||||||
await backend.upsertEpisode(seasonId, e);
|
.where((s) => s >= 0)
|
||||||
}
|
.toList();
|
||||||
}
|
final seasonsJson = <int, Map<String, dynamic>>{};
|
||||||
// ignore: unused_result
|
await runChunked<int>(
|
||||||
ref.invalidate(seriesGroupedProvider);
|
sNos,
|
||||||
// ignore: unused_result
|
AppConfig.tmdbSeasonFetchConcurrency,
|
||||||
ref.invalidate(seriesSummaryProvider);
|
(sNo) async {
|
||||||
messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen')));
|
if (cancel) return;
|
||||||
} catch (e) {
|
final sj = await tmdb.getSeason(tid, sNo);
|
||||||
messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e')));
|
seasonsJson[sNo] = sj;
|
||||||
}
|
},
|
||||||
|
isCancelled: () => cancel,
|
||||||
|
);
|
||||||
|
Map<int,int> seasonIds = const {};
|
||||||
|
try {
|
||||||
|
seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList());
|
||||||
|
} catch (_) {
|
||||||
|
final m = <int,int>{};
|
||||||
|
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<Map<String, dynamic>>();
|
||||||
|
try { await backend.upsertEpisodesBulk(seasonId, eps); done += eps.length; if (ctx.mounted) setState(() {}); }
|
||||||
|
catch (_) {
|
||||||
|
await runChunked<Map<String, dynamic>>(
|
||||||
|
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(
|
MenuItemButton(
|
||||||
@@ -495,7 +619,7 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
if (eps == null || eps.isEmpty) {
|
if (eps == null || eps.isEmpty) {
|
||||||
cells.add(DataCell(
|
cells.add(DataCell(
|
||||||
MenuAnchor(
|
MenuAnchor(
|
||||||
menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
|
menuChildren: [...widget._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
child: const Text('set all progress to done'),
|
child: const Text('set all progress to done'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -587,7 +711,7 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
cells.add(DataCell(
|
cells.add(DataCell(
|
||||||
MenuAnchor(
|
MenuAnchor(
|
||||||
menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
|
menuChildren: [...widget._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
child: const Text('set all progress to done'),
|
child: const Text('set all progress to done'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -687,7 +811,21 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
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<Map<String, dynamic>> items) {
|
BuildContext context, WidgetRef ref, List<Map<String, dynamic>> items) {
|
||||||
final filter = ref.watch(episodeFilterProvider);
|
final filter = ref.watch(episodeFilterProvider);
|
||||||
|
|
||||||
bool _includeByFilter(Map<String, dynamic> m) {
|
bool includeByFilter(Map<String, dynamic> m) {
|
||||||
int initTotal = 0;
|
int initTotal = 0;
|
||||||
int progTotal = 0;
|
int progTotal = 0;
|
||||||
int doneTotal = 0;
|
int doneTotal = 0;
|
||||||
@@ -766,7 +904,7 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply series-level filter
|
// Apply series-level filter
|
||||||
items = items.where(_includeByFilter).toList();
|
items = items.where(includeByFilter).toList();
|
||||||
// Build and sort keys
|
// Build and sort keys
|
||||||
final keys = <String>[];
|
final keys = <String>[];
|
||||||
final byKey = <String, Map<String, dynamic>>{};
|
final byKey = <String, Map<String, dynamic>>{};
|
||||||
@@ -969,7 +1107,7 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Left (with context menu)
|
// Left (with context menu)
|
||||||
MenuAnchor(
|
MenuAnchor(
|
||||||
menuChildren: _seriesMenuItems(context, ref, rowKey, name, downloadPath),
|
menuChildren: widget._seriesMenuItems(context, ref, rowKey, name, downloadPath),
|
||||||
builder: (ctx, controller, child) => GestureDetector(
|
builder: (ctx, controller, child) => GestureDetector(
|
||||||
onSecondaryTapDown: (_) => controller.open(),
|
onSecondaryTapDown: (_) => controller.open(),
|
||||||
onSecondaryTapUp: (_) => controller.open(),
|
onSecondaryTapUp: (_) => controller.open(),
|
||||||
@@ -1059,7 +1197,7 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (eps.isNotEmpty) {
|
if (eps.isNotEmpty) {
|
||||||
return MenuAnchor(
|
return MenuAnchor(
|
||||||
menuChildren: [..._seriesMenuItems(context, ref, rowKey, name, downloadPath),
|
menuChildren: [...widget._seriesMenuItems(context, ref, rowKey, name, downloadPath),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
@@ -1108,7 +1246,7 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (match.isEmpty) {
|
if (match.isEmpty) {
|
||||||
return MenuAnchor(
|
return MenuAnchor(
|
||||||
menuChildren: [..._seriesMenuItems(context, ref, rowKey, name, downloadPath),
|
menuChildren: [...widget._seriesMenuItems(context, ref, rowKey, name, downloadPath),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
@@ -1195,29 +1333,38 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headerHeight = 32.0;
|
const headerHeight = 32.0;
|
||||||
return Scrollbar(
|
return LayoutBuilder(
|
||||||
thumbVisibility: true,
|
builder: (context, constraints) {
|
||||||
child: LayoutBuilder(
|
return SingleChildScrollView(
|
||||||
builder: (context, constraints) {
|
scrollDirection: Axis.horizontal,
|
||||||
return SingleChildScrollView(
|
child: widgets.SizedBox(
|
||||||
scrollDirection: Axis.horizontal,
|
width: tableWidth,
|
||||||
child: widgets.SizedBox(
|
height: constraints.maxHeight,
|
||||||
width: tableWidth,
|
child: Stack(
|
||||||
height: constraints.maxHeight,
|
children: [
|
||||||
child: Stack(
|
// Sticky header at the top; scrolls horizontally with content
|
||||||
children: [
|
Positioned(
|
||||||
// Sticky header at the top; scrolls horizontally with content
|
top: 0,
|
||||||
Positioned(
|
left: 0,
|
||||||
top: 0,
|
right: 0,
|
||||||
left: 0,
|
child:
|
||||||
right: 0,
|
widgets.SizedBox(height: headerHeight, child: buildHeader()),
|
||||||
child: widgets.SizedBox(
|
),
|
||||||
height: headerHeight, child: buildHeader()),
|
// Vertical list below the header with its own scrollbar
|
||||||
),
|
Positioned.fill(
|
||||||
// Vertical list below the header
|
top: headerHeight,
|
||||||
Positioned.fill(
|
child: RawScrollbar(
|
||||||
top: headerHeight,
|
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(
|
child: ListView.separated(
|
||||||
|
controller: _summaryVController,
|
||||||
itemCount: keys.length,
|
itemCount: keys.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (_, i) => widgets.SizedBox(
|
itemBuilder: (_, i) => widgets.SizedBox(
|
||||||
@@ -1226,12 +1373,12 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+131
-1
@@ -47,7 +47,7 @@ function inputBody(): array {
|
|||||||
$in = $_POST ?: [];
|
$in = $_POST ?: [];
|
||||||
}
|
}
|
||||||
// Normalize: decode JSON strings for nested payloads (e.g., tmdb)
|
// 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])) {
|
if (isset($in[$k]) && is_string($in[$k])) {
|
||||||
$d = json_decode($in[$k], true);
|
$d = json_decode($in[$k], true);
|
||||||
if (is_array($d)) $in[$k] = $d;
|
if (is_array($d)) $in[$k] = $d;
|
||||||
@@ -191,6 +191,136 @@ try {
|
|||||||
}
|
}
|
||||||
resp(['ok'=>true,'id'=>(int)$id]);
|
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': {
|
case 'upsert_movie': {
|
||||||
$tmdb = $in['tmdb'] ?? null; if (!$tmdb || !isset($tmdb['id'])) fail('missing tmdb payload');
|
$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)
|
$stmt = $pdo->prepare("INSERT INTO movies (tmdb_id, title, original_title, release_year, poster_path, backdrop_path, runtime, json)
|
||||||
|
|||||||
Reference in New Issue
Block a user