Film / Serie Update

This commit is contained in:
2025-10-30 20:48:22 +01:00
parent 6ec3ad363c
commit dd21a3ed71
6 changed files with 456 additions and 10 deletions
+14 -2
View File
@@ -161,8 +161,9 @@ class BackendApi {
return (map['items'] as List).cast<Map<String, dynamic>>();
}
Future<void> upsertShow(Map<String, dynamic> tmdbJson) async {
await _post({'action': 'upsert_show', 'tmdb': tmdbJson});
Future<int> upsertShow(Map<String, dynamic> tmdbJson) async {
final map = await _post({'action': 'upsert_show', 'tmdb': tmdbJson});
return (map['id'] as num).toInt();
}
Future<int> upsertSeason(int showId, Map<String, dynamic> seasonJson) async {
@@ -187,6 +188,17 @@ class BackendApi {
return v == null ? null : (v as num).toInt();
}
Future<List<Map<String, dynamic>>> listShows() async {
final map = await _post({'action': 'list_shows'});
return (map['items'] as List).cast<Map<String, dynamic>>();
}
Future<int?> 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<void> setShowMeta({
required int showId,
String? resolution,
@@ -90,6 +90,35 @@ class _MovieDetailScreenState extends ConsumerState<MovieDetailScreen> {
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(
@@ -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<void> 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 {
);
}
}
@@ -40,6 +40,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
String? _overview;
List<Map<String, dynamic>> _cast = const [];
List<Map<String, dynamic>> _crew = const [];
int? _tmdbId;
@override
void initState() {
@@ -76,10 +77,11 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
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<String, dynamic>;
_tmdbId = (m['id'] as num?)?.toInt() ?? _tmdbId;
_overview = (m['overview'] as String?) ?? _overview;
final credits = m['credits'] as Map<String, dynamic>?;
if (credits != null) {
@@ -237,6 +239,76 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
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<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 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();
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),
@@ -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<void> run() async {
try {
final tmdb = ref.read(tmdbApiProvider);
final backend = ref.read(backendApiProvider);
List<Map<String, dynamic>> 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<String, dynamic>)['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<Map<String, dynamic>>();
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<List<int>>(
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 = <int>[];
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<void> 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<String, dynamic>)['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<Map<String, dynamic>>();
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(
+68 -6
View File
@@ -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': {