import 'dart:async'; import 'dart:convert'; import 'dart:developer' as developer; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:tetra_stats/data_objects/tetra_stats.dart'; import 'package:tetra_stats/data_objects/tetrio_multiplayer_replay.dart'; import 'package:tetra_stats/main.dart' show packageInfo; import 'package:flutter/foundation.dart'; import 'package:tetra_stats/services/custom_http_client.dart'; import 'package:http/http.dart' as http; import 'package:tetra_stats/services/crud_exceptions.dart'; import 'package:tetra_stats/services/sqlite_db_controller.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:csv/csv.dart'; const String dbName = "TetraStats.db"; const String tetrioUsersTable = "tetrioUsers"; const String tetrioUsersToTrackTable = "tetrioUsersToTrack"; const String tetraLeagueMatchesTable = "tetrioAlphaLeagueMathces"; const String tetrioTLReplayStatsTable = "tetrioTLReplayStats"; const String idCol = "id"; const String replayID = "replayId"; const String nickCol = "nickname"; const String timestamp = "timestamp"; const String endContext1 = "endContext1"; const String endContext2 = "endContext2"; const String statesCol = "jsonStates"; const String player1id = "player1id"; const String player2id = "player2id"; /// Table, that store players data, their stats at some moments of time const String createTetrioUsersTable = ''' CREATE TABLE IF NOT EXISTS "tetrioUsers" ( "id" TEXT UNIQUE, "nickname" TEXT, "jsonStates" TEXT, PRIMARY KEY("id") );'''; /// Table, that store ids of players we need keep track of const String createTetrioUsersToTrack = ''' CREATE TABLE IF NOT EXISTS "tetrioUsersToTrack" ( "id" TEXT NOT NULL UNIQUE, PRIMARY KEY("ID") ) '''; /// Table of Tetra League matches. Each match corresponds with their own players and end contexts const String createTetrioTLRecordsTable = ''' CREATE TABLE IF NOT EXISTS "tetrioAlphaLeagueMathces" ( "id" TEXT NOT NULL UNIQUE, "replayId" TEXT, "player1id" TEXT, "player2id" TEXT, "timestamp" TEXT, "endContext1" TEXT, "endContext2" TEXT, PRIMARY KEY("id") ) '''; /// Table, that contains results of replay analysis in order to not analyze it more, than one time. const String createTetrioTLReplayStats = ''' CREATE TABLE IF NOT EXISTS "tetrioTLReplayStats" ( "id" TEXT NOT NULL, "data" TEXT NOT NULL, "freyhoe" TEXT, PRIMARY KEY("id") ) '''; class CacheController { late Map _cache; late Map _nicknames; CacheController.init(){ _cache = {}; _nicknames = {}; } String _getObjectId(dynamic object){ switch (object.runtimeType){ case TetrioPlayer: object as TetrioPlayer; _nicknames[object.username] = object.userId; return object.userId; case TetrioPlayersLeaderboard: return object.runtimeType.toString()+object.type; case Cutoffs: return object.runtimeType.toString(); case TetrioPlayerFromLeaderboard: // i may be a little stupid return object.runtimeType.toString()+"topone"; case TetraLeagueAlphaStream: return object.runtimeType.toString()+object.userId; case SingleplayerStream: return object.type+object.userId; default: return object.runtimeType.toString()+object.id; } } void store(dynamic object, int? cachedUntil) async { String key = _getObjectId(object) + cachedUntil!.toString(); _cache[key] = object; } dynamic get(String id, Type datatype){ if (_cache.isEmpty) return null; MapEntry? objectEntry; try{ switch (datatype){ case TetrioPlayer: objectEntry = id.length <= 16 ? _cache.entries.firstWhere((element) => element.key.startsWith(_nicknames[id]??"huh?")) : _cache.entries.firstWhere((element) => element.key.startsWith(id)); if (id.length <= 16) id = _nicknames[id]??"huh?"; break; default: objectEntry = _cache.entries.firstWhere((element) => element.key.startsWith(datatype.toString()+id)); id = datatype.toString()+id; break; } } on StateError{ return null; } if (int.parse(objectEntry.key.substring(id.length)) <= DateTime.now().millisecondsSinceEpoch){ _cache.remove(objectEntry.key); return null; }else{ return objectEntry.value; } } void removeOld() async { _cache.removeWhere((key, value) => int.parse(key.substring(_getObjectId(value).length)) <= DateTime.now().millisecondsSinceEpoch); } void reset(){ _cache.clear(); } } class TetrioService extends DB { final Map _players = {}; final _cache = CacheController.init(); // I'm trying to send as less requests, as possible, so i'm caching the results of those requests. final Map _lbPositions = {}; // separate one because attached to the leaderboard /// Thing, that sends every request to the API endpoints final client = kDebugMode ? UserAgentClient("Kagari-chan loves osk (Tetra Stats dev build)", http.Client()) : UserAgentClient("Tetra Stats v${packageInfo.version} (dm @dan63047 if someone abuse that software)", http.Client()); /// We should have only one instanse of this service static final TetrioService _shared = TetrioService._sharedInstance(); factory TetrioService() => _shared; late final StreamController> _tetrioStreamController; TetrioService._sharedInstance() { _tetrioStreamController = StreamController>.broadcast(onListen: () { _tetrioStreamController.sink.add(_players); }); } @override Future open() async { await super.open(); await _loadPlayers(); } Stream> get allPlayers => _tetrioStreamController.stream; /// Loading and sending to the stream everyone. Future _loadPlayers() async { final allPlayers = await getAllPlayerToTrack(); for (var element in allPlayers) { _players[element] = await getNicknameByID(element); } developer.log("_loadPlayers: $_players", name: "services/tetrio_crud"); _tetrioStreamController.add(_players); } /// Removes player entry from tetrioUsersTable with given [id]. /// Can throw an error is player with this id is not exist Future deletePlayer(String id) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final deletedPlayer = await db.delete(tetrioUsersTable, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); if (deletedPlayer != 1) { throw CouldNotDeletePlayer(); } else { _players.removeWhere((key, value) => key == id); _tetrioStreamController.add(_players); } } /// Gets nickname from database or requests it from API if missing. /// Throws an exception if user not exist or request failed. Future getNicknameByID(String id) async { if (id.length <= 16) return id; // nicknames can be up to 16 symbols in length, that's how i'm differentiate nickname from ids try{ await ensureDbIsOpen(); final db = getDatabaseOrThrow(); var request = await db.query(tetrioUsersTable, limit: 1, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); return request.first[nickCol] as String; } catch (e){ return await fetchPlayer(id).then((value) => value.username); } } /// Puts results of replay analysis into a tetrioTLReplayStatsTable Future saveReplayStats(ReplayData replay) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); db.insert(tetrioTLReplayStatsTable, {idCol: replay.id, "data": jsonEncode(replay.toJson())}); } void cacheLeaderboardPositions(String userID, PlayerLeaderboardPosition positions){ _lbPositions[userID] = positions; } PlayerLeaderboardPosition? getCachedLeaderboardPositions(String userID){ return _lbPositions[userID]; } void cacheRoutine(){ _cache.removeOld(); } /// Downloads replay from inoue (szy API). Requiers [replayID]. If request have /// different from 200 statusCode, it will throw an excepction. Returns list, that contains same replay /// as string and as binary. Future szyGetReplay(String replayID) async { // Trying to get it from cache first RawReplay? cached = _cache.get(replayID, RawReplay); if (cached != null) return cached; // If failed, trying to obtain replay from download directory if (!kIsWeb){ // can't obtain download directory on web var downloadPath = await getDownloadsDirectory(); downloadPath ??= Platform.isAndroid ? Directory("/storage/emulated/0/Download") : await getApplicationDocumentsDirectory(); var replayFile = File("${downloadPath.path}/$replayID.ttrm"); if (replayFile.existsSync()) return RawReplay(replayID, replayFile.readAsBytesSync(), replayFile.readAsStringSync()); } // If failed, actually trying to retrieve Uri url; if (kIsWeb) { // Web version sends every request through my php script at the same domain, where Tetra Stats located because of CORS url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioReplay", "replayid": replayID}); } else { // Actually going to hit inoue url = Uri.https('inoue.szy.lol', '/api/replay/$replayID'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: developer.log("szyDownload: Replay $replayID downloaded", name: "services/tetrio_crud"); RawReplay replay = RawReplay(replayID, response.bodyBytes, response.body); DateTime now = DateTime.now(); _cache.store(replay, now.millisecondsSinceEpoch + 3600000); return replay; // if not 200 - throw a unique for each code exception case 404: throw SzyNotFound(); case 403: throw SzyForbidden(); case 429: throw SzyTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw SzyInternalProblem(); default: developer.log("szyDownload: Failed to download a replay $replayID", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { // If local http client fails developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); // just assuming, that our end user don't have acess to the internet } } /// Saves replay with given [replayID] to Download or Documents directory as [replayID].ttrm. Throws an exception, /// if file with name [replayID].ttrm exist, if it fails to get replay or unable to save replay Future saveReplay(String replayID) async { var downloadPath = await getDownloadsDirectory(); downloadPath ??= Platform.isAndroid ? Directory("/storage/emulated/0/Download") : await getApplicationDocumentsDirectory(); var replayFile = File("${downloadPath.path}/$replayID.ttrm"); if (replayFile.existsSync()) throw TetrioReplayAlreadyExist(); RawReplay replay = await szyGetReplay(replayID); await replayFile.writeAsBytes(replay.asBytes); return replayFile.path; } /// Gets replay with given [replayID] and returns some stats about it. If [isAvailable] is false /// or unable to get replay, it will throw an exception Future analyzeReplay(String replayID, bool isAvailable) async{ // trying retirieve existing stats from DB first await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final results = await db.query(tetrioTLReplayStatsTable, where: '$idCol = ?', whereArgs: [replayID]); if (results.isNotEmpty) return ReplayData.fromJson(jsonDecode(results.first["data"].toString())); // if success if (!isAvailable) throw ReplayNotAvalable(); // if replay too old // otherwise, actually going to download a replay and analyze it String replay = (await szyGetReplay(replayID)).asString; Map toAnalyze = jsonDecode(replay); ReplayData data = ReplayData.fromJson(toAnalyze); saveReplayStats(data); // saving to DB for later return data; } /// Retrieves avaliable Tetra League matches from Tetra Channel api. Returns stream object (fake stream). /// Throws an exception if fails to retrieve. Future fetchSingleplayerStream(String userID, String stream) async { SingleplayerStream? cached = _cache.get(userID, SingleplayerStream); if (cached != null) return cached; Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "singleplayerStream", "user": userID.toLowerCase().trim(), "stream": stream}); } else { url = Uri.https('ch.tetr.io', 'api/streams/${stream}_${userID.toLowerCase().trim()}'); } try { final response = await client.get(url); switch (response.statusCode) { case 200: if (jsonDecode(response.body)['success']) { SingleplayerStream records = SingleplayerStream.fromJson(jsonDecode(response.body)['data']['records'], userID, stream); _cache.store(records, jsonDecode(response.body)['cache']['cached_until']); developer.log("fetchSingleplayerStream: $stream $userID stream retrieved and cached", name: "services/tetrio_crud"); return records; } else { developer.log("fetchSingleplayerStream: User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchSingleplayerStream: Failed to fetch stream $stream $userID", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Gets and returns Top TR for a player with given [id]. May return null if player top tr is unknown /// or api is unavaliable (404). May throw an exception, if something else happens. Future fetchTopTR(String id) async { // Trying to get it from cache first TopTr? cached = _cache.get(id, TopTr); if (cached != null) return cached; Uri url; if (kIsWeb) { // Web version sends every request through my php script at the same domain, where Tetra Stats located because of CORS url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "PeakTR", "user": id}); } else { // Actually going to hit p1nkl0bst3r api url = Uri.https('api.p1nkl0bst3r.xyz', 'toptr/$id'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: // ok - return the value TopTr result = TopTr(id, double.tryParse(response.body)); _cache.store(result, DateTime.now().millisecondsSinceEpoch + 300000); return result; case 404: // not found - return null TopTr result = TopTr(id, null); developer.log("fetchTopTR: Probably, player doesn't have top TR", name: "services/tetrio_crud", error: response.statusCode); _cache.store(result, DateTime.now().millisecondsSinceEpoch + 300000); //_topTRcache[(DateTime.now().millisecondsSinceEpoch + 300000).toString()] = {id: null}; return result; // if not 200 or 404 - throw a unique for each code exception case 403: throw P1nkl0bst3rForbidden(); case 429: throw P1nkl0bst3rTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw P1nkl0bst3rInternalProblem(); default: developer.log("fetchTopTR: Failed to fetch top TR", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { // If local http client fails developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); // just assuming, that our end user don't have acess to the internet } } // Sidenote: as you can see, fetch functions looks and works pretty much same way, as described above, // so i'm going to document only unique differences between them Future fetchCutoffs() async { Cutoffs? cached = _cache.get("", Cutoffs); if (cached != null) return cached; Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLCutoffs"}); } else { url = Uri.https('api.p1nkl0bst3r.xyz', 'rankcutoff', {"users": null}); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: Map rawData = jsonDecode(response.body); Map data = rawData["cutoffs"] as Map; Cutoffs result = Cutoffs({}, {}); for (String rank in data.keys){ result.tr[rank] = data[rank]["rating"]; result.glicko[rank] = data[rank]["glicko"]; } _cache.store(result, rawData["ts"] + 300000); return result; case 404: developer.log("fetchCutoffs: Cutoffs are gone", name: "services/tetrio_crud", error: response.statusCode); return null; // if not 200 or 404 - throw a unique for each code exception case 403: throw P1nkl0bst3rForbidden(); case 429: throw P1nkl0bst3rTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw P1nkl0bst3rInternalProblem(); default: developer.log("fetchCutoffs: Failed to fetch top Cutoffs", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { // If local http client fails developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); // just assuming, that our end user don't have acess to the internet } } Future fetchTopOneFromTheLeaderboard() async { TetrioPlayerFromLeaderboard? cached = _cache.get("topone", TetrioPlayerFromLeaderboard); if (cached != null) return cached; Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLTopOne"}); } else { url = Uri.https('ch.tetr.io', 'api/users/lists/league', {"after": "25000", "limit": "1"}); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: var rawJson = jsonDecode(response.body); TetrioPlayerFromLeaderboard result = TetrioPlayerFromLeaderboard.fromJson(rawJson["data"]["users"][0], DateTime.fromMillisecondsSinceEpoch(rawJson["cache"]["cached_at"])); _cache.store(result, rawJson["cache"]["cached_until"]); return result; case 404: throw TetrioPlayerNotExist(); // if not 200 or 404 - throw a unique for each code exception case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw P1nkl0bst3rInternalProblem(); default: developer.log("fetchTopOneFromTheLeaderboard: Failed to fetch top one", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { // If local http client fails developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); // just assuming, that our end user don't have acess to the internet } } /// Retrieves Tetra League history from p1nkl0bst3r api for a player with given [id]. Returns a list of states /// (state = instance of [TetrioPlayer] at some point of time). Can throw an exception if fails to retrieve data. Future> fetchAndsaveTLHistory(String id) async { Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLHistory", "user": id}); } else { url = Uri.https('api.p1nkl0bst3r.xyz', 'tlhist/$id'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: // that one api returns csv instead of json List> csv = const CsvToListConverter().convert(response.body)..removeAt(0); List history = []; // doesn't return nickname, need to retrieve it separately String nick = await getNicknameByID(id); for (List entry in csv){ // each entry is one state TetrioPlayer state = TetrioPlayer( userId: id, username: nick, role: "p1nkl0bst3r", state: DateTime.parse(entry[9]), badges: [], friendCount: -1, gamesPlayed: -1, gamesWon: -1, gameTime: const Duration(seconds: -1), xp: -1, supporterTier: 0, verified: false, connections: null, tlSeason1: TetraLeagueAlpha( timestamp: DateTime.parse(entry[9]), apm: entry[6] != '' ? entry[6] : null, pps: entry[7] != '' ? entry[7] : null, vs: entry[8] != '' ? entry[8] : null, glicko: entry[4], rd: noTrRd, gamesPlayed: entry[1], gamesWon: entry[2], bestRank: "z", decaying: false, rating: entry[3], rank: entry[5], percentileRank: entry[5], percentile: rankCutoffs[entry[5]]!, standing: -1, standingLocal: -1, nextAt: -1, prevAt: -1 ), sprint: [], blitz: [] ); history.add(state); } // trying to dump it to local DB await ensureDbIsOpen(); final db = getDatabaseOrThrow(); List states = await getPlayer(id); if (states.isEmpty) await createPlayer(history.first); states.insertAll(0, history.reversed); final Map statesJson = {}; for (var e in states) { // making one big json out of this list statesJson.addEntries({(e.state.millisecondsSinceEpoch ~/ 1000).toString(): e.toJson()}.entries); } // and putting it to local DB await db.update(tetrioUsersTable, {idCol: id, nickCol: nick, statesCol: jsonEncode(statesJson)}, where: '$idCol = ?', whereArgs: [id]); return history; case 404: developer.log("fetchTLHistory: Probably, history doesn't exist", name: "services/tetrio_crud", error: response.statusCode); throw TetrioHistoryNotExist(); case 403: throw P1nkl0bst3rForbidden(); case 429: throw P1nkl0bst3rTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw P1nkl0bst3rInternalProblem(); default: developer.log("fetchTLHistory: Failed to fetch history", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Docs later Future> fetchAndSaveOldTLmatches(String userID) async { Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLMatches", "user": userID}); } else { url = Uri.https('api.p1nkl0bst3r.xyz', 'tlmatches/$userID'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: // that one api returns csv instead of json List> csv = const CsvToListConverter().convert(response.body)..removeAt(0); List matches = []; // parsing data into TetraLeagueAlphaRecord objects for (var entry in csv){ TetraLeagueAlphaRecord match = TetraLeagueAlphaRecord( replayId: entry[0].toString(), ownId: entry[0].toString(), // i gonna disting p1nkl0bst3r entries with it timestamp: DateTime.parse(entry[1]), endContext: [ EndContextMulti( userId: entry[2].toString(), username: entry[3].toString(), naturalOrder: 0, inputs: -1, piecesPlaced: -1, handling: Handling(arr: -1, das: -1, sdf: -1, dcd: 0, cancel: true, safeLock: true), points: entry[4], wins: entry[4], secondary: entry[6], secondaryTracking: [], tertiary: entry[5], tertiaryTracking: [], extra: entry[7], extraTracking: [], success: true ), EndContextMulti( userId: entry[8].toString(), username: entry[9].toString(), naturalOrder: 1, inputs: -1, piecesPlaced: -1, handling: Handling(arr: -1, das: -1, sdf: -1, dcd: 0, cancel: true, safeLock: true), points: entry[10], wins: entry[10], secondary: entry[12], secondaryTracking: [], tertiary: entry[11], tertiaryTracking: [], extra: entry[13], extraTracking: [], success: false ) ], replayAvalable: false ); matches.add(match); } // trying to dump it to local DB TetraLeagueAlphaStream fakeStream = TetraLeagueAlphaStream(userId: userID, records: matches); saveTLMatchesFromStream(fakeStream); return matches; case 404: developer.log("fetchAndSaveOldTLmatches: Probably, history doesn't exist", name: "services/tetrio_crud", error: response.statusCode); throw TetrioHistoryNotExist(); case 403: throw P1nkl0bst3rForbidden(); case 429: throw P1nkl0bst3rTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw P1nkl0bst3rInternalProblem(); default: developer.log("fetchAndSaveOldTLmatches: Failed to fetch history", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Retrieves full Tetra League leaderboard from Tetra Channel api. Returns a leaderboard object. Throws an exception if fails to retrieve. Future fetchTLLeaderboard() async { TetrioPlayersLeaderboard? cached = _cache.get("league", TetrioPlayersLeaderboard); if (cached != null) return cached; Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLLeaderboard"}); } else { url = Uri.https('ch.tetr.io', 'api/users/lists/league/all'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: _lbPositions.clear(); var rawJson = jsonDecode(response.body); if (rawJson['success']) { // if api confirmed that everything ok TetrioPlayersLeaderboard leaderboard = TetrioPlayersLeaderboard.fromJson(rawJson['data']['users'], "league", DateTime.fromMillisecondsSinceEpoch(rawJson['cache']['cached_at'])); developer.log("fetchTLLeaderboard: Leaderboard retrieved and cached", name: "services/tetrio_crud"); //_leaderboardsCache[rawJson['cache']['cached_until'].toString()] = leaderboard; _cache.store(leaderboard, rawJson['cache']['cached_until']); return leaderboard; } else { // idk how to hit that one developer.log("fetchTLLeaderboard: Bruh", name: "services/tetrio_crud", error: rawJson); throw Exception("Failed to get leaderboard (problems on the tetr.io side)"); // will it be on tetr.io side? } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchTLLeaderboard: Failed to fetch leaderboard", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } // i want to know progress, so i trying to figure out this thing: // Stream fetchTLLeaderboardAsStream() async { // TetrioPlayersLeaderboard? cached = _cache.get("league", TetrioPlayersLeaderboard); // if (cached != null) return cached; // Uri url; // if (kIsWeb) { // url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLLeaderboard"}); // } else { // url = Uri.https('ch.tetr.io', 'api/users/lists/league/all'); // } // Stream stream = http.StreamedRequest("GET", url); // } TetrioPlayersLeaderboard? getCachedLeaderboard(){ return _cache.get("league", TetrioPlayersLeaderboard); } /// Retrieves and returns 100 latest news entries from Tetra Channel api for given [userID]. Throws an exception if fails to retrieve. Future fetchNews(String userID) async{ News? cached = _cache.get(userID, News); if (cached != null) return cached; Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioNews", "user": userID.toLowerCase().trim(), "limit": "100"}); } else { url = Uri.https('ch.tetr.io', 'api/news/user_${userID.toLowerCase().trim()}', {"limit": "100"}); } try { final response = await client.get(url); switch (response.statusCode) { case 200: var payload = jsonDecode(response.body); if (payload['success']) { // if api confirmed that everything ok News news = News.fromJson(payload['data'], userID); _cache.store(news, payload['cache']['cached_until']); developer.log("fetchNews: $userID news retrieved and cached", name: "services/tetrio_crud"); return news; } else { developer.log("fetchNews: User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchNews: Failed to fetch stream", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Retrieves avaliable Tetra League matches from Tetra Channel api. Returns stream object (fake stream). /// Throws an exception if fails to retrieve. Future fetchTLStream(String userID) async { TetraLeagueAlphaStream? cached = _cache.get(userID, TetraLeagueAlphaStream); if (cached != null) return cached; Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioUserTL", "user": userID.toLowerCase().trim()}); } else { url = Uri.https('ch.tetr.io', 'api/streams/league_userrecent_${userID.toLowerCase().trim()}'); } try { final response = await client.get(url); switch (response.statusCode) { case 200: if (jsonDecode(response.body)['success']) { TetraLeagueAlphaStream stream = TetraLeagueAlphaStream.fromJson(jsonDecode(response.body)['data']['records'], userID); _cache.store(stream, jsonDecode(response.body)['cache']['cached_until']); developer.log("fetchTLStream: $userID stream retrieved and cached", name: "services/tetrio_crud"); return stream; } else { developer.log("fetchTLStream User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchTLStream Failed to fetch stream", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Saves Tetra League Matches from [stream] to the local DB. Future saveTLMatchesFromStream(TetraLeagueAlphaStream stream) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); for (TetraLeagueAlphaRecord match in stream.records) { // putting then one by one final results = await db.query(tetraLeagueMatchesTable, where: '$replayID = ?', whereArgs: [match.replayId]); if (results.isNotEmpty) continue; // if match alreay exist - skip db.insert(tetraLeagueMatchesTable, { idCol: match.ownId, replayID: match.replayId, timestamp: match.timestamp.toString(), player1id: match.endContext.first.userId, player2id: match.endContext.last.userId, endContext1: jsonEncode(match.endContext.first.toJson()), endContext2: jsonEncode(match.endContext.last.toJson()) }); } } /// Deletes duplicate entries of Tetra League matches from local DB. Future removeDuplicatesFromTLMatches() async{ await ensureDbIsOpen(); final db = getDatabaseOrThrow(); await db.execute(""" DELETE FROM $tetraLeagueMatchesTable WHERE $idCol IN ( SELECT $idCol FROM ( SELECT $idCol, ROW_NUMBER() OVER ( PARTITION BY $replayID ORDER BY $replayID) AS row_num FROM $tetraLeagueMatchesTable ) t WHERE row_num > 1 ); """); } /// Gets and returns a list of matches from local DB for a given [playerID]. Future> getTLMatchesbyPlayerID(String playerID) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); List matches = []; final results = await db.query(tetraLeagueMatchesTable, where: '($player1id = ?) OR ($player2id = ?)', whereArgs: [playerID, playerID]); for (var match in results){ matches.add(TetraLeagueAlphaRecord( ownId: match[idCol].toString(), replayId: match[replayID].toString(), timestamp: DateTime.parse(match[timestamp].toString()), endContext:[ EndContextMulti.fromJson(jsonDecode(match[endContext1].toString())), EndContextMulti.fromJson(jsonDecode(match[endContext2].toString())) ], replayAvalable: false )); } return matches; } /// Gets and returns an amount of stored Tetra League mathes between [ourPlayerID] and [enemyPlayerID]. Future getNumberOfTLMatchesBetweenPlayers(String ourPlayerID, String enemyPlayerID) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final results = await db.rawQuery("SELECT COUNT(*) from tetrioAlphaLeagueMathces WHERE (player1id = $ourPlayerID AND player2id = $enemyPlayerID) OR (player1id = $enemyPlayerID AND player2id = $ourPlayerID)"); return results.first.values.first as int; } /// Deletes match and stats of that match with given [matchID] from local DB. Throws an exception if fails. Future deleteTLMatch(String matchID) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final rID = (await db.query(tetraLeagueMatchesTable, where: '$idCol = ?', whereArgs: [matchID])).first[replayID]; final results = await db.delete(tetraLeagueMatchesTable, where: '$idCol = ?', whereArgs: [matchID]); if (results != 1) { throw CouldNotDeleteMatch(); } await db.delete(tetrioTLReplayStatsTable, where: '$idCol = ?', whereArgs: [rID]); } /// Retrieves Blitz, 40 Lines and Zen records for a given [userID] from Tetra Channel api. Returns `UserRecords`. /// Throws an exception if fails to retrieve. Future fetchRecords(String userID) async { UserRecords? cached = _cache.get(userID, UserRecords); if (cached != null) return cached; Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioUserRecords", "user": userID.toLowerCase().trim()}); } else { url = Uri.https('ch.tetr.io', 'api/users/${userID.toLowerCase().trim()}/records'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: if (jsonDecode(response.body)['success']) { Map jsonRecords = jsonDecode(response.body); var sprint = jsonRecords['data']['records']['40l']['record'] != null ? RecordSingle.fromJson(jsonRecords['data']['records']['40l']['record'], jsonRecords['data']['records']['40l']['rank']) : null; var blitz = jsonRecords['data']['records']['blitz']['record'] != null ? RecordSingle.fromJson(jsonRecords['data']['records']['blitz']['record'], jsonRecords['data']['records']['blitz']['rank']) : null; var zen = TetrioZen.fromJson(jsonRecords['data']['zen']); UserRecords result = UserRecords(userID, sprint, blitz, zen); _cache.store(result, jsonDecode(response.body)['cache']['cached_until']); developer.log("fetchRecords: $userID records retrieved and cached", name: "services/tetrio_crud"); return result; } else { developer.log("fetchRecords User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchRecords Failed to fetch records", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Creates an entry in local DB for [tetrioPlayer]. Throws an exception if that player already here. Future createPlayer(TetrioPlayer tetrioPlayer) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); // checking if its already here final results = await db.query(tetrioUsersTable, limit: 1, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId.toLowerCase()]); if (results.isNotEmpty) { throw TetrioPlayerAlreadyExist(); } // converting to json and store final Map statesJson = {(tetrioPlayer.state.millisecondsSinceEpoch ~/ 1000).toString(): tetrioPlayer.toJson()}; db.insert(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)}); _players.addEntries({tetrioPlayer.userId: tetrioPlayer.username}.entries); _tetrioStreamController.add(_players); } /// Adds user id of [tetrioPlayer] to the [tetrioUsersToTrackTable] of database. Future addPlayerToTrack(TetrioPlayer tetrioPlayer) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final results = await db.query(tetrioUsersToTrackTable, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId.toLowerCase()]); if (results.isNotEmpty) { throw TetrioPlayerAlreadyExist(); } db.insert(tetrioUsersToTrackTable, {idCol: tetrioPlayer.userId}); } /// Returns bool, which tells whether is given [id] is in [tetrioUsersToTrackTable]. Future isPlayerTracking(String id) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final results = await db.query(tetrioUsersToTrackTable, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); return results.isNotEmpty; } /// Returns Iterable with user ids of players who is tracked. Future> getAllPlayerToTrack() async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final players = await db.query(tetrioUsersToTrackTable); return players.map((noteRow) => noteRow["id"].toString()); } /// Removes user with given [id] from the [tetrioUsersToTrackTable] of database. Future deletePlayerToTrack(String id) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final deletedPlayer = await db.delete(tetrioUsersToTrackTable, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); if (deletedPlayer != 1) { throw CouldNotDeletePlayer(); } else { _players.removeWhere((key, value) => key == id); _tetrioStreamController.add(_players); } } /// Saves state (which is [tetrioPlayer]) to the local database. Future storeState(TetrioPlayer tetrioPlayer) async { // if tetrio player doesn't have entry in database - just calling different function List states = await getPlayer(tetrioPlayer.userId); if (states.isEmpty) { await createPlayer(tetrioPlayer); return; } // we not going to add state, that is same, as the previous if (!states.last.isSameState(tetrioPlayer)) states.add(tetrioPlayer); // Making map of the states final Map statesJson = {}; for (var e in states) { // Saving in format: {"unix_seconds": json_of_state} statesJson.addEntries({(e.state.millisecondsSinceEpoch ~/ 1000).toString(): e.toJson()}.entries); } // Rewrite our database await ensureDbIsOpen(); final db = getDatabaseOrThrow(); await db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)}, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId]); } /// Remove state (which is [tetrioPlayer]) from the local database Future deleteState(TetrioPlayer tetrioPlayer) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); List states = await getPlayer(tetrioPlayer.userId); // removing state from map that contain every state of each user states.removeWhere((element) => element.state == tetrioPlayer.state); // Making map of the states (without deleted one) final Map statesJson = {}; for (var e in states) { statesJson.addEntries({(e.state.millisecondsSinceEpoch ~/ 1000).toString(): e.toJson()}.entries); } // Rewriting database entry with new json await db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)}, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId]); _tetrioStreamController.add(_players); } /// Returns list of all states of player with given [id] from database. Can return empty list if player /// was not found. Future> getPlayer(String id) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); List states = []; final results = await db.query(tetrioUsersTable, limit: 1, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); if (results.isEmpty) { return states; // it empty } else { dynamic rawStates = results.first['jsonStates'] as String; rawStates = json.decode(rawStates); // recreating objects of states rawStates.forEach((k, v) => states.add(TetrioPlayer.fromJson(v, DateTime.fromMillisecondsSinceEpoch(int.parse(k) * 1000), id, results.first[nickCol] as String))); // updating the stream _players.removeWhere((key, value) => key == id); _players.addEntries({states.last.userId: states.last.username}.entries); _tetrioStreamController.add(_players); return states; } } /// Retrieves general stats of [user] (nickname or id) from Tetra Channel api. Returns [TetrioPlayer] object of this user. /// If [isItDiscordID] is true, function expects [user] to be a discord user id. Throws an exception if fails to retrieve. Future fetchPlayer(String user, {bool isItDiscordID = false}) async { TetrioPlayer? cached = _cache.get(user, TetrioPlayer); if (cached != null) return cached; if (isItDiscordID){ // trying to find player with given discord id Uri dUrl; if (kIsWeb) { dUrl = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioUserByDiscordID", "user": user.toLowerCase().trim()}); } else { dUrl = Uri.https('ch.tetr.io', 'api/users/search/${user.toLowerCase().trim()}'); } try{ final response = await client.get(dUrl); switch (response.statusCode) { case 200: var json = jsonDecode(response.body); if (json['success'] && json['data'] != null) { // success - rewrite user with tetrio user id and going to obtain data about him user = json['data']['user']['_id']; } else { // fail - throw an exception developer.log("fetchPlayer User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioDiscordNotExist(); } break; // more exceptions to god of exceptions case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchPlayer Failed to fetch player", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } // finally going to obtain Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioUser", "user": user.toLowerCase().trim()}); } else { url = Uri.https('ch.tetr.io', 'api/users/${user.toLowerCase().trim()}'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: var json = jsonDecode(response.body); if (json['success']) { // parse and count stats TetrioPlayer player = TetrioPlayer.fromJson(json['data']['user'], DateTime.fromMillisecondsSinceEpoch(json['cache']['cached_at'], isUtc: true), json['data']['user']['_id'], json['data']['user']['username'], DateTime.fromMillisecondsSinceEpoch(json['cache']['cached_until'], isUtc: true)); _cache.store(player, json['cache']['cached_until']); developer.log("fetchPlayer: $user retrieved and cached", name: "services/tetrio_crud"); return player; } else { developer.log("fetchPlayer User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchPlayer Failed to fetch player", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } }on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Retrieves whole [tetrioUsersTable] and returns Map with [TetrioPlayer] objects of everyone in database Future>> getAllPlayers() async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final players = await db.query(tetrioUsersTable); Map> data = {}; for (var entry in players){ var test = json.decode(entry['jsonStates'] as String); List states = []; test.forEach((k, v) => states.add(TetrioPlayer.fromJson(v, DateTime.fromMillisecondsSinceEpoch(int.parse(k) * 1000), entry[idCol] as String, entry[nickCol] as String))); data.addEntries({states.last.userId: states}.entries); } return data; } Future fetchTracked() async { for (String userID in (await getAllPlayerToTrack())) { TetrioPlayer player = await fetchPlayer(userID); storeState(player); sleep(Durations.extralong4); TetraLeagueAlphaStream matches = await fetchTLStream(userID); saveTLMatchesFromStream(matches); sleep(Durations.extralong4); } } }