Add Serie
This commit is contained in:
@@ -56,6 +56,26 @@ class TmdbApi {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> searchShows(String query, {int page = 1}) async {
|
||||||
|
final res = await _dio.get(
|
||||||
|
'/search/tv',
|
||||||
|
queryParameters: {
|
||||||
|
..._auth,
|
||||||
|
'query': query,
|
||||||
|
'page': page,
|
||||||
|
'include_adult': false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw Exception('TMDB searchShows failed: ${res.statusCode} ${res.data}');
|
||||||
|
}
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
final results = (data['results'] as List? ?? const [])
|
||||||
|
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||||
|
.toList();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getShow(int id) async {
|
Future<Map<String, dynamic>> getShow(int id) async {
|
||||||
final res = await _dio.get(
|
final res = await _dio.get(
|
||||||
'/tv/$id',
|
'/tv/$id',
|
||||||
|
|||||||
@@ -19,14 +19,16 @@ final seriesGroupedProvider =
|
|||||||
|
|
||||||
class SeriesGroupedData {
|
class SeriesGroupedData {
|
||||||
final Map<String, Map<int, List<EpisodeItem>>>
|
final Map<String, Map<int, List<EpisodeItem>>>
|
||||||
data; // showName -> season -> episodes
|
data; // groupKey -> season -> episodes (groupKey is unique per show)
|
||||||
SeriesGroupedData(this.data);
|
SeriesGroupedData(this.data);
|
||||||
|
|
||||||
factory SeriesGroupedData.fromEpisodes(List<EpisodeItem> items) {
|
factory SeriesGroupedData.fromEpisodes(List<EpisodeItem> items) {
|
||||||
final map = <String, Map<int, List<EpisodeItem>>>{};
|
final map = <String, Map<int, List<EpisodeItem>>>{};
|
||||||
for (final e in items) {
|
for (final e in items) {
|
||||||
final bySeason =
|
final key = e.showId != null
|
||||||
map.putIfAbsent(e.showName, () => <int, List<EpisodeItem>>{});
|
? '${e.showName}##${e.showId}'
|
||||||
|
: (e.firstAirYear != null ? '${e.showName} (${e.firstAirYear})' : e.showName);
|
||||||
|
final bySeason = map.putIfAbsent(key, () => <int, List<EpisodeItem>>{});
|
||||||
final list = bySeason.putIfAbsent(e.seasonNumber, () => <EpisodeItem>[]);
|
final list = bySeason.putIfAbsent(e.seasonNumber, () => <EpisodeItem>[]);
|
||||||
list.add(e);
|
list.add(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../shared/providers.dart';
|
||||||
|
import '../data/series_repository.dart';
|
||||||
|
|
||||||
|
class SeriesAddScreen extends ConsumerStatefulWidget {
|
||||||
|
const SeriesAddScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SeriesAddScreen> createState() => _SeriesAddScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SeriesAddScreenState extends ConsumerState<SeriesAddScreen> {
|
||||||
|
final _queryCtrl = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
List<Map<String, dynamic>> _results = const [];
|
||||||
|
final Set<int> _selected = {};
|
||||||
|
Set<int> _existing = {};
|
||||||
|
|
||||||
|
Future<void> _loadExisting() async {
|
||||||
|
// Query backend for existing shows to filter out (tmdb_id)
|
||||||
|
final backend = ref.read(backendApiProvider);
|
||||||
|
final rows = await backend.listShows();
|
||||||
|
setState(() {
|
||||||
|
_existing = rows
|
||||||
|
.map((m) => (m['tmdb_id'] as num?))
|
||||||
|
.whereType<num>()
|
||||||
|
.map((n) => n.toInt())
|
||||||
|
.toSet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _search() async {
|
||||||
|
final q = _queryCtrl.text.trim();
|
||||||
|
if (q.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_results = const [];
|
||||||
|
_selected.clear();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await _loadExisting();
|
||||||
|
final tmdb = ref.read(tmdbApiProvider);
|
||||||
|
final items = await tmdb.searchShows(q);
|
||||||
|
final filtered = items
|
||||||
|
.where((m) => !_existing.contains((m['id'] as num).toInt()))
|
||||||
|
.toList();
|
||||||
|
setState(() => _results = filtered);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Suche fehlgeschlagen: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addSelected() async {
|
||||||
|
if (_selected.isEmpty) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
final tmdb = ref.read(tmdbApiProvider);
|
||||||
|
final backend = ref.read(backendApiProvider);
|
||||||
|
int ok = 0;
|
||||||
|
final ids = List<int>.from(_selected);
|
||||||
|
for (final id in ids) {
|
||||||
|
try {
|
||||||
|
final showJson = await tmdb.getShow(id);
|
||||||
|
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) {
|
||||||
|
// Include all seasons; frontend handles hiding S0
|
||||||
|
final seasonJson = await tmdb.getSeason(id, 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++;
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
// Refresh series list/table
|
||||||
|
// ignore: unused_result
|
||||||
|
ref.invalidate(seriesGroupedProvider);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('$ok Serie(n) hinzugefügt')));
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
messenger
|
||||||
|
.showSnackBar(SnackBar(content: Text('Hinzufügen fehlgeschlagen: $e')));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Neue Serien hinzufügen (TMDB)'),
|
||||||
|
actions: [
|
||||||
|
if (_selected.isNotEmpty)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _loading ? null : _addSelected,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text('Hinzufügen (${_selected.length})'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _queryCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Serien suchen…',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _search(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _loading ? null : _search,
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
label: const Text('Suchen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_loading) const LinearProgressIndicator(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _results.isEmpty
|
||||||
|
? const Center(child: Text('Keine Ergebnisse'))
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: _results.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final m = _results[i];
|
||||||
|
final id = (m['id'] as num).toInt();
|
||||||
|
final title = (m['name'] ?? m['original_name'] ?? '') as String;
|
||||||
|
final firstAir = (m['first_air_date'] as String?) ?? '';
|
||||||
|
final year = firstAir.length >= 4 ? firstAir.substring(0, 4) : '';
|
||||||
|
final poster = m['poster_path'] as String?;
|
||||||
|
final sel = _selected.contains(id);
|
||||||
|
return ListTile(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
if (sel) {
|
||||||
|
_selected.remove(id);
|
||||||
|
} else {
|
||||||
|
_selected.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
leading: poster != null
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: 'https://image.tmdb.org/t/p/w154$poster',
|
||||||
|
width: 50,
|
||||||
|
height: 75,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox(width: 50, height: 75),
|
||||||
|
title:
|
||||||
|
Text(year.isNotEmpty ? '$title ($year)' : title),
|
||||||
|
trailing: Checkbox(
|
||||||
|
value: sel,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
if (v == true) {
|
||||||
|
_selected.add(id);
|
||||||
|
} else {
|
||||||
|
_selected.remove(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,13 +9,17 @@ import '../data/series_repository.dart';
|
|||||||
import '../data/episode_model.dart';
|
import '../data/episode_model.dart';
|
||||||
|
|
||||||
class SeriesDetailScreen extends ConsumerStatefulWidget {
|
class SeriesDetailScreen extends ConsumerStatefulWidget {
|
||||||
|
// showName is the grouping key (unique).
|
||||||
final String showName;
|
final String showName;
|
||||||
|
// displayName is the human title used in UI.
|
||||||
|
final String? displayName;
|
||||||
final int? year;
|
final int? year;
|
||||||
final String? resolution;
|
final String? resolution;
|
||||||
final String? posterPath;
|
final String? posterPath;
|
||||||
const SeriesDetailScreen({
|
const SeriesDetailScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.showName,
|
required this.showName,
|
||||||
|
this.displayName,
|
||||||
this.year,
|
this.year,
|
||||||
this.resolution,
|
this.resolution,
|
||||||
this.posterPath,
|
this.posterPath,
|
||||||
@@ -237,7 +241,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text(widget.showName),
|
title: Text(widget.displayName ?? widget.showName),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'TMDB aktualisieren',
|
tooltip: 'TMDB aktualisieren',
|
||||||
@@ -416,7 +420,9 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.year != null ? '${widget.showName} (${widget.year})' : widget.showName,
|
widget.year != null
|
||||||
|
? '${(widget.displayName ?? widget.showName)} (${widget.year})'
|
||||||
|
: (widget.displayName ?? widget.showName),
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Colors.white),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -628,7 +634,24 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _resolutionBadge(String? res, BuildContext context) {
|
Widget _resolutionBadge(String? res, BuildContext context) {
|
||||||
if (res == null || res.isEmpty) return const SizedBox.shrink();
|
if (res == null || res.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.45),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.6)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.hd_outlined, size: 16, color: Colors.white70),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text('Auflösung wählen', style: TextStyle(fontSize: 13, color: Colors.white70)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
final m = RegExp(r"\d+").firstMatch(res);
|
final m = RegExp(r"\d+").firstMatch(res);
|
||||||
final v = m != null ? int.tryParse(m.group(0)!) : null;
|
final v = m != null ? int.tryParse(m.group(0)!) : null;
|
||||||
IconData? icon;
|
IconData? icon;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../movies/presentation/widgets/status_chip.dart';
|
|||||||
import '../data/series_repository.dart';
|
import '../data/series_repository.dart';
|
||||||
import 'widgets/episode_status_strip.dart';
|
import 'widgets/episode_status_strip.dart';
|
||||||
import 'series_detail_screen.dart';
|
import 'series_detail_screen.dart';
|
||||||
|
import 'series_add_screen.dart';
|
||||||
import '../../shared/providers.dart';
|
import '../../shared/providers.dart';
|
||||||
|
|
||||||
class SeriesListScreen extends ConsumerWidget {
|
class SeriesListScreen extends ConsumerWidget {
|
||||||
@@ -31,10 +32,25 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: TextButton.icon(
|
child: Row(
|
||||||
icon: const Icon(Icons.sync),
|
mainAxisSize: MainAxisSize.min,
|
||||||
label: const Text('TMDB: alle Serien updaten'),
|
children: [
|
||||||
onPressed: () async {
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Neue Serien hinzufügen'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const SeriesAddScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.sync),
|
||||||
|
label: const Text('TMDB: alle Serien updaten'),
|
||||||
|
onPressed: () async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
String current = '';
|
String current = '';
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
@@ -128,7 +144,9 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -239,8 +257,18 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Fehler: $e')),
|
error: (e, _) => Center(child: Text('Fehler: $e')),
|
||||||
data: (data) {
|
data: (data) {
|
||||||
final shows = data.data.keys.toList()..sort();
|
final keys = data.data.keys.toList();
|
||||||
if (shows.isEmpty) {
|
String displayOf(String key) {
|
||||||
|
final bySeason = data.data[key];
|
||||||
|
if (bySeason == null || bySeason.isEmpty) return key;
|
||||||
|
// take the first available episode to read display name
|
||||||
|
for (final list in bySeason.values) {
|
||||||
|
if (list.isNotEmpty) return list.first.showName;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
keys.sort((a, b) => displayOf(a).toLowerCase().compareTo(displayOf(b).toLowerCase()));
|
||||||
|
if (keys.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text('Keine Episoden gefunden.'));
|
child: Text('Keine Episoden gefunden.'));
|
||||||
}
|
}
|
||||||
@@ -258,14 +286,15 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
List<DataRow> buildRows() {
|
List<DataRow> buildRows() {
|
||||||
final rows = <DataRow>[];
|
final rows = <DataRow>[];
|
||||||
for (final showName in shows) {
|
for (final key in keys) {
|
||||||
final bySeason = data.data[showName]!;
|
final bySeason = data.data[key]!;
|
||||||
int? year;
|
int? year;
|
||||||
String? resolution;
|
String? resolution;
|
||||||
String? showJson;
|
String? showJson;
|
||||||
String? showStatus;
|
String? showStatus;
|
||||||
bool? showCliffhanger;
|
bool? showCliffhanger;
|
||||||
String? posterPath;
|
String? posterPath;
|
||||||
|
String displayName = displayOf(key);
|
||||||
final sortedSeasons = bySeason.keys.toList()..sort();
|
final sortedSeasons = bySeason.keys.toList()..sort();
|
||||||
for (final s in sortedSeasons) {
|
for (final s in sortedSeasons) {
|
||||||
final eps = bySeason[s]!;
|
final eps = bySeason[s]!;
|
||||||
@@ -341,7 +370,7 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_seriesTitle(showName, year, showStatus, showCliffhanger, context),
|
_seriesTitle(displayName, year, showStatus, showCliffhanger, context),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_resolutionInline(resolution, context),
|
_resolutionInline(resolution, context),
|
||||||
],
|
],
|
||||||
@@ -373,7 +402,8 @@ class SeriesListScreen extends ConsumerWidget {
|
|||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => SeriesDetailScreen(
|
builder: (_) => SeriesDetailScreen(
|
||||||
showName: showName,
|
showName: key,
|
||||||
|
displayName: displayName,
|
||||||
year: year,
|
year: year,
|
||||||
resolution: resolution,
|
resolution: resolution,
|
||||||
posterPath: posterPath,
|
posterPath: posterPath,
|
||||||
|
|||||||
Reference in New Issue
Block a user