Speed up Movies

This commit is contained in:
2025-11-04 13:07:46 +01:00
parent 4d1f4b484a
commit ffe0af7932
8 changed files with 799 additions and 142 deletions
+10
View File
@@ -214,5 +214,15 @@ class BackendApi {
};
await _post(payload);
}
Future<List<Map<String, dynamic>>> getSeriesSummary() async {
final map = await _post({'action': 'get_series_summary'});
return (map['items'] as List).cast<Map<String, dynamic>>();
}
Future<List<Map<String, dynamic>>> getShowEpisodes(int showId) async {
final map = await _post({'action': 'get_show_episodes', 'show_id': showId});
return (map['items'] as List).cast<Map<String, dynamic>>();
}
}
@@ -20,3 +20,24 @@ final moviesProvider = FutureProvider.autoDispose<List<Movie>>((ref) async {
}
return all.map(Movie.fromJson).toList();
});
// Streaming variant for faster perceived load: yields pages as they arrive
final moviesStreamProvider = StreamProvider.autoDispose<List<Movie>>((ref) async* {
final backend = ref.watch(backendApiProvider);
final st = ref.watch(movieFilterProvider);
const pageSize = 300;
var offset = 0;
var agg = <Movie>[];
while (true) {
final page = await backend.getMovies(status: st?.name, offset: offset, limit: pageSize);
final mapped = page.map(Movie.fromJson).toList();
if (mapped.isEmpty) {
if (agg.isEmpty) yield const <Movie>[];
break;
}
agg = [...agg, ...mapped];
yield agg;
if (mapped.length < pageSize) break;
offset += pageSize;
}
});
@@ -14,7 +14,7 @@ class MovieListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final filter = ref.watch(movieFilterProvider);
final moviesAsync = ref.watch(moviesProvider);
final moviesAsync = ref.watch(moviesStreamProvider);
return Padding(
padding: const EdgeInsets.all(12.0),
@@ -75,6 +75,7 @@ class MovieListScreen extends ConsumerWidget {
}
// Refresh list after update
// ignore: unused_result
ref.invalidate(moviesStreamProvider);
ref.invalidate(moviesProvider);
messenger.showSnackBar(SnackBar(content: Text('TMDB Update fertig: $ok/$total Filme')));
} finally {
@@ -114,7 +115,13 @@ class MovieListScreen extends ConsumerWidget {
final itemsSorted = [...items]
..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
return RefreshIndicator(
onRefresh: () async => ref.invalidate(moviesProvider),
onRefresh: () async {
// ignore: unused_result
ref.invalidate(moviesStreamProvider);
// also invalidate fallback provider used elsewhere
// ignore: unused_result
ref.invalidate(moviesProvider);
},
child: ListView.separated(
itemCount: itemsSorted.length,
separatorBuilder: (_, __) => const Divider(height: 1),
@@ -9,14 +9,61 @@ final seriesGroupedProvider =
FutureProvider.autoDispose<SeriesGroupedData>((ref) async {
final backend = ref.watch(backendApiProvider);
final st = ref.watch(episodeFilterProvider);
final rows = await backend.getEpisodes(status: st?.name, limit: 5000);
final items = rows
.map(EpisodeItem.fromJson)
.where((e) => e.seasonNumber > 0)
.toList();
int pageSize = 1000;
var offset = 0;
final items = <EpisodeItem>[];
final seenIds = <int>{};
int safetyPages = 0;
int consecutiveFailures = 0;
while (true) {
List<Map<String, dynamic>> page;
try {
page = await backend.getEpisodes(
status: st?.name,
offset: offset,
limit: pageSize,
);
} catch (e) {
// Adaptive fallback: try smaller pages on server errors
if (pageSize > 200 && consecutiveFailures < 5) {
pageSize = (pageSize / 2).round().clamp(200, 2000);
consecutiveFailures++;
continue; // retry same offset with smaller page
}
// If we already have some data, return partial results to unblock UI
if (items.isNotEmpty) break; else rethrow;
}
consecutiveFailures = 0;
if (page.isEmpty) break;
var newCount = 0;
for (final j in page) {
final ep = EpisodeItem.fromJson(j);
if (ep.seasonNumber <= 0) continue;
if (seenIds.add(ep.id)) {
items.add(ep);
newCount++;
}
}
if (page.length < pageSize) break;
// Safety: if server ignores offset and repeats pages, stop when nothing new is added
if (newCount == 0) break;
offset += page.length;
if (++safetyPages > 500) break; // cap to avoid a runaway loop on huge libraries
}
return SeriesGroupedData.fromEpisodes(items);
});
// Lightweight summary provider: uses backend aggregation instead of fetching all episodes.
final seriesSummaryProvider = FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
final backend = ref.watch(backendApiProvider);
try {
final items = await backend.getSeriesSummary();
return items;
} catch (_) {
return const [];
}
});
class SeriesGroupedData {
final Map<String, Map<int, List<EpisodeItem>>>
data; // groupKey -> season -> episodes (groupKey is unique per show)
@@ -13,6 +13,8 @@ class SeriesDetailScreen extends ConsumerStatefulWidget {
final String showName;
// displayName is the human title used in UI.
final String? displayName;
final int? showId;
final String? seasonsEps; // compact per-season episodes string from summary
final int? year;
final String? resolution;
final String? posterPath;
@@ -20,6 +22,8 @@ class SeriesDetailScreen extends ConsumerStatefulWidget {
super.key,
required this.showName,
this.displayName,
this.showId,
this.seasonsEps,
this.year,
this.resolution,
this.posterPath,
@@ -57,62 +61,99 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
setState(() => _downloadPath = v);
}
});
// Quick-fill from seasonsEps for instant UI while fetching real data
if (widget.seasonsEps != null && widget.seasonsEps!.isNotEmpty) {
final map = <int, List<EpisodeItem>>{};
for (final part in widget.seasonsEps!.split(';')) {
if (part.isEmpty) continue;
final seg = part.split(':');
if (seg.length < 2) continue;
final sn = int.tryParse(seg[0]);
if (sn == null || sn <= 0) continue;
final eps = <EpisodeItem>[];
int id = -1;
for (final eSeg in seg[1].split(',')) {
if (eSeg.isEmpty) continue;
final kv = eSeg.split('|');
final epNo = kv.isNotEmpty ? int.tryParse(kv[0]) ?? 0 : 0;
final stCode = (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0;
final st = stCode == 2 ? ItemStatus.Done : (stCode == 1 ? ItemStatus.Progress : ItemStatus.Init);
eps.add(EpisodeItem(id: id--, episodeNumber: epNo, seasonNumber: sn, showName: widget.displayName ?? widget.showName, status: st));
}
if (eps.isNotEmpty) {
map[sn] = eps..sort((a,b)=>a.episodeNumber.compareTo(b.episodeNumber));
}
}
if (map.isNotEmpty) {
_seasons = map;
}
}
_load();
}
Future<void> _load() async {
try {
int? showId = widget.showId;
if (showId == null) {
// Try to parse from key: "name##id"
final parts = widget.showName.split('##');
if (parts.length == 2) {
showId = int.tryParse(parts[1]);
}
}
if (showId != null) {
final rows = await ref.read(backendApiProvider).getShowEpisodes(showId);
final items = rows.map(EpisodeItem.fromJson).toList();
// group by season number
final map = <int, List<EpisodeItem>>{};
for (final e in items) {
if (e.seasonNumber <= 0) continue;
map.putIfAbsent(e.seasonNumber, () => <EpisodeItem>[]).add(e);
}
if (!mounted) return;
setState(() {
_seasons = map.map((k, v) => MapEntry(k, (v..sort((a,b)=>a.episodeNumber.compareTo(b.episodeNumber)))));
final first = items.isNotEmpty ? items.first : null;
if (first != null) {
_resolution ??= first.resolution;
_downloadPath ??= first.downloadPath;
_cliffhanger ??= first.showCliffhanger;
_showId ??= first.showId ?? showId;
_origResolution ??= _resolution;
_origDownloadPath ??= _downloadPath;
_origCliffhanger ??= _cliffhanger;
if (_downloadCtrl.text != (_downloadPath ?? '')) {
_downloadCtrl.text = _downloadPath ?? '';
}
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) {
final c = (credits['cast'] as List? ?? const []).cast().map((e)=>Map<String,dynamic>.from(e as Map)).toList();
final allCrew = (credits['crew'] as List? ?? const []).cast().map((e)=>Map<String,dynamic>.from(e as Map)).toList();
_cast = c.take(12).toList();
const preferredJobs = {'Showrunner','Director','Writer','Screenplay','Story','Teleplay','Executive Producer','Producer'};
final preferred = allCrew.where((m)=> preferredJobs.contains((m['job'] ?? '').toString()) || (m['department'] ?? '').toString()=='Directing' || (m['department'] ?? '').toString()=='Writing');
final chosen = preferred.isNotEmpty ? preferred : allCrew;
_crew = chosen.take(12).toList();
}
} catch (_) {}
}
}
});
return;
}
} catch (_) {
// fall back to grouped provider
}
final grouped = await ref.read(seriesGroupedProvider.future);
final data = grouped.data[widget.showName];
if (!mounted) return;
setState(() {
_seasons = data;
if (data != null) {
// read show-level meta from first episode available
final flat = data.values.expand((e) => e);
final first = flat.isEmpty ? null : flat.first;
if (first != null) {
_resolution ??= first.resolution;
_downloadPath ??= first.downloadPath;
_cliffhanger ??= first.showCliffhanger;
_showId ??= first.showId;
_origResolution ??= _resolution;
_origDownloadPath ??= _downloadPath;
_origCliffhanger ??= _cliffhanger;
if (_downloadCtrl.text != (_downloadPath ?? '')) {
_downloadCtrl.text = _downloadPath ?? '';
}
// 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) {
final c = (credits['cast'] as List? ?? const [])
.cast()
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
final allCrew = (credits['crew'] as List? ?? const [])
.cast()
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
_cast = c.take(12).toList();
// Prefer key roles; if empty, fall back to first crew entries
const preferredJobs = {
'Showrunner', 'Director', 'Writer', 'Screenplay', 'Story', 'Teleplay', 'Executive Producer', 'Producer'
};
final preferred = allCrew.where((m) =>
preferredJobs.contains((m['job'] ?? '').toString()) ||
(m['department'] ?? '').toString() == 'Directing' ||
(m['department'] ?? '').toString() == 'Writing');
final chosen = preferred.isNotEmpty ? preferred : allCrew;
_crew = chosen.take(12).toList();
}
} catch (_) {}
}
}
}
});
}
@@ -147,6 +188,12 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
// refresh list/table
// ignore: unused_result
ref.invalidate(seriesGroupedProvider);
// ignore: unused_result
ref.invalidate(seriesSummaryProvider);
// Reload episodes to reflect the saved statuses in this screen as well
await _load();
// Mark that we have saved changes so list can refresh on back
_saved = true;
if (mounted) {
setState(() {
_pending.clear();
@@ -165,6 +212,8 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
}
}
bool _saved = false;
void _setEpisodeStatus(EpisodeItem ep, ItemStatus s) {
setState(() {
_pending[ep.id] = s;
@@ -236,7 +285,12 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
_resolution != _origResolution ||
_downloadPath != _origDownloadPath ||
_cliffhanger != _origCliffhanger;
return Scaffold(
return WillPopScope(
onWillPop: () async {
Navigator.of(context).pop(_saved ? 'updated' : null);
return false;
},
child: Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
@@ -388,8 +442,9 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
),
),
),
),
);
)
),
);
}
Widget _header(BuildContext context) {
@@ -9,6 +9,8 @@ import 'widgets/episode_status_strip.dart';
import 'series_detail_screen.dart';
import 'series_add_screen.dart';
import '../../shared/providers.dart';
import '../data/episode_model.dart';
import 'widgets/season_status_bar.dart';
class SeriesListScreen extends ConsumerWidget {
const SeriesListScreen({super.key});
@@ -17,6 +19,7 @@ class SeriesListScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final filter = ref.watch(episodeFilterProvider);
final groupedAsync = ref.watch(seriesGroupedProvider);
final summaryAsync = ref.watch(seriesSummaryProvider);
return Scaffold(
body: Padding(
@@ -86,7 +89,7 @@ class SeriesListScreen extends ConsumerWidget {
}
}
total = showRows.length;
int ok = 0;
int success = 0;
for (final row in showRows) {
idx++;
final tmdbId = (row['tmdb_id'] as num?)?.toInt();
@@ -110,13 +113,13 @@ class SeriesListScreen extends ConsumerWidget {
await backend.upsertEpisode(seasonId, e);
}
}
ok++;
success++;
} catch (_) {}
}
// ignore: unused_result
ref.invalidate(seriesGroupedProvider);
messenger.showSnackBar(
SnackBar(content: Text('TMDB Update fertig: $ok/$total Serien')),
SnackBar(content: Text('TMDB Update fertig: $success/$total Serien')),
);
} finally {
if (ctx.mounted) Navigator.of(ctx).pop();
@@ -253,10 +256,12 @@ class SeriesListScreen extends ConsumerWidget {
),
const SizedBox(height: 12),
Expanded(
child: groupedAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Fehler: $e')),
data: (data) {
child: (summaryAsync.hasValue && (summaryAsync.value?.isNotEmpty ?? false))
? _buildFromSummary(context, ref, summaryAsync.value!)
: groupedAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Fehler: $e')),
data: (data) {
final keys = data.data.keys.toList();
String displayOf(String key) {
final bySeason = data.data[key];
@@ -311,7 +316,7 @@ class SeriesListScreen extends ConsumerWidget {
}
if ((year == null || showStatus == null || showCliffhanger == null) && showJson != null) {
try {
final m = jsonDecode(showJson!);
final m = jsonDecode(showJson);
if (m is Map && m['first_air_date'] is String) {
final s = (m['first_air_date'] as String);
if (s.length >= 4) year = int.tryParse(s.substring(0, 4));
@@ -404,6 +409,7 @@ class SeriesListScreen extends ConsumerWidget {
builder: (_) => SeriesDetailScreen(
showName: key,
displayName: displayName,
showId: int.tryParse(key.split('##').last),
year: year,
resolution: resolution,
posterPath: posterPath,
@@ -439,6 +445,334 @@ class SeriesListScreen extends ConsumerWidget {
);
}
Widget _buildFromSummary(BuildContext context, WidgetRef ref, List<Map<String, dynamic>> items) {
// Build and sort keys
final keys = <String>[];
final byKey = <String, Map<String, dynamic>>{};
for (final m in items) {
final showId = (m['show_id'] as num).toInt();
final name = (m['name'] as String?) ?? '';
final key = '${name}##${showId}';
keys.add(key);
byKey[key] = m;
}
String displayOf(String key) => key.split('##').first;
keys.sort((a, b) => displayOf(a).toLowerCase().compareTo(displayOf(b).toLowerCase()));
// Determine all season columns to create a fixed horizontal grid
final allSeasons = <int>{};
for (final m in items) {
final seasonsArr = (m['seasons'] as List?);
if (seasonsArr != null) {
for (final s in seasonsArr) {
final sn = ((s as Map)['season_number'] as num?)?.toInt();
if (sn != null && sn > 0) allSeasons.add(sn);
}
} else if (m['season_status'] is String) {
final str = m['season_status'] as String;
for (final part in str.split('|')) {
if (part.isEmpty) continue;
final seg = part.split(':');
final sn = int.tryParse(seg.first);
if (sn != null && sn > 0) allSeasons.add(sn);
}
}
}
final seasonCols = allSeasons.toList()..sort();
// Compute per-season column widths based on widest row (episode count)
const double barW = 7;
const int cap = 120;
final maxSquares = <int, int>{};
for (final m in items) {
if (m['seasons_eps'] is String) {
final se = (m['seasons_eps'] as String);
for (final part in se.split(';')) {
if (part.isEmpty) continue;
final seg = part.split(':');
final sn = int.tryParse(seg.first);
if (sn == null) continue;
final list = seg.length > 1 ? seg[1] : '';
final cnt = list.isEmpty ? 0 : list.split(',').length;
final v = cnt > cap ? cap : cnt;
if (!maxSquares.containsKey(sn) || v > (maxSquares[sn] ?? 0)) {
maxSquares[sn] = v;
}
}
} else if (m['seasons'] is List) {
for (final s in (m['seasons'] as List)) {
final mm = s as Map;
final sn = (mm['season_number'] as num?)?.toInt();
if (sn == null) continue;
final init = (mm['init'] as num?)?.toInt() ?? 0;
final prog = (mm['progress'] as num?)?.toInt() ?? 0;
final done = (mm['done'] as num?)?.toInt() ?? 0;
var cnt = init + prog + done;
if (cnt > cap) cnt = cap;
if (!maxSquares.containsKey(sn) || cnt > (maxSquares[sn] ?? 0)) {
maxSquares[sn] = cnt;
}
}
}
}
final seasonWidths = <int, double>{
for (final s in seasonCols) s: ((maxSquares[s] ?? 0) * barW).toDouble().clamp(40.0, 2000.0)
};
double tableWidth = 360;
for (final s in seasonCols) {
tableWidth += 16 + (seasonWidths[s] ?? 74);
}
Widget buildRow(String key) {
final data = byKey[key]!;
final name = displayOf(key);
final jsonStr = data['json'] as String?;
int? year;
if (jsonStr != null && jsonStr.isNotEmpty) {
try {
final m = jsonDecode(jsonStr) as Map<String, dynamic>;
final s = (m['first_air_date'] as String?) ?? '';
if (s.length >= 4) year = int.tryParse(s.substring(0, 4));
} catch (_) {}
}
final posterPath = data['poster_path'] as String?;
final resolution = data['resolution'] as String?;
final showStatus = () {
if (jsonStr != null) {
try { final m = jsonDecode(jsonStr) as Map<String, dynamic>; return m['status'] as String?; } catch (_) {}
}
return null;
}();
final cliff = () {
final v = data['cliffhanger'];
if (v is bool) return v; if (v is num) return v != 0; if (v is String) { final s=v.toLowerCase(); return s=='1'||s=='true'||s=='yes'; }
return null;
}();
// Build seasons list from either 'seasons' array or compact 'season_status' string
List<Map<String, dynamic>> seasons;
if (data['seasons'] is List) {
seasons = (data['seasons'] as List).cast<Map<String, dynamic>>();
} else if (data['season_status'] is String) {
final str = data['season_status'] as String;
seasons = [];
for (final part in str.split('|')) {
if (part.isEmpty) continue;
final seg = part.split(':');
if (seg.length < 2) continue;
final sn = int.tryParse(seg[0]);
final counts = seg[1].split(',');
final init = counts.length > 0 ? int.tryParse(counts[0]) ?? 0 : 0;
final prog = counts.length > 1 ? int.tryParse(counts[1]) ?? 0 : 0;
final done = counts.length > 2 ? int.tryParse(counts[2]) ?? 0 : 0;
final tot = counts.length > 3 ? int.tryParse(counts[3]) ?? 0 : (init + prog + done);
if (sn != null) {
seasons.add({'season_number': sn, 'init': init, 'progress': prog, 'done': done, 'total': tot});
}
}
} else {
seasons = const [];
}
// Use any_progress/any_init flags if provided
bool anyProgress = () {
final v = data['any_progress'];
if (v is num) return v.toInt() != 0; if (v is bool) return v; if (v is String) return v == '1' || v.toLowerCase() == 'true';
for (final s in seasons) { if (((s['progress'] as num?)?.toInt() ?? 0) > 0) return true; }
return false;
}();
bool anyInit = () {
final v = data['any_init'];
if (v is num) return v.toInt() != 0; if (v is bool) return v; if (v is String) return v == '1' || v.toLowerCase() == 'true';
for (final s in seasons) { if (((s['init'] as num?)?.toInt() ?? 0) > 0) return true; }
return false;
}();
Color? bg; if (anyProgress) bg = Colors.blue; else if (anyInit) bg = Colors.grey.shade200; else bg = Colors.green;
return InkWell(
onTap: () async {
final res = await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SeriesDetailScreen(
showName: key,
displayName: name,
showId: int.tryParse(key.split('##').last),
seasonsEps: data['seasons_eps'] as String?,
year: year,
resolution: resolution,
posterPath: posterPath,
),
),
);
// After returning from detail, refresh summary to ensure latest values
// ignore: unused_result
ref.invalidate(seriesSummaryProvider);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left
Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
width: 360,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (posterPath != null)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl: 'https://image.tmdb.org/t/p/w154$posterPath',
width: 50,
height: 75,
fit: BoxFit.cover,
),
),
if (posterPath != null) const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: _seriesTitle(name, year, showStatus, cliff, context)),
const SizedBox(width: 6),
if ((showStatus ?? '').isNotEmpty) _statusBadge(showStatus!, context),
],
),
const SizedBox(height: 4),
_resolutionInline(resolution, context),
],
),
),
],
),
),
// Seasons columns
for (final s in seasonCols) ...[
const SizedBox(width: 16),
SizedBox(
width: seasonWidths[s] ?? 74,
child: Builder(builder: (_) {
// Prefer full episode statuses if provided via seasons_eps; fallback to counts
final epsStrAll = data['seasons_eps'] as String?;
if (epsStrAll != null && epsStrAll.isNotEmpty) {
// Find this season
String? matchStr;
for (final part in epsStrAll.split(';')) {
if (part.isEmpty) continue;
final seg = part.split(':');
final sn = int.tryParse(seg.first);
if (sn == s) { matchStr = seg.length > 1 ? seg[1] : ''; break; }
}
if (matchStr != null && matchStr.isNotEmpty) {
final eps = <EpisodeItem>[];
int id = -1;
for (final eSeg in matchStr.split(',')) {
if (eSeg.isEmpty) continue;
final kv = eSeg.split('|');
final epNo = kv.isNotEmpty ? int.tryParse(kv[0]) ?? 0 : 0;
final stCode = (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0;
final st = stCode == 2 ? ItemStatus.Done : (stCode == 1 ? ItemStatus.Progress : ItemStatus.Init);
eps.add(EpisodeItem(id: id--, episodeNumber: epNo, seasonNumber: s, showName: name, status: st));
}
if (eps.isNotEmpty) {
return EpisodeStatusStrip(episodes: eps, barWidth: 7, barHeight: 25, spacing: 0);
}
}
}
// Fallback: counts only
final match = seasons.firstWhere(
(e) => (e['season_number'] as num?)?.toInt() == s,
orElse: () => const {},
);
if (match.isEmpty) {
return const Text('-', textAlign: TextAlign.center);
}
final init = (match['init'] as num?)?.toInt() ?? 0;
final progress = (match['progress'] as num?)?.toInt() ?? 0;
final done = (match['done'] as num?)?.toInt() ?? 0;
return SeasonStatusBar(
seasonNumber: s,
init: init,
progress: progress,
done: done,
barWidth: 7,
barHeight: 25,
cap: 120,
);
}),
),
],
],
),
),
);
}
Widget buildHeader() {
return Container(
color: Theme.of(context).colorScheme.surface,
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(width: 360, child: Text('Serie', style: Theme.of(context).textTheme.labelMedium)),
for (final s in seasonCols) ...[
const SizedBox(width: 16),
SizedBox(
width: seasonWidths[s] ?? 74,
child: Center(
child: Text('Season $s', style: Theme.of(context).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600)),
),
),
]
],
),
);
}
const headerHeight = 32.0;
return Scrollbar(
thumbVisibility: true,
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: 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: SizedBox(height: headerHeight, child: buildHeader()),
),
// Vertical list below the header
Positioned.fill(
top: headerHeight,
child: ListView.separated(
itemCount: keys.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) => buildRow(keys[i]),
),
),
],
),
),
);
},
),
);
}
Widget _resolutionInline(String? res, BuildContext context) {
if (res == null || res.isEmpty) return const SizedBox.shrink();
final m = RegExp(r"\d+").firstMatch(res);
@@ -484,4 +818,28 @@ class SeriesListScreen extends ConsumerWidget {
final text = year != null ? '$name ($year)' : name;
return Text(text, overflow: TextOverflow.ellipsis, style: style);
}
Widget _statusBadge(String status, BuildContext context) {
final s = status.toLowerCase();
Color color;
if (s.contains('canceled') || s.contains('cancelled')) {
color = Colors.redAccent;
} else if (s.contains('ended')) {
color = Colors.grey;
} else {
color = Colors.blueGrey; // running/returning
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withOpacity(0.6)),
),
child: Text(
status,
style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.onSurface),
),
);
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import '../../../../core/status.dart';
class SeasonStatusBar extends StatelessWidget {
final int seasonNumber;
final int init;
final int progress;
final int done;
final double barWidth;
final double barHeight;
final int cap; // max number of squares to render
const SeasonStatusBar({
super.key,
required this.seasonNumber,
required this.init,
required this.progress,
required this.done,
this.barWidth = 7,
this.barHeight = 25,
this.cap = 120,
});
Color _fill(ItemStatus s) {
switch (s) {
case ItemStatus.Init:
return Colors.grey.shade200;
case ItemStatus.Progress:
return Colors.blue;
case ItemStatus.Done:
return Colors.green;
}
}
@override
Widget build(BuildContext context) {
final total = init + progress + done;
if (total <= 0) return const Text('-', textAlign: TextAlign.center);
// Compute how many squares to render (cap at max)
final n = total > cap ? cap : total;
// Distribute counts proportionally to keep the visual feel
int pInit = ((init / total) * n).round();
int pProg = ((progress / total) * n).round();
int pDone = n - pInit - pProg;
if (pInit < 0) pInit = 0; if (pProg < 0) pProg = 0; if (pDone < 0) pDone = 0;
final children = <Widget>[];
for (int i = 0; i < pInit; i++) {
children.add(_square(_fill(ItemStatus.Init), i == 0));
}
for (int i = 0; i < pProg; i++) {
children.add(_square(_fill(ItemStatus.Progress), pInit == 0 && i == 0));
}
for (int i = 0; i < pDone; i++) {
children.add(_square(_fill(ItemStatus.Done), pInit == 0 && pProg == 0 && i == 0));
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: children),
);
}
Widget _square(Color color, bool isFirst) {
return Container(
width: barWidth,
height: barHeight,
decoration: BoxDecoration(
color: color,
border: Border(
left: BorderSide(color: Colors.black, width: isFirst ? 1 : 0),
top: const BorderSide(color: Colors.black, width: 1),
right: const BorderSide(color: Colors.black, width: 1),
bottom: const BorderSide(color: Colors.black, width: 1),
),
),
);
}
}
+155 -77
View File
@@ -257,50 +257,27 @@ try {
JOIN seasons se ON se.id = e.season_id
JOIN shows sh ON sh.id = se.show_id
WHERE 1=1";
// Prefer extracting show status directly from JSON and include cliffhanger column if present
$selectWithYearJson = "SELECT e.*,
CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status,
sh.resolution AS resolution,
sh.poster_path AS poster_path,
sh.first_air_year AS first_air_year,
sh.id AS show_id,
sh.download_path AS download_path,
JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status,
sh.cliffhanger AS show_cliffhanger,
sh.json AS show_json,
se.season_number, sh.name AS show_name ".$base;
$selectNoYearJson = "SELECT e.*,
CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status,
sh.resolution AS resolution,
sh.poster_path AS poster_path,
NULL AS first_air_year,
sh.id AS show_id,
sh.download_path AS download_path,
JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status,
sh.cliffhanger AS show_cliffhanger,
sh.json AS show_json,
se.season_number, sh.name AS show_name ".$base;
// Fallback selects without JSON_EXTRACT (older MySQL) — frontend will parse from show_json
$selectWithYear = "SELECT e.*,
CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status,
sh.resolution AS resolution,
sh.poster_path AS poster_path,
sh.first_air_year AS first_air_year,
sh.id AS show_id,
sh.download_path AS download_path,
sh.cliffhanger AS show_cliffhanger,
sh.json AS show_json,
se.season_number, sh.name AS show_name ".$base;
$selectNoYear = "SELECT e.*,
CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status,
sh.resolution AS resolution,
sh.poster_path AS poster_path,
NULL AS first_air_year,
sh.id AS show_id,
sh.download_path AS download_path,
sh.cliffhanger AS show_cliffhanger,
sh.json AS show_json,
se.season_number, sh.name AS show_name ".$base;
// JSON-aware select (keine first_air_year-Spalte voraussetzen)
$selectJson = "SELECT e.*,
CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status,
sh.resolution AS resolution,
sh.poster_path AS poster_path,
sh.id AS show_id,
sh.download_path AS download_path,
JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status,
sh.cliffhanger AS show_cliffhanger,
sh.json AS show_json,
se.season_number, sh.name AS show_name ".$base;
// Fallback ohne JSON_EXTRACT (ältere MySQL-Versionen)
$selectNoJson = "SELECT e.*,
CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status,
sh.resolution AS resolution,
sh.poster_path AS poster_path,
sh.id AS show_id,
sh.download_path AS download_path,
sh.cliffhanger AS show_cliffhanger,
sh.json AS show_json,
se.season_number, sh.name AS show_name ".$base;
$run = function(string $sql) use ($pdo, $statusVal, $q, $offset, $limit) {
$params = [];
@@ -315,45 +292,18 @@ try {
};
try {
$rows = $run($selectWithYearJson);
$rows = $run($selectJson);
resp(['ok'=>true,'items'=>$rows]);
} catch (Throwable $e) {
$msg = $e->getMessage();
if (strpos($msg, 'Unknown column') !== false) {
// Maybe first_air_year missing — try JSON version without year
try {
$rows = $run($selectNoYearJson);
resp(['ok'=>true,'items'=>$rows]);
} catch (Throwable $e2) {
$msg2 = $e2->getMessage();
if (stripos($msg2, 'JSON_EXTRACT') !== false || stripos($msg2, 'Unknown function') !== false) {
// Fallback to non-JSON_EXTRACT selects
try {
$rows = $run($selectWithYear);
resp(['ok'=>true,'items'=>$rows]);
} catch (Throwable $e3) {
if (strpos($e3->getMessage(), 'Unknown column') !== false) {
$rows = $run($selectNoYear);
resp(['ok'=>true,'items'=>$rows]);
} else { throw $e3; }
}
} else { throw $e2; }
}
} elseif (stripos($msg, 'JSON_EXTRACT') !== false || stripos($msg, 'Unknown function') !== false) {
// JSON functions not available
try {
$rows = $run($selectWithYear);
resp(['ok'=>true,'items'=>$rows]);
} catch (Throwable $e4) {
if (strpos($e4->getMessage(), 'Unknown column') !== false) {
$rows = $run($selectNoYear);
resp(['ok'=>true,'items'=>$rows]);
} else { throw $e4; }
}
if (stripos($msg, 'JSON_EXTRACT') !== false || stripos($msg, 'Unknown function') !== false) {
// JSON-Funktionen nicht verfügbar -> Fallback ohne JSON
$rows = $run($selectNoJson);
resp(['ok'=>true,'items'=>$rows]);
} else { throw $e; }
}
}
// Movie list handling
if ($type==='movie') {
$statusVal = null;
if ($status) {
@@ -372,6 +322,134 @@ try {
fail('unsupported type');
}
case 'get_series_summary': {
// Optional: increase GROUP_CONCAT limit for very large shows
try { $pdo->query('SET SESSION group_concat_max_len = 1048576'); } catch (Throwable $e) {}
$sql = "
SELECT
sh.id AS show_id,
sh.name,
sh.poster_path,
sh.resolution,
sh.download_path,
sh.cliffhanger,
sh.json,
sa.season_status,
sa.seasons_eps,
(af.progress_sum > 0) AS any_progress,
(af.init_sum > 0) AS any_init
FROM shows sh
LEFT JOIN (
SELECT t.show_id,
GROUP_CONCAT(CONCAT(t.season_number, ':', t.init_cnt, ',', t.prog_cnt, ',', t.done_cnt, ',', t.total_cnt)
ORDER BY t.season_number SEPARATOR '|') AS season_status,
GROUP_CONCAT(CONCAT(t.season_number, ':', t.eps_list)
ORDER BY t.season_number SEPARATOR ';') AS seasons_eps
FROM (
SELECT se.show_id,
se.season_number,
SUM(CASE WHEN e.status IS NULL OR e.status = 0 THEN 1 ELSE 0 END) AS init_cnt,
SUM(CASE WHEN e.status = 1 THEN 1 ELSE 0 END) AS prog_cnt,
SUM(CASE WHEN e.status = 2 THEN 1 ELSE 0 END) AS done_cnt,
COUNT(e.id) AS total_cnt,
GROUP_CONCAT(CONCAT(e.episode_number, '|', COALESCE(e.status,0))
ORDER BY e.episode_number SEPARATOR ',') AS eps_list
FROM seasons se
LEFT JOIN episodes e ON e.season_id = se.id
WHERE se.season_number > 0
GROUP BY se.show_id, se.season_number
) AS t
GROUP BY t.show_id
) AS sa ON sa.show_id = sh.id
LEFT JOIN (
SELECT se.show_id,
SUM(CASE WHEN e.status = 1 THEN 1 ELSE 0 END) AS progress_sum,
SUM(CASE WHEN e.status IS NULL OR e.status = 0 THEN 1 ELSE 0 END) AS init_sum
FROM seasons se
LEFT JOIN episodes e ON e.season_id = se.id
WHERE se.season_number > 0
GROUP BY se.show_id
) AS af ON af.show_id = sh.id
ORDER BY sh.name ASC";
try {
$stmt = $pdo->query($sql);
$rows = $stmt->fetchAll();
resp(['ok' => true, 'items' => $rows]);
} catch (Throwable $e) {
// Fallback for legacy schemas using column name 'state' instead of 'status'
$msg = $e->getMessage();
if (strpos($msg, 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) {
$sql2 = str_replace(['e.status', 'COALESCE(e.status,0)'], ['e.state', 'COALESCE(e.state,0)'], $sql);
$stmt = $pdo->query($sql2);
$rows = $stmt->fetchAll();
resp(['ok' => true, 'items' => $rows]);
} else {
throw $e;
}
}
}
case 'get_show_episodes': {
$showId = (int)($in['show_id'] ?? 0);
if (!$showId) fail('bad params');
$base = "FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN shows sh ON sh.id = se.show_id
WHERE se.show_id = ? AND se.season_number > 0";
$selectJson = "SELECT e.*,
CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status,
sh.resolution AS resolution,
sh.poster_path AS poster_path,
sh.id AS show_id,
sh.download_path AS download_path,
JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status,
sh.cliffhanger AS show_cliffhanger,
sh.json AS show_json,
se.season_number, sh.name AS show_name ".$base.
" ORDER BY se.season_number ASC, e.episode_number ASC";
$selectNoJson = "SELECT e.*,
CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status,
sh.resolution AS resolution,
sh.poster_path AS poster_path,
sh.id AS show_id,
sh.download_path AS download_path,
sh.cliffhanger AS show_cliffhanger,
sh.json AS show_json,
se.season_number, sh.name AS show_name ".$base.
" ORDER BY se.season_number ASC, e.episode_number ASC";
try {
$stmt = $pdo->prepare($selectJson);
$stmt->execute([$showId]);
$rows = $stmt->fetchAll();
resp(['ok'=>true,'items'=>$rows]);
} catch (Throwable $e) {
$msg = $e->getMessage();
if (stripos($msg, 'JSON_EXTRACT') !== false || stripos($msg, 'Unknown function') !== false) {
$stmt = $pdo->prepare($selectNoJson);
$stmt->execute([$showId]);
$rows = $stmt->fetchAll();
resp(['ok'=>true,'items'=>$rows]);
}
// Fallback for legacy 'state' column instead of 'status'
if (strpos($msg, 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) {
$selectJson2 = str_replace('e.status', 'e.state', $selectJson);
try {
$stmt = $pdo->prepare($selectJson2);
$stmt->execute([$showId]);
$rows = $stmt->fetchAll();
resp(['ok'=>true,'items'=>$rows]);
} catch (Throwable $e2) {
$selectNoJson2 = str_replace('e.status', 'e.state', $selectNoJson);
$stmt = $pdo->prepare($selectNoJson2);
$stmt->execute([$showId]);
$rows = $stmt->fetchAll();
resp(['ok'=>true,'items'=>$rows]);
}
}
throw $e;
}
}
case 'get_show_by_tmdb': {
$tmdbId = (int)($in['tmdb_id'] ?? 0);
if (!$tmdbId) fail('bad params');