Speed up Movies
This commit is contained in:
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user