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> _post(Map body) async { Future sendJson(Map p) { final prev = _dio.options.contentType; _dio.options.contentType = Headers.jsonContentType; return _dio.post('', data: p).whenComplete(() { _dio.options.contentType = prev; }); } Future sendForm(Map 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? 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; } if (res == null || res.statusCode != 200) { // ignore: avoid_print final sc = res?.statusCode; final data = res?.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 && map['success'] != 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>> 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>(); } Future>> getGames( {String? status, String? q, int offset = 0, int limit = 50, String? lang}) async { final map = await _post({ 'action': 'get_list', 'type': 'game', if (status != null) 'status': status, if (q != null && q.isNotEmpty) 'q': q, if (lang != null && lang.isNotEmpty) 'lang': lang, 'offset': offset, 'limit': limit, }); return (map['items'] as List).cast>(); } Future 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 upsertMovie(Map tmdbJson) async { await _post({ 'action': 'upsert_movie', 'tmdb': tmdbJson, }); } Future setMovieResolution({required int movieId, required String resolution}) async { await _post({ 'action': 'set_movie_resolution', 'movie_id': movieId, 'resolution': resolution, }); } Future deleteMovie(int movieId) async { await _post({'action': 'delete_movie', 'movie_id': movieId}); } Future>> 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>(); } Future upsertShow(Map tmdbJson) async { final map = await _post({'action': 'upsert_show', 'tmdb': tmdbJson}); return (map['id'] as num).toInt(); } Future upsertSeason(int showId, Map seasonJson) async { final map = await _post( {'action': 'upsert_season', 'show_id': showId, 'tmdb': seasonJson}); return (map['id'] as num).toInt(); } Future upsertEpisode( int seasonId, Map episodeJson) async { final map = await _post({ 'action': 'upsert_episode', 'season_id': seasonId, 'tmdb': episodeJson }); return (map['id'] as num).toInt(); } Future upsertEpisodesBulk(int seasonId, List> episodes) async { final map = await _post({ 'action': 'upsert_episodes_bulk', 'season_id': seasonId, 'episodes': episodes, }); return (map['count'] as num?)?.toInt() ?? 0; } Future> upsertSeasonsBulk(int showId, List> seasons) async { final map = await _post({ 'action': 'upsert_seasons_bulk', 'show_id': showId, 'seasons': seasons, }); final m = {}; final any = map['map']; if (any is Map) { for (final e in any.entries) { final k = int.tryParse(e.key.toString()); final v = (e.value as num?)?.toInt(); if (k != null && v != null) m[k] = v; } } return m; } Future> getCapabilities() async { final map = await _post({'action': 'get_capabilities'}); final caps = map['capabilities']; return caps is Map ? caps : (caps as Map).cast(); } Future 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(); } Future>> listShows() async { final map = await _post({'action': 'list_shows'}); return (map['items'] as List).cast>(); } Future getTmdbIdByShowId(int showId) async { final map = await _post({'action': 'get_tmdb_by_show_id', 'show_id': showId}); final v = map['tmdb_id']; return v == null ? null : (v as num).toInt(); } Future setShowMeta({ required int showId, String? resolution, String? downloadPath, bool? cliffhanger, }) async { final payload = { 'action': 'set_show_meta', 'show_id': showId, if (resolution != null) 'resolution': resolution, if (downloadPath != null) 'download_path': downloadPath, if (cliffhanger != null) 'cliffhanger': cliffhanger, }; await _post(payload); } Future>> getSeriesSummary() async { final map = await _post({'action': 'get_series_summary'}); return (map['items'] as List).cast>(); } Future>> getShowEpisodes(int showId) async { final map = await _post({'action': 'get_show_episodes', 'show_id': showId}); return (map['items'] as List).cast>(); } Future deleteShow(int showId) async { await _post({'action': 'delete_show', 'show_id': showId}); } Future deleteGame(int igdbId) async { await _post({'action': 'delete_game', 'igdb_id': igdbId}); } Future> setGameStatus({ required int igdbId, required String name, String? originalName, required int status, // 0=Init,1=Progress,2=Done String? note, String? lang, String? title, String? summary, String? storyline, String? coverUrl, int? releaseYear, bool? locked, Map? json, }) async { final map = await _post({ 'action': 'set_game_status', 'igdb_id': igdbId, 'name': name, if (originalName != null) 'original_name': originalName, 'status': status, if (note != null) 'note': note, if (lang != null && lang.isNotEmpty) 'lang': lang, if (title != null) 'title': title, if (summary != null) 'summary': summary, if (storyline != null) 'storyline': storyline, if (coverUrl != null) 'cover_url': coverUrl, if (releaseYear != null) 'release_year': releaseYear, if (locked != null) 'locked': locked, if (json != null) 'json': json, }); return (map['game'] as Map).cast(); } }