chore: init repository with project sources
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'features/movies/presentation/movie_list_screen.dart';
|
||||
import 'features/series/presentation/series_list_screen.dart';
|
||||
import 'features/import/import_screen.dart';
|
||||
import 'features/ping/ping_test_screen.dart';
|
||||
|
||||
class MultimediaApp extends StatelessWidget {
|
||||
const MultimediaApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'multimediaFlutter',
|
||||
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blueGrey),
|
||||
home: const _HomeTabs(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeTabs extends StatefulWidget {
|
||||
const _HomeTabs();
|
||||
|
||||
@override
|
||||
State<_HomeTabs> createState() => _HomeTabsState();
|
||||
}
|
||||
|
||||
class _HomeTabsState extends State<_HomeTabs>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('multimediaFlutter'),
|
||||
bottom: TabBar(
|
||||
controller: _controller,
|
||||
tabs: const [
|
||||
Tab(text: 'Filme'),
|
||||
Tab(text: 'Serien'),
|
||||
Tab(text: 'Import'),
|
||||
Tab(text: 'Ping'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _controller,
|
||||
children: const [
|
||||
MovieListScreen(),
|
||||
SeriesListScreen(),
|
||||
ImportScreen(),
|
||||
PingTestScreen(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'dart:convert';import 'package:dio/dio.dart';
|
||||
import '../../core/config.dart';
|
||||
|
||||
class BackendApi {
|
||||
final Dio _dio;
|
||||
|
||||
BackendApi()
|
||||
: _dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.backendBaseUrl,
|
||||
headers: const {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
contentType: Headers.jsonContentType,
|
||||
validateStatus: (s) => s != null && s < 500,
|
||||
),
|
||||
);
|
||||
|
||||
Future<Map<String, dynamic>> _post(Map<String, dynamic> body) async {
|
||||
Future<Response> sendJson(Map<String, dynamic> p) {
|
||||
final prev = _dio.options.contentType;
|
||||
_dio.options.contentType = Headers.jsonContentType;
|
||||
return _dio.post('', data: p).whenComplete(() {
|
||||
_dio.options.contentType = prev;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Response> sendForm(Map<String, dynamic> p) {
|
||||
final prev = _dio.options.contentType;
|
||||
_dio.options.contentType = Headers.formUrlEncodedContentType;
|
||||
final flat = p.map((k, v) => MapEntry(k, (v is Map || v is List) ? jsonEncode(v) : v));
|
||||
return _dio.post('', data: flat).whenComplete(() {
|
||||
_dio.options.contentType = prev;
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic>? map;
|
||||
Response? res;
|
||||
final payload = {...body};
|
||||
try {
|
||||
res = await sendJson(payload);
|
||||
} on DioException catch (e) {
|
||||
// Capture 5xx with body
|
||||
if (e.response != null) {
|
||||
res = e.response;
|
||||
} else {
|
||||
// ignore: avoid_print
|
||||
print('Backend request failed (JSON, no response). URL: ${_dio.options.baseUrl}, payload: $payload, error: $e');
|
||||
throw Exception('Backend request failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool shouldFallbackToForm() {
|
||||
final sc = res?.statusCode;
|
||||
if (sc == 400) {
|
||||
try {
|
||||
final d = res?.data;
|
||||
if (d is Map && (d['error']?.toString().toLowerCase().contains('unknown action') ?? false)) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldFallbackToForm()) {
|
||||
// ignore: avoid_print
|
||||
print('Retrying request as form-urlencoded due to unknown action (server likely expects \$_POST).');
|
||||
try {
|
||||
res = await sendForm(payload);
|
||||
} on DioException catch (e) {
|
||||
if (e.response != null) {
|
||||
res = e.response;
|
||||
} else {
|
||||
// ignore: avoid_print
|
||||
print('Backend request failed (FORM, no response). URL: ${_dio.options.baseUrl}, payload: $payload, error: $e');
|
||||
throw Exception('Backend request failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (res != null && res.data is Map) {
|
||||
map = res.data as Map<String, dynamic>;
|
||||
}
|
||||
if (res == null || res.statusCode != 200) {
|
||||
// ignore: avoid_print
|
||||
final sc = res?.statusCode;
|
||||
final data = res?.data;
|
||||
print('Backend HTTP ${sc}. URL: ${_dio.options.baseUrl}, payload: $payload, data: ${data}');
|
||||
if (map != null && map.containsKey('error')) {
|
||||
throw Exception('Backend HTTP ${sc}: ${map['error']}');
|
||||
}
|
||||
throw Exception('Backend HTTP ${sc}: ${data}');
|
||||
}
|
||||
if (map == null) {
|
||||
throw Exception('Backend: Unexpected response format');
|
||||
}
|
||||
if (map['ok'] != true) {
|
||||
// ignore: avoid_print
|
||||
print('Backend logical error. URL: ${_dio.options.baseUrl}, payload: $payload, data: $map');
|
||||
throw Exception('Backend responded with error: ${map['error']}');
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getMovies(
|
||||
{String? status, String? q, int offset = 0, int limit = 50}) async {
|
||||
final map = await _post({
|
||||
'action': 'get_list',
|
||||
'type': 'movie',
|
||||
if (status != null) 'status': status,
|
||||
if (q != null && q.isNotEmpty) 'q': q,
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
});
|
||||
return (map['items'] as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<void> setStatus({
|
||||
required String type, // 'movie' | 'episode'
|
||||
required int refId,
|
||||
required String status, // 'Init' | 'Progress' | 'Done'
|
||||
}) async {
|
||||
await _post({
|
||||
'action': 'set_status',
|
||||
'type': type,
|
||||
'ref_id': refId,
|
||||
'status': status,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertMovie(Map<String, dynamic> tmdbJson) async {
|
||||
await _post({
|
||||
'action': 'upsert_movie',
|
||||
'tmdb': tmdbJson,
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getEpisodes({
|
||||
String? status,
|
||||
String? q,
|
||||
int offset = 0,
|
||||
int limit = 5000,
|
||||
}) async {
|
||||
final map = await _post({
|
||||
'action': 'get_list',
|
||||
'type': 'episode',
|
||||
if (status != null) 'status': status,
|
||||
if (q != null && q.isNotEmpty) 'q': q,
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
});
|
||||
return (map['items'] as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<void> upsertShow(Map<String, dynamic> tmdbJson) async {
|
||||
await _post({'action': 'upsert_show', 'tmdb': tmdbJson});
|
||||
}
|
||||
|
||||
Future<int> upsertSeason(int showId, Map<String, dynamic> seasonJson) async {
|
||||
final map = await _post(
|
||||
{'action': 'upsert_season', 'show_id': showId, 'tmdb': seasonJson});
|
||||
return (map['id'] as num).toInt();
|
||||
}
|
||||
|
||||
Future<int> upsertEpisode(
|
||||
int seasonId, Map<String, dynamic> episodeJson) async {
|
||||
final map = await _post({
|
||||
'action': 'upsert_episode',
|
||||
'season_id': seasonId,
|
||||
'tmdb': episodeJson
|
||||
});
|
||||
return (map['id'] as num).toInt();
|
||||
}
|
||||
|
||||
Future<int?> getShowDbIdByTmdbId(int tmdbId) async {
|
||||
final map = await _post({'action': 'get_show_by_tmdb', 'tmdb_id': tmdbId});
|
||||
final v = map['id'];
|
||||
return v == null ? null : (v as num).toInt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// lib/core/api/tmdb_api.dart
|
||||
import 'package:dio/dio.dart';
|
||||
import '../config.dart';
|
||||
|
||||
class TmdbApi {
|
||||
final Dio _dio;
|
||||
|
||||
TmdbApi()
|
||||
: _dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: 'https://api.themoviedb.org/3',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
validateStatus: (code) => code != null && code < 500,
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> get _auth => {
|
||||
'api_key': AppConfig.tmdbApiKey,
|
||||
'language': 'de-DE',
|
||||
};
|
||||
|
||||
Future<Map<String, dynamic>> getMovie(int id) async {
|
||||
final res = await _dio.get(
|
||||
'/movie/$id',
|
||||
queryParameters: {
|
||||
..._auth,
|
||||
'append_to_response': 'images,credits',
|
||||
},
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception(
|
||||
'TMDB getMovie($id) failed: ${res.statusCode} ${res.data}');
|
||||
}
|
||||
return Map<String, dynamic>.from(res.data);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getShow(int id) async {
|
||||
final res = await _dio.get(
|
||||
'/tv/$id',
|
||||
queryParameters: {
|
||||
..._auth,
|
||||
'append_to_response': 'images,credits',
|
||||
},
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception(
|
||||
'TMDB getShow($id) failed: ${res.statusCode} ${res.data}');
|
||||
}
|
||||
return Map<String, dynamic>.from(res.data);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getSeason(int showId, int seasonNumber) async {
|
||||
final res = await _dio.get(
|
||||
'/tv/$showId/season/$seasonNumber',
|
||||
queryParameters: _auth,
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception(
|
||||
'TMDB getSeason($showId,S$seasonNumber) failed: ${res.statusCode} ${res.data}');
|
||||
}
|
||||
return Map<String, dynamic>.from(res.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// Globale Konfiguration. Standard: von `--dart-define` lesen.
|
||||
/// Fallbacks sind hilfreich für lokale Tests.
|
||||
|
||||
class AppConfig {
|
||||
static const backendBaseUrl = String.fromEnvironment(
|
||||
'BACKEND_BASE_URL',
|
||||
defaultValue: 'https://api.windesign.at/multimedia.php',
|
||||
);
|
||||
|
||||
/// Token eines vorhandenen Users in deiner DB (users.api_token)
|
||||
static const backendToken = String.fromEnvironment(
|
||||
'BACKEND_TOKEN',
|
||||
defaultValue: 'dasistwiedereinverystrongtoken',
|
||||
);
|
||||
|
||||
/// TMDB API Key (nur lesend). Für Public-Apps besser: Server-Proxy/Caching.
|
||||
static const tmdbApiKey = String.fromEnvironment(
|
||||
'TMDB_API_KEY',
|
||||
|
||||
defaultValue: 'a33271b9e54cdcb9a80680eaf5522f1b',
|
||||
|
||||
///defaultValue: 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJhMzMyNzFiOWU1NGNkY2I5YTgwNjgwZWFmNTUyMmYxYiIsIm5iZiI6MTM0ODc2NTY2MS4wLCJzdWIiOiI1MDY0ODdkZDE5YzI5NTY2M2MwMDBhOGIiLCJzY29wZXMiOlsiYXBpX3JlYWQiXSwidmVyc2lvbiI6MX0.m26QybYBGQVY8OuL87FFae3ThPqAnOqEwgbLMtnH0wo'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
enum ItemStatus { Init, Progress, Done }
|
||||
|
||||
ItemStatus statusFromString(String? s) {
|
||||
return ItemStatus.values.firstWhere(
|
||||
(e) => e.name == (s ?? 'Init'),
|
||||
orElse: () => ItemStatus.Init,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../core/api/tmdb_api.dart';
|
||||
import '../../core/api/backend_api.dart';
|
||||
import '../shared/providers.dart';
|
||||
|
||||
class ImportScreen extends ConsumerStatefulWidget {
|
||||
const ImportScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ImportScreen> createState() => _ImportScreenState();
|
||||
}
|
||||
|
||||
class _ImportScreenState extends ConsumerState<ImportScreen> {
|
||||
final _movieCtrl =
|
||||
TextEditingController(text: '603, 27205'); // Matrix, Inception
|
||||
final _showCtrl =
|
||||
TextEditingController(text: '1396, 1399'); // Breaking Bad, GoT
|
||||
String _log = '';
|
||||
bool _busy = false;
|
||||
|
||||
void _append(String msg) => setState(() => _log += msg + '\n');
|
||||
|
||||
Future<void> _importMovies() async {
|
||||
setState(() => _busy = true);
|
||||
final tmdb = ref.read(tmdbApiProvider);
|
||||
final backend = ref.read(backendApiProvider);
|
||||
|
||||
final ids = _movieCtrl.text
|
||||
.split(RegExp(r'[,\s]+'))
|
||||
.where((s) => s.isNotEmpty)
|
||||
.map(int.parse);
|
||||
for (final id in ids) {
|
||||
try {
|
||||
_append('Film $id: TMDB laden …');
|
||||
final json = await tmdb.getMovie(id);
|
||||
await backend.upsertMovie(json);
|
||||
_append('Film $id: OK ✓');
|
||||
} catch (e) {
|
||||
_append('Film $id: Fehler → $e');
|
||||
}
|
||||
}
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
|
||||
Future<void> _importShows() async {
|
||||
setState(() => _busy = true);
|
||||
final tmdb = ref.read(tmdbApiProvider);
|
||||
final backend = ref.read(backendApiProvider);
|
||||
|
||||
final ids = _showCtrl.text
|
||||
.split(RegExp(r'[,\s]+'))
|
||||
.where((s) => s.isNotEmpty)
|
||||
.map(int.parse);
|
||||
|
||||
for (final showId in ids) {
|
||||
try {
|
||||
_append('Serie $showId: TMDB laden …');
|
||||
final showJson = await tmdb.getShow(showId);
|
||||
print('SHOW JSON: $showJson'); // Debug-Ausgabe
|
||||
|
||||
await backend.upsertShow(showJson);
|
||||
_append('Serie $showId: Show OK ✓');
|
||||
|
||||
final seasons = (showJson['seasons'] as List? ?? const [])
|
||||
.where((s) => (s['season_number'] ?? 0) is int)
|
||||
.cast<Map<String, dynamic>>();
|
||||
|
||||
for (final s in seasons) {
|
||||
final seasonNo = (s['season_number'] as num).toInt();
|
||||
if (seasonNo < 0) continue;
|
||||
_append(' S$seasonNo: TMDB Season laden …');
|
||||
|
||||
final seasonJson = await tmdb.getSeason(showId, seasonNo);
|
||||
final dbShowId = await _getDbShowIdByTmdb(backend, showId);
|
||||
final dbSeasonId = await backend.upsertSeason(dbShowId, seasonJson);
|
||||
|
||||
_append(' S$seasonNo: Season OK (db:$dbSeasonId)');
|
||||
|
||||
final eps = (seasonJson['episodes'] as List? ?? const [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
for (final e in eps) {
|
||||
await backend.upsertEpisode(dbSeasonId, e);
|
||||
}
|
||||
_append(' S$seasonNo: ${eps.length} Episoden OK ✓');
|
||||
}
|
||||
} catch (e) {
|
||||
// 👇 Hier kommt der erweiterte Catch hin!
|
||||
if (e is DioException) {
|
||||
print('❗ TMDB DioException für $showId');
|
||||
print('➡️ Request: ${e.requestOptions.uri}');
|
||||
print('➡️ Response: ${e.response?.data}');
|
||||
print('➡️ Status: ${e.response?.statusCode}');
|
||||
}
|
||||
_append('Serie $showId: Fehler → $e');
|
||||
}
|
||||
}
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
|
||||
Future<int> _getDbShowIdByTmdb(BackendApi backend, int tmdbId) async {
|
||||
final id = await backend.getShowDbIdByTmdbId(tmdbId);
|
||||
if (id == null) {
|
||||
throw Exception(
|
||||
'Show mit tmdb_id=$tmdbId nicht gefunden – zuerst upsert_show aufrufen.');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inputStyle = const TextStyle(fontSize: 13);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Import (TMDB → DB)')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('Filme TMDB-IDs: '),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child:
|
||||
TextField(controller: _movieCtrl, style: inputStyle)),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _importMovies,
|
||||
child: const Text('Import Filme'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Serien TMDB-IDs: '),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(controller: _showCtrl, style: inputStyle)),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _importShows,
|
||||
child: const Text('Import Serien'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_busy) const LinearProgressIndicator(),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.black12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.topLeft,
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(_log,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:convert';
|
||||
import '../../../core/status.dart';
|
||||
|
||||
class Movie {
|
||||
final int id; // DB-ID
|
||||
final int tmdbId;
|
||||
final String title;
|
||||
final int? releaseYear;
|
||||
final String? posterPath;
|
||||
final ItemStatus status;
|
||||
final String? resolution;
|
||||
final String? overview;
|
||||
|
||||
Movie({
|
||||
required this.id,
|
||||
required this.tmdbId,
|
||||
required this.title,
|
||||
this.releaseYear,
|
||||
this.posterPath,
|
||||
this.status = ItemStatus.Init,
|
||||
this.resolution,
|
||||
this.overview,
|
||||
});
|
||||
|
||||
factory Movie.fromJson(Map<String, dynamic> j) {
|
||||
String? ov;
|
||||
if (j['overview'] is String) {
|
||||
ov = j['overview'] as String?;
|
||||
} else if (j['json'] is String) {
|
||||
try {
|
||||
final m = jsonDecode(j['json'] as String);
|
||||
if (m is Map && m['overview'] is String) ov = m['overview'] as String;
|
||||
} catch (_) {}
|
||||
}
|
||||
return Movie(
|
||||
id: j['id'] as int,
|
||||
tmdbId: j['tmdb_id'] as int,
|
||||
title: j['title'] as String,
|
||||
releaseYear: j['release_year'] as int?,
|
||||
posterPath: j['poster_path'] as String?,
|
||||
status: j['status'] != null ? statusFromString(j['status'] as String) : ItemStatus.Init,
|
||||
resolution: j['resolution'] as String?,
|
||||
overview: ov,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/status.dart';
|
||||
import '../../shared/providers.dart';
|
||||
import 'movie_model.dart';
|
||||
|
||||
final movieFilterProvider = StateProvider<ItemStatus?>((_) => null);
|
||||
|
||||
final moviesProvider = FutureProvider.autoDispose<List<Movie>>((ref) async {
|
||||
final backend = ref.watch(backendApiProvider);
|
||||
final st = ref.watch(movieFilterProvider);
|
||||
final list = await backend.getMovies(status: st?.name);
|
||||
return list.map(Movie.fromJson).toList();
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/status.dart';
|
||||
import '../data/movie_repository.dart';
|
||||
import 'widgets/status_chip.dart';
|
||||
|
||||
class MovieListScreen extends ConsumerWidget {
|
||||
const MovieListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final filter = ref.watch(movieFilterProvider);
|
||||
final moviesAsync = ref.watch(moviesProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusChip(
|
||||
selected: filter,
|
||||
onChanged: (f) => ref.read(movieFilterProvider.notifier).state = f,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: moviesAsync.when(
|
||||
data: (items) {
|
||||
final itemsSorted = [...items]
|
||||
..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(moviesProvider),
|
||||
child: ListView.separated(
|
||||
itemCount: itemsSorted.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final m = itemsSorted[i];
|
||||
return ListTile(
|
||||
tileColor: _statusBg(m.status, context),
|
||||
leading: m.posterPath != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl:
|
||||
'https://image.tmdb.org/t/p/w154${m.posterPath}',
|
||||
width: 50,
|
||||
height: 75,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: 50, height: 75),
|
||||
title: Text(
|
||||
m.releaseYear != null
|
||||
? '${m.title} (${m.releaseYear})'
|
||||
: m.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: FittedBox(
|
||||
alignment: Alignment.topLeft,
|
||||
fit: BoxFit.scaleDown,
|
||||
child: _resolutionBadge(m.resolution, context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
m.overview ?? '',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
onTap: () {
|
||||
// TODO: Detailseite / Status ändern
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, st) => Center(
|
||||
child: Text('Fehler: ${e.toString()}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _statusIcon(ItemStatus s) {
|
||||
switch (s) {
|
||||
case ItemStatus.Init:
|
||||
return Icons.hourglass_empty;
|
||||
case ItemStatus.Progress:
|
||||
return Icons.downloading;
|
||||
case ItemStatus.Done:
|
||||
return Icons.check_circle;
|
||||
}
|
||||
}
|
||||
|
||||
Color? _statusBg(ItemStatus s, BuildContext context) {
|
||||
switch (s) {
|
||||
case ItemStatus.Init:
|
||||
return Colors.grey.shade200;
|
||||
case ItemStatus.Progress:
|
||||
return Colors.blue;
|
||||
case ItemStatus.Done:
|
||||
return Colors.green;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _resolutionBadge(String? res, BuildContext context) {
|
||||
if (res == null || res.isEmpty) return const SizedBox.shrink();
|
||||
final m = RegExp(r"\d+").firstMatch(res);
|
||||
final v = m != null ? int.tryParse(m.group(0)!) : null;
|
||||
IconData? icon;
|
||||
Color color = Theme.of(context).colorScheme.primary;
|
||||
String label = res;
|
||||
if (v != null) {
|
||||
if (v >= 2000) {
|
||||
icon = Icons.four_k;
|
||||
color = Colors.deepOrange;
|
||||
label = '${v}p';
|
||||
} else if (v >= 1000) {
|
||||
icon = Icons.hd;
|
||||
color = Colors.blue;
|
||||
label = '${v}p';
|
||||
} else {
|
||||
icon = Icons.sd_card;
|
||||
color = Colors.grey;
|
||||
label = '${v}p';
|
||||
}
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: color.withOpacity(0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) Icon(icon, size: 14, color: color),
|
||||
if (icon != null) const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:multimediaFlutter/core/status.dart';
|
||||
|
||||
class StatusChip extends StatelessWidget {
|
||||
final ItemStatus? selected;
|
||||
final ValueChanged<ItemStatus?> onChanged;
|
||||
|
||||
const StatusChip(
|
||||
{super.key, required this.selected, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('Alle'),
|
||||
selected: selected == null,
|
||||
onSelected: (_) => onChanged(null),
|
||||
),
|
||||
for (final s in ItemStatus.values)
|
||||
FilterChip(
|
||||
label: Text(s.name),
|
||||
selected: selected == s,
|
||||
onSelected: (_) => onChanged(s),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../core/config.dart';
|
||||
|
||||
class PingTestScreen extends StatefulWidget {
|
||||
const PingTestScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PingTestScreen> createState() => _PingTestScreenState();
|
||||
}
|
||||
|
||||
class _PingTestScreenState extends State<PingTestScreen> {
|
||||
String? _result;
|
||||
bool _loading = false;
|
||||
|
||||
Future<void> _pingServer() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_result = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final res = await Dio(BaseOptions(
|
||||
baseUrl: AppConfig.backendBaseUrl,
|
||||
headers: const {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
contentType: Headers.jsonContentType,
|
||||
validateStatus: (s) => s != null && s < 500,
|
||||
)).post('', data: {'action': 'ping'});
|
||||
|
||||
setState(() {
|
||||
_result = '✅ Antwort: ${res.data}';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_result = '❌ Fehler: $e';
|
||||
});
|
||||
} finally {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Ping-Test')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.wifi_tethering),
|
||||
label: const Text('Ping-Server'),
|
||||
onPressed: _loading ? null : _pingServer,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_loading) const CircularProgressIndicator(),
|
||||
if (_result != null)
|
||||
SelectableText(
|
||||
_result!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import '../../../core/status.dart';
|
||||
|
||||
class EpisodeItem {
|
||||
final int id;
|
||||
final int episodeNumber;
|
||||
final int seasonNumber;
|
||||
final String showName;
|
||||
final String? name;
|
||||
final ItemStatus status;
|
||||
final String? resolution;
|
||||
final int? firstAirYear;
|
||||
final String? showJson;
|
||||
final String? showStatus;
|
||||
final bool? showCliffhanger;
|
||||
final String? posterPath;
|
||||
|
||||
EpisodeItem({
|
||||
required this.id,
|
||||
required this.episodeNumber,
|
||||
required this.seasonNumber,
|
||||
required this.showName,
|
||||
this.name,
|
||||
required this.status,
|
||||
this.resolution,
|
||||
this.firstAirYear,
|
||||
this.showJson,
|
||||
this.showStatus,
|
||||
this.showCliffhanger,
|
||||
this.posterPath,
|
||||
});
|
||||
|
||||
factory EpisodeItem.fromJson(Map<String, dynamic> j) => EpisodeItem(
|
||||
id: j['id'] as int,
|
||||
episodeNumber: j['episode_number'] as int,
|
||||
seasonNumber: j['season_number'] as int,
|
||||
showName: j['show_name'] as String,
|
||||
name: j['name'] as String?,
|
||||
status: statusFromString(j['status'] as String?),
|
||||
resolution: j['resolution'] as String?,
|
||||
firstAirYear: j['first_air_year'] as int?,
|
||||
showJson: j['show_json'] as String?,
|
||||
showStatus: j['show_status'] as String?,
|
||||
showCliffhanger: _parseBool(j['show_cliffhanger']),
|
||||
posterPath: j['poster_path'] as String?,
|
||||
);
|
||||
|
||||
static bool? _parseBool(dynamic v) {
|
||||
if (v == null) return null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../shared/providers.dart';
|
||||
import '../../../core/status.dart';
|
||||
import 'episode_model.dart';
|
||||
|
||||
final episodeFilterProvider = StateProvider<ItemStatus?>((_) => null);
|
||||
|
||||
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();
|
||||
return SeriesGroupedData.fromEpisodes(items);
|
||||
});
|
||||
|
||||
class SeriesGroupedData {
|
||||
final Map<String, Map<int, List<EpisodeItem>>>
|
||||
data; // showName -> season -> episodes
|
||||
SeriesGroupedData(this.data);
|
||||
|
||||
factory SeriesGroupedData.fromEpisodes(List<EpisodeItem> items) {
|
||||
final map = <String, Map<int, List<EpisodeItem>>>{};
|
||||
for (final e in items) {
|
||||
final bySeason =
|
||||
map.putIfAbsent(e.showName, () => <int, List<EpisodeItem>>{});
|
||||
final list = bySeason.putIfAbsent(e.seasonNumber, () => <EpisodeItem>[]);
|
||||
list.add(e);
|
||||
}
|
||||
for (final bySeason in map.values) {
|
||||
for (final eps in bySeason.values) {
|
||||
eps.sort((a, b) => a.episodeNumber.compareTo(b.episodeNumber));
|
||||
}
|
||||
}
|
||||
return SeriesGroupedData(map);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/status.dart';
|
||||
import '../../movies/presentation/widgets/status_chip.dart';
|
||||
import '../data/series_repository.dart';
|
||||
import 'widgets/episode_status_strip.dart';
|
||||
|
||||
class SeriesListScreen extends ConsumerWidget {
|
||||
const SeriesListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final filter = ref.watch(episodeFilterProvider);
|
||||
final groupedAsync = ref.watch(seriesGroupedProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusChip(
|
||||
selected: filter,
|
||||
onChanged: (f) =>
|
||||
ref.read(episodeFilterProvider.notifier).state = f,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: groupedAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Fehler: $e')),
|
||||
data: (data) {
|
||||
final shows = data.data.keys.toList()..sort();
|
||||
if (shows.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Keine Episoden gefunden.'));
|
||||
}
|
||||
// Collect all season columns across all shows
|
||||
final allSeasons = <int>{};
|
||||
for (final m in data.data.values) {
|
||||
allSeasons.addAll(m.keys);
|
||||
}
|
||||
final seasonCols = allSeasons.toList()..sort();
|
||||
|
||||
List<DataColumn> buildColumns() => [
|
||||
const DataColumn(label: Text('Serie')),
|
||||
for (final s in seasonCols) DataColumn(label: Text('Staffel $s')),
|
||||
];
|
||||
|
||||
List<DataRow> buildRows() {
|
||||
final rows = <DataRow>[];
|
||||
for (final showName in shows) {
|
||||
final bySeason = data.data[showName]!;
|
||||
int? year;
|
||||
String? resolution;
|
||||
String? showJson;
|
||||
String? showStatus;
|
||||
bool? showCliffhanger;
|
||||
String? posterPath;
|
||||
final sortedSeasons = bySeason.keys.toList()..sort();
|
||||
for (final s in sortedSeasons) {
|
||||
final eps = bySeason[s]!;
|
||||
for (final e in eps) {
|
||||
year ??= e.firstAirYear;
|
||||
resolution ??= e.resolution;
|
||||
showJson ??= e.showJson;
|
||||
showStatus ??= e.showStatus;
|
||||
showCliffhanger ??= e.showCliffhanger;
|
||||
posterPath ??= e.posterPath;
|
||||
if (year != null && resolution != null && showStatus != null && showCliffhanger != null && posterPath != null) break;
|
||||
}
|
||||
if (year != null && resolution != null) break;
|
||||
}
|
||||
if ((year == null || showStatus == null || showCliffhanger == null) && showJson != null) {
|
||||
try {
|
||||
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));
|
||||
}
|
||||
if (m is Map && showStatus == null && m['status'] is String) {
|
||||
showStatus = m['status'] as String;
|
||||
}
|
||||
if (m is Map && showCliffhanger == null && m['cliffhanger'] != null) {
|
||||
final v = m['cliffhanger'];
|
||||
if (v is bool) showCliffhanger = v;
|
||||
else if (v is num) showCliffhanger = v != 0;
|
||||
else if (v is String) {
|
||||
final s = v.toLowerCase();
|
||||
showCliffhanger = (s == '1' || s == 'true' || s == 'yes');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Determine background for left cell based on episodes status
|
||||
bool anyProgress = false;
|
||||
bool anyInit = false;
|
||||
for (final epsList in bySeason.values) {
|
||||
for (final ep in epsList) {
|
||||
if (ep.status == ItemStatus.Progress) anyProgress = true;
|
||||
if (ep.status == ItemStatus.Init) anyInit = true;
|
||||
}
|
||||
}
|
||||
Color? bg;
|
||||
if (anyProgress) bg = Colors.blue;
|
||||
else if (anyInit) bg = Colors.grey.shade200;
|
||||
else bg = Colors.green;
|
||||
|
||||
final cells = <DataCell>[
|
||||
DataCell(Container(
|
||||
color: bg,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
child: SizedBox(
|
||||
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: [
|
||||
_seriesTitle(showName, year, showStatus, showCliffhanger, context),
|
||||
const SizedBox(height: 4),
|
||||
_resolutionInline(resolution, context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
];
|
||||
|
||||
for (final s in seasonCols) {
|
||||
final eps = bySeason[s];
|
||||
if (eps == null || eps.isEmpty) {
|
||||
cells.add(const DataCell(Text('-', textAlign: TextAlign.center)));
|
||||
} else {
|
||||
cells.add(DataCell(EpisodeStatusStrip(
|
||||
episodes: eps,
|
||||
barWidth: 7,
|
||||
barHeight: 25,
|
||||
spacing: 0,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
rows.add(DataRow(cells: cells));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
final table = DataTable(
|
||||
columns: buildColumns(),
|
||||
rows: buildRows(),
|
||||
headingRowHeight: 40,
|
||||
dataRowMinHeight: 88,
|
||||
dataRowMaxHeight: 96,
|
||||
columnSpacing: 16,
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SingleChildScrollView(child: table),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resolutionInline(String? res, BuildContext context) {
|
||||
if (res == null || res.isEmpty) return const SizedBox.shrink();
|
||||
final m = RegExp(r"\d+").firstMatch(res);
|
||||
final v = m != null ? int.tryParse(m.group(0)!) : null;
|
||||
IconData? icon;
|
||||
Color color = Theme.of(context).colorScheme.primary;
|
||||
String label = res;
|
||||
if (v != null) {
|
||||
if (v >= 2000) {
|
||||
icon = Icons.four_k;
|
||||
color = Colors.deepOrange;
|
||||
label = '${v}p';
|
||||
} else if (v >= 1000) {
|
||||
icon = Icons.hd;
|
||||
color = Colors.blue;
|
||||
label = '${v}p';
|
||||
} else {
|
||||
icon = Icons.sd_card;
|
||||
color = Colors.grey;
|
||||
label = '${v}p';
|
||||
}
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) Icon(icon, size: 16, color: color),
|
||||
if (icon != null) const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _seriesTitle(String name, int? year, String? status, bool? cliff, BuildContext context) {
|
||||
final base = Theme.of(context).textTheme.titleMedium;
|
||||
final s = (status ?? '').toLowerCase();
|
||||
final endedOrCanceled = s == 'ended' || s == 'canceled' || s == 'cancelled';
|
||||
final fw = endedOrCanceled ? FontWeight.normal : FontWeight.w600;
|
||||
final fs = (cliff == true) ? FontStyle.italic : FontStyle.normal;
|
||||
final style = base?.copyWith(fontWeight: fw, fontStyle: fs);
|
||||
final text = year != null ? '$name ($year)' : name;
|
||||
return Text(text, overflow: TextOverflow.ellipsis, style: style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/status.dart';
|
||||
import '../../data/episode_model.dart';
|
||||
|
||||
class EpisodeStatusStrip extends StatelessWidget {
|
||||
final List<EpisodeItem> episodes;
|
||||
final double barWidth; // Breite eines Quadrats
|
||||
final double barHeight; // Höhe eines Quadrats
|
||||
final double spacing; // Abstand zwischen Quadraten
|
||||
|
||||
const EpisodeStatusStrip({
|
||||
super.key,
|
||||
required this.episodes,
|
||||
this.barWidth = 7,
|
||||
this.barHeight = 25,
|
||||
this.spacing = 0,
|
||||
});
|
||||
|
||||
Color _fill(ItemStatus s) {
|
||||
switch (s) {
|
||||
case ItemStatus.Init:
|
||||
return Colors.grey.shade200; // wie Filme (Init)
|
||||
case ItemStatus.Progress:
|
||||
return Colors.blue; // wie Filme (Progress)
|
||||
case ItemStatus.Done:
|
||||
return Colors.green; // wie Filme (Done)
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (episodes.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < episodes.length; i++) ...[
|
||||
Container(
|
||||
width: barWidth,
|
||||
height: barHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: _fill(episodes[i].status),
|
||||
border: Border(
|
||||
left: BorderSide(color: Colors.black, width: i == 0 ? 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (i != episodes.length - 1 && spacing != 0) SizedBox(width: spacing),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/api/backend_api.dart';
|
||||
import '../../core/api/tmdb_api.dart';
|
||||
|
||||
final backendApiProvider = Provider<BackendApi>((ref) => BackendApi());
|
||||
final tmdbApiProvider = Provider<TmdbApi>((ref) => TmdbApi());
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'app.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const ProviderScope(child: MultimediaApp()));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// multimedia-settings.php – analog zu deinen bestehenden Projekten
|
||||
|
||||
const MM_ALLOWED_ORIGINS = [
|
||||
'https://windesign.at',
|
||||
'https://hedgehog.windesign.at',
|
||||
'https://api.windesign.at',
|
||||
'https://preview.flutlab.io',
|
||||
'https://*.flutlab.io',
|
||||
'http://localhost:*',
|
||||
'http://127.0.0.1:*',
|
||||
'https://localhost:*',
|
||||
'https://127.0.0.1:*',
|
||||
];
|
||||
|
||||
define('DATABASE_NAME', 'multimediaFlutter');
|
||||
define('DATABASE_USER', 'multimediaFlutter');
|
||||
define('DATABASE_PASSWORD', 'WeissIchNicht8');
|
||||
define('DATABASE_HOST', 'localhost');
|
||||
|
||||
define('UPLOAD_DIR', __DIR__ . '/multimedia/uploads');
|
||||
define('UPLOAD_THUMB_DIR', __DIR__ . '/multimedia/uploads/thumbs');
|
||||
define('UPLOAD_BASE_URL', 'https://api.windesign.at/multimedia/uploads');
|
||||
|
||||
define('MAX_UPLOAD_SIZE', 512 * 1024 * 1024);
|
||||
define('MM_DEBUG', true);
|
||||
?>
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/multimedia-settings.php';
|
||||
|
||||
// --- CORS ---
|
||||
function mm_origin_allowed(string $origin): bool {
|
||||
foreach (MM_ALLOWED_ORIGINS as $pat) {
|
||||
$rx = preg_quote($pat, '/');
|
||||
$rx = str_replace(['\\*', '\\:\\*'], ['.*', '(?::\\d+)?'], $rx);
|
||||
if (preg_match('/^'.$rx.'$/i', $origin)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if ($origin && mm_origin_allowed($origin)) {
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Vary: Origin');
|
||||
} else {
|
||||
header('Access-Control-Allow-Origin: https://windesign.at'); // Fallback
|
||||
}
|
||||
|
||||
// WICHTIG: weit gefasste Allow-Headers (Browser senden oft zusätzliche)
|
||||
$reqHeaders = $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ?? '';
|
||||
$allowHeaders = 'Content-Type, Accept, X-Requested-With, Authorization';
|
||||
if ($reqHeaders) { $allowHeaders .= ', ' . $reqHeaders; }
|
||||
|
||||
header('Access-Control-Allow-Headers: ' . $allowHeaders);
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
header('Access-Control-Max-Age: 86400'); // Preflights cachen
|
||||
header('Access-Control-Allow-Credentials: false');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
|
||||
|
||||
function jsonBody(): array {
|
||||
$raw = file_get_contents('php://input');
|
||||
if (!$raw) return [];
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
// Accept both JSON and form-urlencoded. Also decode JSON-like strings in keys we expect.
|
||||
function inputBody(): array {
|
||||
$in = jsonBody();
|
||||
if (!$in) {
|
||||
// Fallback to form POST
|
||||
$in = $_POST ?: [];
|
||||
}
|
||||
// Normalize: decode JSON strings for nested payloads (e.g., tmdb)
|
||||
foreach (['tmdb'] as $k) {
|
||||
if (isset($in[$k]) && is_string($in[$k])) {
|
||||
$d = json_decode($in[$k], true);
|
||||
if (is_array($d)) $in[$k] = $d;
|
||||
}
|
||||
}
|
||||
// Cast common numeric fields when present
|
||||
foreach (['show_id','season_id','tmdb_id','ref_id','limit','offset'] as $k) {
|
||||
if (isset($in[$k])) $in[$k] = (int)$in[$k];
|
||||
}
|
||||
return $in;
|
||||
}
|
||||
function resp($data, int $code = 200) { http_response_code($code); echo json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); exit; }
|
||||
function fail($msg, int $code = 400) { resp(['ok'=>false,'error'=>$msg], $code); }
|
||||
|
||||
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DATABASE_HOST, DATABASE_NAME);
|
||||
try {
|
||||
$pdo = new PDO($dsn, DATABASE_USER, DATABASE_PASSWORD, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (MM_DEBUG) fail('DB connection failed: '.$e->getMessage(), 500);
|
||||
fail('DB connection failed', 500);
|
||||
}
|
||||
|
||||
$in = inputBody();
|
||||
$action = $in['action'] ?? null;
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'upsert_show': {
|
||||
$tmdb = $in['tmdb'] ?? null; if (!$tmdb || !isset($tmdb['id'])) fail('missing tmdb payload');
|
||||
try {
|
||||
$stmt = $pdo->prepare("INSERT INTO shows (tmdb_id, name, original_name, first_air_year, poster_path, backdrop_path, json)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), original_name=VALUES(original_name), first_air_year=VALUES(first_air_year), poster_path=VALUES(poster_path), backdrop_path=VALUES(backdrop_path), json=VALUES(json)");
|
||||
$stmt->execute([
|
||||
$tmdb['id'],
|
||||
$tmdb['name'] ?? '',
|
||||
$tmdb['original_name'] ?? null,
|
||||
isset($tmdb['first_air_date']) ? intval(substr($tmdb['first_air_date'],0,4)) : null,
|
||||
$tmdb['poster_path'] ?? null,
|
||||
$tmdb['backdrop_path'] ?? null,
|
||||
json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// Fallback for schemas without original_name/first_air_year
|
||||
if (strpos($e->getMessage(), 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) {
|
||||
$stmt = $pdo->prepare("INSERT INTO shows (tmdb_id, name, poster_path, backdrop_path, json)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), poster_path=VALUES(poster_path), backdrop_path=VALUES(backdrop_path), json=VALUES(json)");
|
||||
$stmt->execute([
|
||||
$tmdb['id'],
|
||||
$tmdb['name'] ?? '',
|
||||
$tmdb['poster_path'] ?? null,
|
||||
$tmdb['backdrop_path'] ?? null,
|
||||
json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
$id = $pdo->lastInsertId();
|
||||
if (!$id) { $q=$pdo->prepare('SELECT id FROM shows WHERE tmdb_id=?'); $q->execute([$tmdb['id']]); $id=$q->fetchColumn(); }
|
||||
resp(['ok'=>true,'id'=>(int)$id]);
|
||||
}
|
||||
|
||||
case 'upsert_season': {
|
||||
$showId = (int)($in['show_id'] ?? 0);
|
||||
$tmdb = $in['tmdb'] ?? null; if (!$showId || !$tmdb) fail('bad params');
|
||||
$seasonNo = isset($tmdb['season_number']) ? (int)$tmdb['season_number'] : null; if ($seasonNo===null) fail('missing season_number');
|
||||
$name = $tmdb['name'] ?? (isset($seasonNo) ? ('Season '.$seasonNo) : '');
|
||||
$airDate = $tmdb['air_date'] ?? null;
|
||||
try {
|
||||
$stmt = $pdo->prepare("INSERT INTO seasons (show_id, season_number, name, air_date, json)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), air_date=VALUES(air_date), json=VALUES(json)");
|
||||
$stmt->execute([$showId, $seasonNo, $name, $airDate, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]);
|
||||
} catch (Throwable $e) {
|
||||
if (strpos($e->getMessage(), 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) {
|
||||
$stmt = $pdo->prepare("INSERT INTO seasons (show_id, season_number, name, json)
|
||||
VALUES (?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), json=VALUES(json)");
|
||||
$stmt->execute([$showId, $seasonNo, $name, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]);
|
||||
} else { throw $e; }
|
||||
}
|
||||
$id = $pdo->lastInsertId();
|
||||
if (!$id) { $q=$pdo->prepare('SELECT id FROM seasons WHERE show_id=? AND season_number=?'); $q->execute([$showId,$seasonNo]); $id=$q->fetchColumn(); }
|
||||
resp(['ok'=>true,'id'=>(int)$id]);
|
||||
}
|
||||
|
||||
case 'upsert_episode': {
|
||||
$seasonId = (int)($in['season_id'] ?? 0);
|
||||
$tmdb = $in['tmdb'] ?? null; if (!$seasonId || !$tmdb) fail('bad params');
|
||||
$epNo = isset($tmdb['episode_number']) ? (int)$tmdb['episode_number'] : null; if ($epNo===null) fail('missing episode_number');
|
||||
$name = $tmdb['name'] ?? ('Episode '.$epNo);
|
||||
$runtime = isset($tmdb['runtime']) ? (int)$tmdb['runtime'] : null;
|
||||
$tmdbId = $tmdb['id'] ?? null;
|
||||
try {
|
||||
if ($tmdbId) {
|
||||
$stmt = $pdo->prepare("INSERT INTO episodes (season_id, tmdb_id, episode_number, name, runtime, json)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE episode_number=VALUES(episode_number), name=VALUES(name), runtime=VALUES(runtime), json=VALUES(json)");
|
||||
$stmt->execute([$seasonId, $tmdbId, $epNo, $name, $runtime, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]);
|
||||
} else {
|
||||
// No tmdb_id
|
||||
$stmt = $pdo->prepare("INSERT INTO episodes (season_id, episode_number, name, runtime, json)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), runtime=VALUES(runtime), json=VALUES(json)");
|
||||
$stmt->execute([$seasonId, $epNo, $name, $runtime, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Fallback if tmdb_id column doesn't exist: always use (season_id, episode_number)
|
||||
if (strpos($e->getMessage(), 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) {
|
||||
$stmt = $pdo->prepare("INSERT INTO episodes (season_id, episode_number, name, runtime, json)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), runtime=VALUES(runtime), json=VALUES(json)");
|
||||
$stmt->execute([$seasonId, $epNo, $name, $runtime, json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]);
|
||||
} else { throw $e; }
|
||||
}
|
||||
$id = $pdo->lastInsertId();
|
||||
if (!$id) {
|
||||
if ($tmdbId) { $q=$pdo->prepare('SELECT id FROM episodes WHERE tmdb_id=?'); $q->execute([$tmdbId]); $id=$q->fetchColumn(); }
|
||||
if (!$id) { $q=$pdo->prepare('SELECT id FROM episodes WHERE season_id=? AND episode_number=?'); $q->execute([$seasonId,$epNo]); $id=$q->fetchColumn(); }
|
||||
}
|
||||
resp(['ok'=>true,'id'=>(int)$id]);
|
||||
}
|
||||
case 'upsert_movie': {
|
||||
$tmdb = $in['tmdb'] ?? null; if (!$tmdb || !isset($tmdb['id'])) fail('missing tmdb payload');
|
||||
$stmt = $pdo->prepare("INSERT INTO movies (tmdb_id, title, original_title, release_year, poster_path, backdrop_path, runtime, json)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE title=VALUES(title), original_title=VALUES(original_title), release_year=VALUES(release_year), poster_path=VALUES(poster_path), backdrop_path=VALUES(backdrop_path), runtime=VALUES(runtime), json=VALUES(json)");
|
||||
$stmt->execute([
|
||||
$tmdb['id'],
|
||||
$tmdb['title'] ?? $tmdb['name'] ?? '',
|
||||
$tmdb['original_title'] ?? $tmdb['original_name'] ?? null,
|
||||
isset($tmdb['release_date']) ? intval(substr($tmdb['release_date'],0,4)) : null,
|
||||
$tmdb['poster_path'] ?? null,
|
||||
$tmdb['backdrop_path'] ?? null,
|
||||
$tmdb['runtime'] ?? null,
|
||||
json_encode($tmdb, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
$id = $pdo->lastInsertId();
|
||||
if (!$id) { $q=$pdo->prepare('SELECT id FROM movies WHERE tmdb_id=?'); $q->execute([$tmdb['id']]); $id=$q->fetchColumn(); }
|
||||
resp(['ok'=>true,'id'=>(int)$id]);
|
||||
}
|
||||
|
||||
case 'set_status': {
|
||||
$type=$in['type'] ?? null;
|
||||
$ref=(int)($in['ref_id'] ?? 0);
|
||||
$st=$in['status'] ?? null; // may be string or int
|
||||
if (!in_array($type,['movie','episode'],true) || !$ref || $st===null) fail('bad params');
|
||||
// Map string statuses to int codes: 0 Init, 1 Progress, 2 Done
|
||||
$stInt = is_numeric($st) ? (int)$st : (function($s){
|
||||
$s = strtolower((string)$s);
|
||||
if ($s==='progress') return 1; if ($s==='done') return 2; return 0;
|
||||
})($st);
|
||||
if ($type==='movie') {
|
||||
$stmt=$pdo->prepare("UPDATE movies SET status=? WHERE id=?");
|
||||
$stmt->execute([$stInt,$ref]);
|
||||
} else {
|
||||
$stmt=$pdo->prepare("UPDATE episodes SET status=? WHERE id=?");
|
||||
$stmt->execute([$stInt,$ref]);
|
||||
}
|
||||
resp(['ok'=>true]);
|
||||
}
|
||||
|
||||
case 'get_list': {
|
||||
$type=$in['type'] ?? 'movie';
|
||||
$status=$in['status'] ?? null;
|
||||
$q=$in['q'] ?? '';
|
||||
$limit=max(1,(int)($in['limit']??50));
|
||||
$offset=max(0,(int)($in['offset']??0));
|
||||
|
||||
if ($type === 'episode') {
|
||||
$statusVal = null;
|
||||
if ($status) {
|
||||
$s = strtolower($status);
|
||||
if ($s==='init') $statusVal = 0; elseif ($s==='progress') $statusVal = 1; elseif ($s==='done') $statusVal = 2;
|
||||
}
|
||||
$base = "FROM episodes e
|
||||
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,
|
||||
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,
|
||||
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.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.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 = [];
|
||||
if ($statusVal !== null) { $sql .= " AND e.status = ?"; $params[] = $statusVal; }
|
||||
if ($q) { $sql .= " AND e.name LIKE ?"; $params[] = "%$q%"; }
|
||||
$sql .= " ORDER BY sh.name ASC, se.season_number ASC, e.episode_number ASC LIMIT ?, ?";
|
||||
$params[] = $offset; $params[] = $limit;
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$i=1; foreach ($params as $p) { $stmt->bindValue($i++, $p, is_int($p)?PDO::PARAM_INT:PDO::PARAM_STR); }
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll();
|
||||
};
|
||||
|
||||
try {
|
||||
$rows = $run($selectWithYearJson);
|
||||
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; }
|
||||
}
|
||||
} else { throw $e; }
|
||||
}
|
||||
}
|
||||
|
||||
if ($type==='movie') {
|
||||
$statusVal = null;
|
||||
if ($status) {
|
||||
$s = strtolower($status);
|
||||
if ($s==='init') $statusVal = 0; elseif ($s==='progress') $statusVal = 1; elseif ($s==='done') $statusVal = 2;
|
||||
}
|
||||
$sql = "SELECT m.*, CASE m.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, m.resolution
|
||||
FROM movies m WHERE 1=1";
|
||||
$params=[];
|
||||
if ($statusVal !== null) { $sql.=" AND m.status=?"; $params[]=$statusVal; }
|
||||
if ($q){$sql.=" AND m.title LIKE ?"; $params[]='%'.$q.'%';}
|
||||
$sql.=" ORDER BY m.title ASC LIMIT ?,?"; $params[]=$offset; $params[]=$limit;
|
||||
$stmt=$pdo->prepare($sql); $i=1; foreach($params as $p){$stmt->bindValue($i++,$p,is_int($p)?PDO::PARAM_INT:PDO::PARAM_STR);} $stmt->execute();
|
||||
resp(['ok'=>true,'items'=>$stmt->fetchAll()]);
|
||||
}
|
||||
fail('unsupported type');
|
||||
}
|
||||
|
||||
case 'get_show_by_tmdb': {
|
||||
$tmdbId = (int)($in['tmdb_id'] ?? 0);
|
||||
if (!$tmdbId) fail('bad params');
|
||||
$stmt = $pdo->prepare('SELECT id FROM shows WHERE tmdb_id = ?');
|
||||
$stmt->execute([$tmdbId]);
|
||||
$id = $stmt->fetchColumn();
|
||||
resp(['ok' => true, 'id' => $id ? (int)$id : null]);
|
||||
}
|
||||
|
||||
case 'ping': {
|
||||
resp([
|
||||
'ok' => true,
|
||||
'time' => date('c'),
|
||||
'origin' => $origin,
|
||||
'php' => PHP_VERSION,
|
||||
]);
|
||||
}
|
||||
|
||||
default: fail('unknown action');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if (MM_DEBUG) resp(['ok'=>false,'error'=>$e->getMessage()], 500);
|
||||
resp(['ok'=>false,'error'=>'server error'], 500);
|
||||
}
|
||||
Reference in New Issue
Block a user