diff --git a/README.md b/README.md index 57abd2e..b19e89d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ multiplayer info, and rendered osu! images. It is the backend for [Seira](https://github.com/BotSeira/SeiraCore) bot, and also provides a standalone API for other clients to consume. +Image endpoints also return JSON when requested with `Accept: application/json`. +See [response formats and supported endpoints](docs/image-responses.md). + ## What You Get - PNG score panels for best and recent scores, beatmap, beatmapset, and so on! @@ -100,9 +103,15 @@ curl "http://localhost:8721/bp?u=12345678&n=20" --output best_of_20.png Base URL: `http://localhost:` -Most JSON endpoints return: `{"success": boolean, "message": string, "data": any}`. +Most JSON endpoints return: `{"success": boolean, "message": string, "data": object}`. +Array results are wrapped as `{"data":{"result":[...]}}` instead of being placed directly in `data`. Image endpoints return PNG bytes. Replay download returns `video/mp4`. +When `ostella.token` is configured, every request must include +`Authorization: Bearer `. Endpoints that need a player's osu! OAuth +credential use the separate `X-Osu-Authorization: Bearer ` header; +the player credential must not replace the oStella service header. + ### Beatmaps | Method | Path | Purpose | Params / POST Body | Response | @@ -136,22 +145,22 @@ Image endpoints return PNG bytes. Replay download returns `video/mp4`. ### Multiplayer Rooms -| Method | Path | Purpose | Params / POST Body | Response | -|--------|-----------------------------------|----------------------------|-------------------------------|----------| -| GET | `/multiplayer/rooms/current` | Current multiplayer room | Requires Authorization Header | JSON | -| GET | `/multiplayer/rooms/current/item` | Current room playlist item | Requires Authorization Header | JSON | +| Method | Path | Purpose | Params / POST Body | Response | +|--------|-----------------------------------|----------------------------|---------------------------------------------------------|----------| +| GET | `/multiplayer/rooms/current` | Current multiplayer room | Requires `X-Osu-Authorization: Bearer ` | JSON | +| GET | `/multiplayer/rooms/current/item` | Current room playlist item | Requires `X-Osu-Authorization: Bearer ` | JSON | ### Users -| Method | Path | Purpose | Params / POST Body | Response | -|--------|-------------------------------------|-------------------------------|--------------------------------------------------|----------| -| POST | `/users` | Get multiple user data | POST Body `{"ids":[user ids]}` | JSON | -| GET | `/users/me` | User data | Requires Authorization Header | JSON | -| GET | `/users/me/friends` | Friends list for user | Requires Authorization Header | JSON | -| POST | `/users/leaderboards` | User PP leaderboard image | `{"uids":[user ids]}` | PNG | -| GET | `/users/{userId}/scores/bestof` | Best-of-N scores image | path `userId`, query `n` (count) | PNG | -| GET | `/users/{userId}/scores/recent` | Recent scores image | path `userId`, query `n` (count) | PNG | -| GET | `/users/{userId}/scores/today-best` | Best scores achieved recently | path `userId`, optional query `days` (default 1) | PNG | +| Method | Path | Purpose | Params / POST Body | Response | +|--------|-------------------------------------|-------------------------------|---------------------------------------------------------|----------| +| POST | `/users` | Get multiple user data | POST Body `{"ids":[user ids]}` | JSON | +| GET | `/users/me` | User data | Requires `X-Osu-Authorization: Bearer ` | JSON | +| GET | `/users/me/friends` | Friends list for user | Requires `X-Osu-Authorization: Bearer ` | JSON | +| POST | `/users/leaderboards` | User PP leaderboard image | `{"uids":[user ids]}` | PNG | +| GET | `/users/{userId}/scores/bestof` | Best-of-N scores image | path `userId`, query `n` (count) | PNG | +| GET | `/users/{userId}/scores/recent` | Recent scores image | path `userId`, query `n` (count) | PNG | +| GET | `/users/{userId}/scores/today-best` | Best scores achieved recently | path `userId`, optional query `days` (default 1) | PNG | ### Replays (enabled when `replayRender.enabled` is true) diff --git a/docs/console.md b/docs/console.md index c0ef06b..c64a7bb 100644 --- a/docs/console.md +++ b/docs/console.md @@ -2,25 +2,42 @@ oStella uses Log4J2 for service output and JLine for interactive input, history, completion, and prompt-safe log redraw. Console text is English to avoid terminal encoding problems. -| Command | Purpose | -| --- | --- | -| `status` | Web, token, HTTP, async, renderer, replay-worker, cache, and uptime health | -| `metrics` | HTTP and asynchronous work counters | -| `token status` | Show osu! API token health | -| `token renew` | Queue an immediate token renewal | -| `replay status` | Probe configured osuRenderer queues | -| `replay job ` | Find a remote render job | -| `replay delete confirm` | Delete a remote render job | -| `cache status` | Show file count and size for every cache area | -| `cache ` | Operate on local cache and aggregate every configured osuRenderer worker | -| `cache clear confirm` | Clear `beatmaps`, `images`, `replays`, `score-json`, `beatmapsets`, or `all` | -| `config show` | Show effective configuration with credentials redacted | -| `config check` | Validate `config.yml` without applying it | -| `log show` | Show the current Log4J2 root level | -| `log level ` | Set `trace`, `debug`, `info`, `warn`, or `error` until restart | -| `system` | Show version, JVM, OS, threads, memory, and uptime | -| `stop confirm` | Gracefully stop the console and every owned service | +| Command | Purpose | +|-------------------------------------------------------------------------|------------------------------------------------------------------------------| +| `status` | Web, token, HTTP, async, renderer, replay-worker, cache, and uptime health | +| `metrics` | HTTP and asynchronous work counters | +| `token status` | Show osu! API token health | +| `token renew` | Queue an immediate token renewal | +| `replay status` | Probe configured osuRenderer queues | +| `replay job ` | Find a remote render job | +| `replay delete confirm` | Delete a remote render job | +| `cache status` | Show file count and size for every cache area | +| `cache ` | Operate on local cache and aggregate every configured osuRenderer worker | +| `cache clear confirm` | Clear `beatmaps`, `images`, `replays`, `score-json`, `beatmapsets`, or `all` | +| `config show` | Show effective configuration with credentials redacted | +| `config check` | Validate `config.yml` without applying it | +| `log show` | Show the current Log4J2 root level | +| `log level ` | Set `trace`, `debug`, `info`, `warn`, or `error` until restart | +| `system` | Show version, JVM, OS, threads, memory, and uptime | +| `stop confirm` | Gracefully stop the console and every owned service | Use `help [command]` and Tab completion in the running console. Bulk cache clearing and service shutdown require the literal `confirm` argument; unified single-ID cache deletion uses the four-part command directly. For the unified cache command, `query` returns the chain presence matrix, `get` adds path/size/time metadata, `delete` removes all reachable copies, and `fetch` downloads into oStella before pushing beatmapsets or replays to every worker. Score and beatmap caches are oStella-only and therefore appear as `N/A` on workers. + +## Automatic caching + +`autocache ` toggles each type independently. All four default to off, and settings last until restart. For example: + +```text +autocache beatmapset-json on +autocache beatmap-json on +autocache beatmap on +autocache beatmapset off +``` + +The worker discovers distinct user IDs from currently cached score JSON files and fetches their best 200 osu!standard scores in two pages of at most 100. It prefetches missing `.osz` archives, full beatmapset JSON, `.osu` files, or full beatmap JSON according to the enabled types. Shared maps/sets reuse the existing local cache; it does not push files to rendering workers. + +Work starts only after five seconds without foreground API activity, including queued requests and replay requests. One page or cache item is processed per second, subject to the existing API rate limit. Direct API calls, including token renewal, also count as activity. Foreground calls never wait for the background download; an already-started download may finish, but subsequent requests pause until idle again. Turning a type off prevents further downloads of that type; shutdown interrupts the worker. + +Cached-score users are rediscovered every five minutes when the current queue drains. Each user's best scores are refreshed at most hourly; missing or failed resources are retried on a later refresh. Changing a toggle restarts discovery. Malformed cached score files are skipped. Downloaded beatmapsets can use substantial disk space; there is no automatic eviction. diff --git a/docs/image-responses.md b/docs/image-responses.md new file mode 100644 index 0000000..fe4d457 --- /dev/null +++ b/docs/image-responses.md @@ -0,0 +1,65 @@ +# Image and JSON responses + +Image endpoints support an explicit JSON opt-in using `Accept: application/json`. +URLs, query parameters, authentication, filtering, and ordering remain the same. + +```sh +curl -H 'Accept: application/json' 'http://localhost:8080/users/123' +curl -H 'Accept: application/json' 'http://localhost:8080/beatmapsets/456' +curl -H 'Accept: application/json' 'http://localhost:8080/users/123/scores/bestof?n=5' +``` + +Use your configured server port. JSON responses use the existing envelope: + +```json +{"success":true,"message":"Success","data":{}} +``` + +When an endpoint returns an array, it is exposed as `data.result`, for example +`{"success":true,"message":"Success","data":{"result":[]}}`. + +The response has `Content-Type: application/json`. Rendered images have +`Content-Type: image/png`; background downloads retain their image format. +Negotiated responses include `Vary: Accept` so caches distinguish representations. + +An absent `Accept`, `*/*`, or an image media type keeps the image response. +Media types are case-insensitive; parameters and comma-separated media ranges are +supported. An explicit `application/json` with a valid positive `q` value opts in, +even when images are also listed. `application/json;q=0` does not opt in. +Wildcard media types do not opt in to JSON. + +## Supported endpoints and `data` + +| Endpoint | JSON data | +|--------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------| +| `GET /users/{userId}` | User object; no best-score request is made | +| `GET /beatmapsets/{beatmapsetId}` | Beatmapset object with difficulties ordered by stars | +| `GET /beatmaps/{beatmapId}` | Beatmap object with beatmapset metadata | +| `GET /scores/{scoreId}` | Score object | +| `GET /users/{userId}/scores/recent` | `user`, filtered `scores`, `type`, `filters`, and original `positions` | +| `GET /users/{userId}/scores/bestof` | Same score-list structure | +| `GET /users/{userId}/scores/today-best` | Same structure, plus `title` describing the time window | +| `POST /users/leaderboards` | `result` array containing users sorted by osu! pp | +| `POST /beatmaps/{beatmapId}/leaderboards` | `beatmap` and sorted `placements` | +| `GET /beatmaps/{beatmapId}/analysis` | `beatmap`, `diff`, `mods`, `performance`, and `patterns` | +| `GET /scores/{scoreId}/analysis` | Score, difficulty, hit/miss positions, timing errors, unstable rate, performance graphs, PP+, and simulation results | +| `GET /scores/{scoreId}/misses/{missIndex}/visualize` | Miss index, beatmap ID, object index, time, type, nearby keyframes, difficulty, and PP loss estimates | +| `GET /multiplayer/rooms/{roomId}/playlist/{playlistItemId}/result` | Multiplayer result data including players, teams, and series scores | +| `POST /templates/{templateName}/render` | Resolved template variables, including `@score`, `@user`, `@beatmap`, and `@beatmapset` references | +| `GET /beatmapsets/{beatmapsetId}/background` | `beatmapset_id` and cover `url`; no image download | +| `GET /beatmaps/{beatmapId}/background` | `beatmapId`, `beatmapsetId`, and background `fileName`; no archive extraction | + +osu! model fields retain their existing JSON names. New composite data uses the +field names listed above; optional null fields may be omitted. The nested +beatmapset in a beatmap response omits `beatmaps` to avoid circular references. +Score analysis excludes the parser's complete replay/beatmap objects. + +JSON requests skip HTML, screenshots, and the browser render queue. Basic score, +beatmap, and map leaderboard JSON requests also skip image-only difficulty +calculations. Analysis endpoints still perform the analysis and require the same +replay/PP+ dependencies as their image counterparts. The basic beatmap JSON is +the API model; the `mod` parameter affects rendered difficulty, while the analysis +endpoint returns calculated modded data. + +Existing JSON-only endpoints, beatmapset archive downloads, and replay video +endpoints keep their existing response formats. diff --git a/pom.xml b/pom.xml index c62f0ba..8962576 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.12.0 + 1.13.0 25 @@ -112,13 +112,13 @@ com.github.BotSeira osuModel - v1.0.3 + v1.0.9 com.github.BotSeira osuParser - v1.5.6 + v1.6.4 diff --git a/src/main/java/xyz/zcraft/ostella/config/OstellaConfig.java b/src/main/java/xyz/zcraft/ostella/config/OstellaConfig.java index f0073db..32a08dc 100644 --- a/src/main/java/xyz/zcraft/ostella/config/OstellaConfig.java +++ b/src/main/java/xyz/zcraft/ostella/config/OstellaConfig.java @@ -1,6 +1,7 @@ package xyz.zcraft.ostella.config; public record OstellaConfig( + String token, int requestPerSecond, int replayRequestIntervalMillis, int replayMaxConcurrent, diff --git a/src/main/java/xyz/zcraft/ostella/console/ConsoleInputParser.java b/src/main/java/xyz/zcraft/ostella/console/ConsoleInputParser.java index 2dc75d3..5f804d4 100644 --- a/src/main/java/xyz/zcraft/ostella/console/ConsoleInputParser.java +++ b/src/main/java/xyz/zcraft/ostella/console/ConsoleInputParser.java @@ -4,7 +4,8 @@ import java.util.List; final class ConsoleInputParser { - private ConsoleInputParser() { } + private ConsoleInputParser() { + } static ParsedInput parse(String input) { String raw = input == null ? "" : input; @@ -18,13 +19,27 @@ static ParsedInput parse(String input) { while (index < raw.length()) { char current = raw.charAt(index); if (quote != 0) { - if (current == quote) { quote = 0; index++; } - else if (current == '\\' && index + 1 < raw.length()) { value.append(raw.charAt(index + 1)); index += 2; } - else { value.append(current); index++; } - } else if (current == '\'' || current == '"') { quote = current; index++; } - else if (Character.isWhitespace(current)) break; - else if (current == '\\' && index + 1 < raw.length()) { value.append(raw.charAt(index + 1)); index += 2; } - else { value.append(current); index++; } + if (current == quote) { + quote = 0; + index++; + } else if (current == '\\' && index + 1 < raw.length()) { + value.append(raw.charAt(index + 1)); + index += 2; + } else { + value.append(current); + index++; + } + } else if (current == '\'' || current == '"') { + quote = current; + index++; + } else if (Character.isWhitespace(current)) break; + else if (current == '\\' && index + 1 < raw.length()) { + value.append(raw.charAt(index + 1)); + index += 2; + } else { + value.append(current); + index++; + } } if (quote != 0) throw new IllegalArgumentException("Unclosed quote in console command"); values.add(value.toString()); @@ -33,7 +48,12 @@ static ParsedInput parse(String input) { } record ParsedInput(List values) { - int size() { return values.size(); } - String value(int index) { return values.get(index); } + int size() { + return values.size(); + } + + String value(int index) { + return values.get(index); + } } } diff --git a/src/main/java/xyz/zcraft/ostella/console/JLineConsole.java b/src/main/java/xyz/zcraft/ostella/console/JLineConsole.java index 488d7d4..c6868c7 100644 --- a/src/main/java/xyz/zcraft/ostella/console/JLineConsole.java +++ b/src/main/java/xyz/zcraft/ostella/console/JLineConsole.java @@ -24,8 +24,13 @@ public final class JLineConsole implements AutoCloseable { private volatile Terminal terminal; private volatile JLineLogBridge bridge; - public JLineConsole(OstellaConsoleProcessor processor) { this.processor = processor; } - public void start() { if (running.compareAndSet(false, true)) thread.execute(this::runLoop); } + public JLineConsole(OstellaConsoleProcessor processor) { + this.processor = processor; + } + + public void start() { + if (running.compareAndSet(false, true)) thread.execute(this::runLoop); + } private void runLoop() { try (Terminal created = TerminalBuilder.builder().system(true).build()) { @@ -42,30 +47,46 @@ private void runLoop() { while (running.get()) { try { OstellaConsoleProcessor.Result result = processor.execute(reader.readLine("ostella> ")); - if (!result.message().isBlank()) reader.printAbove((result.success() ? "" : "Error: ") + result.message()); + if (!result.message().isBlank()) + reader.printAbove((result.success() ? "" : "Error: ") + result.message()); } catch (UserInterruptException ignored) { - } catch (EndOfFileException e) { break; } + } catch (EndOfFileException e) { + break; + } } - } finally { bridge = null; } + } finally { + bridge = null; + } } catch (IOException | RuntimeException e) { if (running.get()) LOG.error("Interactive console stopped unexpectedly", e); - } finally { terminal = null; running.set(false); } + } finally { + terminal = null; + running.set(false); + } } - @Override public void close() { + @Override + public void close() { running.set(false); JLineLogBridge currentBridge = bridge; if (currentBridge != null) currentBridge.close(); Terminal current = terminal; - if (current != null) try { current.close(); } catch (IOException e) { LOG.warn("Failed to close console terminal", e); } + if (current != null) try { + current.close(); + } catch (IOException e) { + LOG.warn("Failed to close console terminal", e); + } thread.shutdownNow(); } private static final class CommandCompleter implements Completer { - @Override public void complete(LineReader reader, ParsedLine line, List candidates) { + @Override + public void complete(LineReader reader, ParsedLine line, List candidates) { List values = line.wordIndex() == 0 ? OstellaConsoleProcessor.rootCommands() : line.wordIndex() == 1 && !line.words().isEmpty() - ? OstellaConsoleProcessor.subcommands(line.words().getFirst()) : List.of(); + ? OstellaConsoleProcessor.subcommands(line.words().getFirst()) + : line.wordIndex() == 2 && "autocache".equalsIgnoreCase(line.words().getFirst()) + ? List.of("on", "off") : List.of(); values.forEach(value -> candidates.add(new Candidate(value))); } } diff --git a/src/main/java/xyz/zcraft/ostella/console/JLineLogBridge.java b/src/main/java/xyz/zcraft/ostella/console/JLineLogBridge.java index 89218cd..356e446 100644 --- a/src/main/java/xyz/zcraft/ostella/console/JLineLogBridge.java +++ b/src/main/java/xyz/zcraft/ostella/console/JLineLogBridge.java @@ -29,8 +29,12 @@ final class JLineLogBridge implements AutoCloseable { private JLineLogBridge(LoggerContext context, LoggerConfig root, Appender original, Appender replacement, Level level, Filter filter) { - this.context = context; this.root = root; this.original = original; - this.replacement = replacement; this.level = level; this.filter = filter; + this.context = context; + this.root = root; + this.original = original; + this.replacement = replacement; + this.level = level; + this.filter = filter; } static JLineLogBridge install(LineReader reader) { @@ -45,7 +49,10 @@ static JLineLogBridge install(LineReader reader) { Filter filter = reference == null ? null : reference.getFilter(); Appender replacement = new ReaderAppender(reader, original); replacement.start(); - synchronized (root) { root.removeAppender(CONSOLE); root.addAppender(replacement, level, filter); } + synchronized (root) { + root.removeAppender(CONSOLE); + root.addAppender(replacement, level, filter); + } context.updateLoggers(); return new JLineLogBridge(context, root, original, replacement, level, filter); } @@ -53,18 +60,24 @@ static JLineLogBridge install(LineReader reader) { @Override public void close() { if (!closed.compareAndSet(false, true) || original == null) return; - synchronized (root) { root.removeAppender(JLINE); root.addAppender(original, level, filter); } + synchronized (root) { + root.removeAppender(JLINE); + root.addAppender(original, level, filter); + } context.updateLoggers(); replacement.stop(); } private static final class ReaderAppender extends AbstractAppender { private final LineReader reader; + private ReaderAppender(LineReader reader, Appender original) { super(JLINE, null, original.getLayout(), original.ignoreExceptions(), Property.EMPTY_ARRAY); this.reader = reader; } - @Override public void append(LogEvent event) { + + @Override + public void append(LogEvent event) { Serializable rendered = toSerializable(event); if (rendered != null) reader.printAbove(rendered.toString()); } diff --git a/src/main/java/xyz/zcraft/ostella/console/OstellaConsoleAccess.java b/src/main/java/xyz/zcraft/ostella/console/OstellaConsoleAccess.java index d1d05fb..f4d1980 100644 --- a/src/main/java/xyz/zcraft/ostella/console/OstellaConsoleAccess.java +++ b/src/main/java/xyz/zcraft/ostella/console/OstellaConsoleAccess.java @@ -1,8 +1,8 @@ package xyz.zcraft.ostella.console; -import xyz.zcraft.ostella.network.WebServer; import xyz.zcraft.ostella.cache.CacheControlRequest; import xyz.zcraft.ostella.cache.CacheControlResult; +import xyz.zcraft.ostella.network.WebServer; import xyz.zcraft.ostella.service.CacheService; import xyz.zcraft.ostella.service.ReplayService; @@ -21,5 +21,7 @@ public interface OstellaConsoleAccess { CacheControlResult controlCache(CacheControlRequest request); + void setAutoCache(xyz.zcraft.ostella.service.AutoCacheService.Type type, boolean enabled); + void requestStop(); } diff --git a/src/main/java/xyz/zcraft/ostella/console/OstellaConsoleProcessor.java b/src/main/java/xyz/zcraft/ostella/console/OstellaConsoleProcessor.java index b777a23..df6f484 100644 --- a/src/main/java/xyz/zcraft/ostella/console/OstellaConsoleProcessor.java +++ b/src/main/java/xyz/zcraft/ostella/console/OstellaConsoleProcessor.java @@ -3,29 +3,26 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.core.config.Configurator; -import xyz.zcraft.ostella.config.AppConfig; -import xyz.zcraft.ostella.config.ConfigLoader; import xyz.zcraft.ostella.cache.CacheControlRequest; import xyz.zcraft.ostella.cache.CacheControlResult; +import xyz.zcraft.ostella.config.AppConfig; +import xyz.zcraft.ostella.config.ConfigLoader; import xyz.zcraft.ostella.network.WebServer; import xyz.zcraft.ostella.service.CacheService; import xyz.zcraft.ostella.service.ReplayService; import xyz.zcraft.ostella.util.VersionInfo; import java.time.Duration; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.UUID; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; public final class OstellaConsoleProcessor { private static final long STARTED_AT = System.currentTimeMillis(); private static final List ROOT = List.of( - "help", "status", "metrics", "token", "replay", "cache", "config", "log", "system", "stop"); + "help", "status", "metrics", "token", "replay", "cache", "autocache", "config", "log", "system", "stop"); private static final Map> SUB = Map.of( + "autocache", List.of("beatmapset", "beatmapset-json", "beatmap", "beatmap-json"), "token", List.of("status", "renew"), "replay", List.of("status", "job", "delete"), "cache", List.of("query", "delete", "get", "fetch", "status", "clear"), @@ -40,6 +37,118 @@ public OstellaConsoleProcessor(AppConfig config, OstellaConsoleAccess access) { this.access = Objects.requireNonNull(access); } + static List rootCommands() { + return ROOT; + } + + static List subcommands(String command) { + return SUB.getOrDefault(command.toLowerCase(Locale.ROOT), List.of()); + } + + private static Result exact(ConsoleInputParser.ParsedInput input, int size, + java.util.function.Supplier action, String usage) { + return input.size() == size ? action.get() : Result.error(usage); + } + + private static String formatReplayJob(String id, ReplayService.JobProgress job) { + StringBuilder value = new StringBuilder(id).append(" | ").append(job.status()); + if (job.progress() != null) value.append(" | ").append(job.progress()); + if (job.speed() != null) value.append(" | ").append(job.speed()); + if (job.eta() != null) value.append(" | ETA ").append(job.eta()); + if (job.error() != null) value.append(" | error: ").append(job.error()); + if (job.qqFile() != null) value.append(" | QQ uploaded"); + return value.toString(); + } + + private static CacheService.CacheArea cacheArea(String value) { + return switch (value.toLowerCase(Locale.ROOT)) { + case "beatmap", "beatmaps" -> CacheService.CacheArea.BEATMAPS; + case "image", "images" -> CacheService.CacheArea.IMAGES; + case "replay", "replays" -> CacheService.CacheArea.REPLAYS; + case "score-json", "scores" -> CacheService.CacheArea.SCORE_JSON; + case "beatmapset", "beatmapsets" -> CacheService.CacheArea.BEATMAPSETS; + case "beatmap-json" -> CacheService.CacheArea.BEATMAP_JSON; + case "beatmapset-json" -> CacheService.CacheArea.BEATMAPSET_JSON; + case "all" -> CacheService.CacheArea.ALL; + default -> + throw new IllegalArgumentException("Cache area must be beatmaps, images, replays, score-json, beatmapsets, beatmap-json, beatmapset-json, or all."); + }; + } + + private static String cacheControlType(String value) { + String normalized = value.toUpperCase(Locale.ROOT); + if (!List.of("SCORE", "BEATMAP", "BEATMAPSET", "REPLAY", "BEATMAP-JSON", "BEATMAPSET-JSON").contains(normalized)) { + throw new IllegalArgumentException("Cache type must be score, beatmap, beatmapset, replay, beatmap-json, or beatmapset-json."); + } + return normalized; + } + + private static long positiveLong(String value) { + try { + long id = Long.parseLong(value); + if (id > 0) return id; + } catch (NumberFormatException ignored) { + } + throw new IllegalArgumentException("ID must be a positive integer."); + } + + private static String formatCacheControl(CacheControlResult result) { + StringBuilder output = new StringBuilder(result.operation().toLowerCase(Locale.ROOT)) + .append(' ').append(result.type().toLowerCase(Locale.ROOT)).append(' ').append(result.id()); + for (CacheControlResult.CacheNodeResult node : result.nodes()) { + output.append("\n ").append(node.node()).append(": ").append(node.status()); + if (node.path() != null) output.append(" | path=").append(node.path()); + if (node.sizeBytes() != null) output.append(" | size=").append(bytes(node.sizeBytes())); + if (node.modifiedAt() != null) output.append(" | modified=").append(node.modifiedAt()); + if (node.message() != null) output.append(" | ").append(node.message()); + } + return output.toString(); + } + + private static String uuid(String value) { + try { + return UUID.fromString(value).toString(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Job ID must be a UUID."); + } + } + + private static Level level(String value) { + return switch (value.toLowerCase(Locale.ROOT)) { + case "trace" -> Level.TRACE; + case "debug" -> Level.DEBUG; + case "info" -> Level.INFO; + case "warn" -> Level.WARN; + case "error" -> Level.ERROR; + default -> throw new IllegalArgumentException("Log level must be trace, debug, info, warn, or error."); + }; + } + + private static String bytes(long bytes) { + double value = bytes; + String[] units = {"B", "KiB", "MiB", "GiB", "TiB"}; + int unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return unit == 0 ? bytes + " B" : String.format(Locale.ROOT, "%.1f %s", value, units[unit]); + } + + private static String duration(long millis) { + long seconds = Duration.ofMillis(Math.max(0, millis)).toSeconds(); + long days = seconds / 86400, hours = seconds % 86400 / 3600, minutes = seconds % 3600 / 60; + return days > 0 ? "%dd %02dh %02dm".formatted(days, hours, minutes) + : hours > 0 ? "%dh %02dm".formatted(hours, minutes) + : "%dm %02ds".formatted(minutes, seconds % 60); + } + + private static String rootMessage(Throwable error) { + Throwable current = error; + while (current.getCause() != null) current = current.getCause(); + return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + } + public Result execute(String line) { try { ConsoleInputParser.ParsedInput input = ConsoleInputParser.parse(line); @@ -51,6 +160,7 @@ public Result execute(String line) { case "token" -> token(input); case "replay" -> replay(input); case "cache" -> cache(input); + case "autocache" -> autoCache(input); case "config" -> config(input); case "log" -> log(input); case "system" -> exact(input, 1, this::system, "Usage: system"); @@ -65,11 +175,6 @@ public Result execute(String line) { } } - static List rootCommands() { return ROOT; } - static List subcommands(String command) { - return SUB.getOrDefault(command.toLowerCase(Locale.ROOT), List.of()); - } - private Result help(ConsoleInputParser.ParsedInput input) { if (input.size() > 2) return Result.error("Usage: help [command]"); if (input.size() == 1) return Result.ok(""" @@ -82,26 +187,33 @@ private Result help(ConsoleInputParser.ParsedInput input) { replay delete confirm Delete a remote replay-render job cache Inspect or delete cache across oStella and workers + autocache Toggle idle background caching until restart cache status Show each local cache area and total size cache clear confirm Clear a selected cache area config Show redacted config or validate config.yml log Inspect or change the Log4J2 root level system JVM, OS, thread, memory, version, and uptime stop confirm Gracefully stop oStella - + Aliases: ? (help), shutdown/exit/quit (stop) """.stripTrailing()); String topic = input.value(1).toLowerCase(Locale.ROOT); String detail = switch (topic) { - case "status" -> "status\nShows web, osu! token, async work, image renderer, replay workers, and cache health."; + case "status" -> + "status\nShows web, osu! token, async work, image renderer, replay workers, and cache health."; case "metrics" -> "metrics\nShows HTTP totals plus submitted, completed, failed, and active async work."; - case "token" -> "token status\ntoken renew\nRenewal is queued on the token worker and does not block the console."; - case "replay" -> "replay status\nreplay job \nreplay delete confirm\nCommands contact configured osuRenderer workers."; - case "cache" -> "cache \nQueries oStella followed by every configured osuRenderer worker. get includes metadata; fetch downloads into oStella and pushes beatmapsets/replays to workers; delete removes all reachable copies.\ncache status\ncache clear confirm"; + case "token" -> + "token status\ntoken renew\nRenewal is queued on the token worker and does not block the console."; + case "replay" -> + "replay status\nreplay job \nreplay delete confirm\nCommands contact configured osuRenderer workers."; + case "cache" -> + "cache \nQueries oStella followed by every configured osuRenderer worker (beatmap-json and beatmapset-json are local only). get includes metadata; fetch downloads into oStella and pushes beatmapsets/replays to workers; delete removes all reachable copies.\ncache status\ncache clear confirm"; + case "autocache" -> "autocache \nEach type defaults to off. Prefetches missing files for cached-score users and their best 200 osu! scores, only while idle. Changes last until restart."; case "config" -> "config show\nconfig check\nSecrets are redacted. Runtime changes require restart."; case "log" -> "log show\nlog level "; case "system" -> "system\nShows local runtime information and process uptime."; - case "stop", "shutdown", "exit", "quit" -> "stop confirm\nGracefully closes JLine, Javalin, render workers, and token polling."; + case "stop", "shutdown", "exit", "quit" -> + "stop confirm\nGracefully closes JLine, Javalin, render workers, and token polling."; default -> null; }; return detail == null ? Result.error("No help topic named '" + input.value(1) + "'.") : Result.ok(detail); @@ -174,6 +286,17 @@ private Result replay(ConsoleInputParser.ParsedInput input) { return Result.error("Usage: replay |delete confirm>"); } + private Result autoCache(ConsoleInputParser.ParsedInput input) { + if (input.size() != 3 || !("on".equalsIgnoreCase(input.value(2)) || "off".equalsIgnoreCase(input.value(2)))) { + return Result.error("Usage: autocache "); + } + var type = xyz.zcraft.ostella.service.AutoCacheService.Type.parse(input.value(1)); + boolean enabled = "on".equalsIgnoreCase(input.value(2)); + access.setAutoCache(type, enabled); + return Result.ok("Auto cache " + type.name().toLowerCase(Locale.ROOT).replace('_', '-') + + " is " + (enabled ? "on" : "off") + " until restart."); + } + private Result cache(ConsoleInputParser.ParsedInput input) { if (input.size() == 4 && List.of("query", "delete", "get", "fetch") .contains(input.value(1).toLowerCase(Locale.ROOT))) { @@ -192,18 +315,22 @@ private Result cache(ConsoleInputParser.ParsedInput input) { Replays: %d files, %s Score JSON: %d files, %s Beatmapsets: %d files, %s + Beatmap JSON: %d files, %s + Beatmapset JSON: %d files, %s Total: %d files, %s """.formatted(value.beatmaps().files(), bytes(value.beatmaps().bytes()), value.images().files(), bytes(value.images().bytes()), value.replays().files(), bytes(value.replays().bytes()), value.scoreJson().files(), bytes(value.scoreJson().bytes()), value.beatmapsets().files(), - bytes(value.beatmapsets().bytes()), value.totalFiles(), bytes(value.totalBytes())).stripTrailing()); + bytes(value.beatmapsets().bytes()), value.beatmapJson().files(), bytes(value.beatmapJson().bytes()), + value.beatmapsetJson().files(), bytes(value.beatmapsetJson().bytes()), + value.totalFiles(), bytes(value.totalBytes())).stripTrailing()); } if (input.size() == 4 && "clear".equalsIgnoreCase(input.value(1)) && "confirm".equalsIgnoreCase(input.value(3))) { CacheService.CacheArea area = cacheArea(input.value(2)); return Result.ok("Removed " + access.clearCache(area) + " cache entries."); } - return Result.error("Usage: cache | cache status | cache clear confirm"); + return Result.error("Usage: cache | cache status | cache clear confirm"); } private Result config(ConsoleInputParser.ParsedInput input) { @@ -247,14 +374,16 @@ private Result log(ConsoleInputParser.ParsedInput input) { if (input.size() == 2 && "show".equalsIgnoreCase(input.value(1))) return Result.ok("Root log level: " + LogManager.getRootLogger().getLevel()); if (input.size() == 3 && "level".equalsIgnoreCase(input.value(1))) { - Level level = level(input.value(2)); Configurator.setRootLevel(level); + Level level = level(input.value(2)); + Configurator.setRootLevel(level); return Result.ok("Root log level changed to " + level + "."); } return Result.error("Usage: log >"); } private Result system() { - Runtime runtime = Runtime.getRuntime(); long used = runtime.totalMemory() - runtime.freeMemory(); + Runtime runtime = Runtime.getRuntime(); + long used = runtime.totalMemory() - runtime.freeMemory(); return Result.ok(""" System information oStella: %s @@ -271,102 +400,19 @@ private Result system() { } private Result stop(ConsoleInputParser.ParsedInput input) { - if (input.size() != 2 || !"confirm".equalsIgnoreCase(input.value(1))) return Result.error("Usage: stop confirm"); + if (input.size() != 2 || !"confirm".equalsIgnoreCase(input.value(1))) + return Result.error("Usage: stop confirm"); CompletableFuture.delayedExecutor(100, TimeUnit.MILLISECONDS).execute(access::requestStop); return Result.ok("Graceful shutdown requested."); } - private static Result exact(ConsoleInputParser.ParsedInput input, int size, - java.util.function.Supplier action, String usage) { - return input.size() == size ? action.get() : Result.error(usage); - } - - private static String formatReplayJob(String id, ReplayService.JobProgress job) { - StringBuilder value = new StringBuilder(id).append(" | ").append(job.status()); - if (job.progress() != null) value.append(" | ").append(job.progress()); - if (job.speed() != null) value.append(" | ").append(job.speed()); - if (job.eta() != null) value.append(" | ETA ").append(job.eta()); - if (job.error() != null) value.append(" | error: ").append(job.error()); - if (job.qqFile() != null) value.append(" | QQ uploaded"); - return value.toString(); - } - - private static CacheService.CacheArea cacheArea(String value) { - return switch (value.toLowerCase(Locale.ROOT)) { - case "beatmap", "beatmaps" -> CacheService.CacheArea.BEATMAPS; - case "image", "images" -> CacheService.CacheArea.IMAGES; - case "replay", "replays" -> CacheService.CacheArea.REPLAYS; - case "score-json", "scores" -> CacheService.CacheArea.SCORE_JSON; - case "beatmapset", "beatmapsets" -> CacheService.CacheArea.BEATMAPSETS; - case "all" -> CacheService.CacheArea.ALL; - default -> throw new IllegalArgumentException("Cache area must be beatmaps, images, replays, score-json, beatmapsets, or all."); - }; - } - - private static String cacheControlType(String value) { - String normalized = value.toUpperCase(Locale.ROOT); - if (!List.of("SCORE", "BEATMAP", "BEATMAPSET", "REPLAY").contains(normalized)) { - throw new IllegalArgumentException("Cache type must be score, beatmap, beatmapset, or replay."); - } - return normalized; - } - - private static long positiveLong(String value) { - try { - long id = Long.parseLong(value); - if (id > 0) return id; - } catch (NumberFormatException ignored) { + public record Result(boolean success, String message) { + static Result ok(String message) { + return new Result(true, message); } - throw new IllegalArgumentException("ID must be a positive integer."); - } - private static String formatCacheControl(CacheControlResult result) { - StringBuilder output = new StringBuilder(result.operation().toLowerCase(Locale.ROOT)) - .append(' ').append(result.type().toLowerCase(Locale.ROOT)).append(' ').append(result.id()); - for (CacheControlResult.CacheNodeResult node : result.nodes()) { - output.append("\n ").append(node.node()).append(": ").append(node.status()); - if (node.path() != null) output.append(" | path=").append(node.path()); - if (node.sizeBytes() != null) output.append(" | size=").append(bytes(node.sizeBytes())); - if (node.modifiedAt() != null) output.append(" | modified=").append(node.modifiedAt()); - if (node.message() != null) output.append(" | ").append(node.message()); + static Result error(String message) { + return new Result(false, message); } - return output.toString(); - } - - private static String uuid(String value) { - try { return UUID.fromString(value).toString(); } - catch (IllegalArgumentException e) { throw new IllegalArgumentException("Job ID must be a UUID."); } - } - - private static Level level(String value) { - return switch (value.toLowerCase(Locale.ROOT)) { - case "trace" -> Level.TRACE; case "debug" -> Level.DEBUG; case "info" -> Level.INFO; - case "warn" -> Level.WARN; case "error" -> Level.ERROR; - default -> throw new IllegalArgumentException("Log level must be trace, debug, info, warn, or error."); - }; - } - - private static String bytes(long bytes) { - double value = bytes; String[] units = {"B", "KiB", "MiB", "GiB", "TiB"}; int unit = 0; - while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } - return unit == 0 ? bytes + " B" : String.format(Locale.ROOT, "%.1f %s", value, units[unit]); - } - - private static String duration(long millis) { - long seconds = Duration.ofMillis(Math.max(0, millis)).toSeconds(); - long days = seconds / 86400, hours = seconds % 86400 / 3600, minutes = seconds % 3600 / 60; - return days > 0 ? "%dd %02dh %02dm".formatted(days, hours, minutes) - : hours > 0 ? "%dh %02dm".formatted(hours, minutes) - : "%dm %02ds".formatted(minutes, seconds % 60); - } - - private static String rootMessage(Throwable error) { - Throwable current = error; while (current.getCause() != null) current = current.getCause(); - return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); - } - - public record Result(boolean success, String message) { - static Result ok(String message) { return new Result(true, message); } - static Result error(String message) { return new Result(false, message); } } } diff --git a/src/main/java/xyz/zcraft/ostella/data/BeatmapAnalysisData.java b/src/main/java/xyz/zcraft/ostella/data/BeatmapAnalysisData.java index 2e79102..a213a9b 100644 --- a/src/main/java/xyz/zcraft/ostella/data/BeatmapAnalysisData.java +++ b/src/main/java/xyz/zcraft/ostella/data/BeatmapAnalysisData.java @@ -15,46 +15,6 @@ public record BeatmapAnalysisData( PerfPlusApi.PerformancePlus performance, BeatmapPatternAnalysis patterns ) { - public PatternView primaryType() { - return patternView(patterns.primaryType()); - } - - public AimPatternView primaryAimType() { - return aimPatternView(patterns.primaryAimType()); - } - - public List types() { - return patterns.types().stream().map(BeatmapAnalysisData::patternView).toList(); - } - - public List aimTypes() { - return patterns.aimTypes().stream() - .filter(type -> type.evidence() > 0) - .map(BeatmapAnalysisData::aimPatternView) - .sorted(Comparator.comparingDouble(AimPatternView::percentage).reversed()) - .toList(); - } - - public List skillsByPercentage() { - return performance.skills().stream() - .sorted(Comparator.comparingDouble(PerfPlusApi.SkillPerformance::percentage).reversed()) - .toList(); - } - - public PerfPlusApi.SkillPerformance primarySkill() { - return performance.skills().stream() - .max(java.util.Comparator.comparingDouble(PerfPlusApi.SkillPerformance::pp)) - .orElseThrow(); - } - - public boolean hasMods() { - return mods != null && !mods.isEmpty(); - } - - public boolean hasAimEvidence() { - return patterns.primaryAimType().evidence() > 0; - } - private static PatternView patternView(BeatmapPatternAnalysis.PatternScore score) { return switch (score.type()) { case STREAM -> new PatternView("Stream", score.percentage(), "#4a90e2", @@ -95,6 +55,46 @@ private static AimPatternView aimPatternView(BeatmapPatternAnalysis.AimPatternSc }; } + public PatternView primaryType() { + return patternView(patterns.primaryType()); + } + + public AimPatternView primaryAimType() { + return aimPatternView(patterns.primaryAimType()); + } + + public List types() { + return patterns.types().stream().map(BeatmapAnalysisData::patternView).toList(); + } + + public List aimTypes() { + return patterns.aimTypes().stream() + .filter(type -> type.evidence() > 0) + .map(BeatmapAnalysisData::aimPatternView) + .sorted(Comparator.comparingDouble(AimPatternView::percentage).reversed()) + .toList(); + } + + public List skillsByPercentage() { + return performance.skills().stream() + .sorted(Comparator.comparingDouble(PerfPlusApi.SkillPerformance::percentage).reversed()) + .toList(); + } + + public PerfPlusApi.SkillPerformance primarySkill() { + return performance.skills().stream() + .max(java.util.Comparator.comparingDouble(PerfPlusApi.SkillPerformance::pp)) + .orElseThrow(); + } + + public boolean hasMods() { + return mods != null && !mods.isEmpty(); + } + + public boolean hasAimEvidence() { + return patterns.primaryAimType().evidence() > 0; + } + public record PatternView( String name, double percentage, diff --git a/src/main/java/xyz/zcraft/ostella/data/BeatmapData.java b/src/main/java/xyz/zcraft/ostella/data/BeatmapData.java new file mode 100644 index 0000000..bf6ec35 --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/data/BeatmapData.java @@ -0,0 +1,22 @@ +package xyz.zcraft.ostella.data; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import xyz.zcraft.osu.model.BeatmapExtended; +import xyz.zcraft.osu.model.Beatmapset; + +/** Builds an independent response model without a beatmap -> set -> beatmap cycle. */ +public final class BeatmapData { + private static final Gson GSON = new Gson(); + + private BeatmapData() { + } + + public static BeatmapExtended withBeatmapset(BeatmapExtended beatmap, Beatmapset beatmapset) { + BeatmapExtended result = GSON.fromJson(GSON.toJsonTree(beatmap), BeatmapExtended.class); + JsonObject setData = GSON.toJsonTree(beatmapset).getAsJsonObject(); + setData.remove("beatmaps"); + result.setBeatmapset(GSON.fromJson(setData, Beatmapset.class)); + return result; + } +} diff --git a/src/main/java/xyz/zcraft/ostella/data/MultiplayerMatchDetails.java b/src/main/java/xyz/zcraft/ostella/data/MultiplayerMatchDetails.java deleted file mode 100644 index 1ed4393..0000000 --- a/src/main/java/xyz/zcraft/ostella/data/MultiplayerMatchDetails.java +++ /dev/null @@ -1,66 +0,0 @@ -package xyz.zcraft.ostella.data; - -import com.google.gson.JsonObject; -import com.google.gson.annotations.SerializedName; -import lombok.Data; -import xyz.zcraft.osu.model.BeatmapExtended; -import xyz.zcraft.osu.model.User; - -import java.util.List; - -@Data -public class MultiplayerMatchDetails { - private MatchInfo match; - private List events; - private List users; - - @SerializedName("latest_event_id") - private Long latestEventId; - - @SerializedName("current_game_id") - private Long currentGameId; - - @Data - public static class MatchInfo { - private long id; - private String name; - - @SerializedName("start_time") - private String startTime; - - @SerializedName("end_time") - private String endTime; - } - - @Data - public static class MatchEvent { - private long id; - private String timestamp; - private MatchGame game; - } - - @Data - public static class MatchGame { - private long id; - - @SerializedName("beatmap_id") - private long beatmapId; - - @SerializedName("start_time") - private String startTime; - - @SerializedName("end_time") - private String endTime; - - private String mode; - - @SerializedName("scoring_type") - private String scoringType; - - @SerializedName("team_type") - private String teamType; - - private BeatmapExtended beatmap; - private List scores; - } -} diff --git a/src/main/java/xyz/zcraft/ostella/data/MultiplayerResultData.java b/src/main/java/xyz/zcraft/ostella/data/MultiplayerResultData.java index 91e6dd3..3193844 100644 --- a/src/main/java/xyz/zcraft/ostella/data/MultiplayerResultData.java +++ b/src/main/java/xyz/zcraft/ostella/data/MultiplayerResultData.java @@ -9,7 +9,7 @@ public record MultiplayerResultData(long roomId, String roomName, long playlistI long totalScore, long averageScore, double teamLeadPercent, String winningTeam, SeriesScore seriesScore, BeatmapInfo beatmap, UserInfo queuedBy, List players, - List teams, List unassignedPlayers) { + List teams, List unassignedPlayers, Integer customBo) { public MultiplayerResultData( long roomId, String roomName, @@ -27,7 +27,8 @@ public MultiplayerResultData( UserInfo queuedBy, List players, List teams, - List unassignedPlayers + List unassignedPlayers, + Integer customBo ) { this.roomId = roomId; this.roomName = roomName; @@ -46,6 +47,15 @@ public MultiplayerResultData( this.players = List.copyOf(players); this.teams = List.copyOf(teams); this.unassignedPlayers = List.copyOf(unassignedPlayers); + this.customBo = customBo; + } + + private static int teamOrder(String team) { + return switch (team == null ? "" : team) { + case "red" -> 0; + case "blue" -> 1; + default -> 2; + }; } public boolean isTeamVs() { @@ -89,14 +99,6 @@ public List versusScores() { .toList(); } - private static int teamOrder(String team) { - return switch (team == null ? "" : team) { - case "red" -> 0; - case "blue" -> 1; - default -> 2; - }; - } - private boolean isTeamDuel() { return players.size() == 2 && teams.size() == 2 diff --git a/src/main/java/xyz/zcraft/ostella/data/MultiplayerRoomDetails.java b/src/main/java/xyz/zcraft/ostella/data/MultiplayerRoomDetails.java deleted file mode 100644 index 2921d33..0000000 --- a/src/main/java/xyz/zcraft/ostella/data/MultiplayerRoomDetails.java +++ /dev/null @@ -1,68 +0,0 @@ -package xyz.zcraft.ostella.data; - -import com.google.gson.annotations.SerializedName; -import lombok.Data; -import xyz.zcraft.osu.model.BeatmapExtended; -import xyz.zcraft.osu.model.User; - -import java.util.List; -import java.util.Map; - -@Data -public class MultiplayerRoomDetails { - private long id; - private String name; - private String status; - private String type; - private boolean active; - - @SerializedName("ends_at") - private String endsAt; - - private User host; - - @SerializedName("current_playlist_item") - private PlaylistItem currentPlaylistItem; - - private List playlist; - - @SerializedName("recent_participants") - private List recentParticipants; - - @Data - public static class PlaylistItem { - private long id; - - @SerializedName("room_id") - private long roomId; - - @SerializedName("beatmap_id") - private long beatmapId; - - @SerializedName("owner_id") - private long ownerId; - - @SerializedName("played_at") - private String playedAt; - - private boolean expired; - private BeatmapExtended beatmap; - - private Details details; - - public String teamFor(Long userId) { - if (userId == null || userId <= 0 || details == null || details.getTeams() == null) { - return null; - } - return details.getTeams().get(String.valueOf(userId)); - } - } - - @Data - public static class Details { - @SerializedName("room_type") - private String roomType; - - private Map teams; - } -} diff --git a/src/main/java/xyz/zcraft/ostella/data/PerformanceGraphData.java b/src/main/java/xyz/zcraft/ostella/data/PerformanceGraphData.java new file mode 100644 index 0000000..5aa96c5 --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/data/PerformanceGraphData.java @@ -0,0 +1,15 @@ +package xyz.zcraft.ostella.data; + +import java.util.List; + +public record PerformanceGraphData( + List windowDifficulties, + List realtimePp, + List misses, + List hit50s, + List hit100s, + List sliderTickBreaks, + List sliderEndBreaks, + long mapEndTime +) { +} diff --git a/src/main/java/xyz/zcraft/ostella/data/ScoreAnalysisData.java b/src/main/java/xyz/zcraft/ostella/data/ScoreAnalysisData.java new file mode 100644 index 0000000..988f446 --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/data/ScoreAnalysisData.java @@ -0,0 +1,50 @@ +package xyz.zcraft.ostella.data; + +import xyz.zcraft.ostella.network.PerfPlusApi; +import xyz.zcraft.osu.model.Score; +import xyz.zcraft.osu.parser.data.beatmap.DiffSpec; +import xyz.zcraft.osu.parser.data.replay.ReplayAnalyze; +import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; + +public record ScoreAnalysisData( + Score score, + DiffSpec diffSpec, + List hitErrors, + List hitPositions, + List hitPositionsAbsolute, + List missPositions, + List missPositionsAbsolute, + double aimBias, + double avgTimingError, + ReplayAnalyze replayAnalyze, + PerformanceGraphData performanceGraph, + PerfPlusApi.PerformancePlus performancePlus, + boolean isLazerScore, + boolean doSimMatch, + String simHitResult +) { + /** Public analysis data excludes the parser's full beatmap and replay object graph. */ + public Map responseData() { + Map data = new LinkedHashMap<>(); + data.put("score", score); + data.put("diffSpec", diffSpec); + data.put("hitErrors", hitErrors); + data.put("hitPositions", hitPositions); + data.put("hitPositionsAbsolute", hitPositionsAbsolute); + data.put("missPositions", missPositions); + data.put("missPositionsAbsolute", missPositionsAbsolute); + data.put("aimBias", aimBias); + data.put("avgTimingError", avgTimingError); + data.put("unstableRate", replayAnalyze.unstableRate()); + data.put("aimUnstableRate", replayAnalyze.aimUnstableRate()); + data.put("performanceGraph", performanceGraph); + data.put("performancePlus", performancePlus); + data.put("isLazerScore", isLazerScore); + data.put("doSimMatch", doSimMatch); + data.put("simHitResult", simHitResult); + return data; + } +} + diff --git a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java index f46dd44..55f4106 100644 --- a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java +++ b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java @@ -1,11 +1,7 @@ package xyz.zcraft.ostella.data; -import org.jline.utils.DiffHelper; import xyz.zcraft.ostella.service.CacheService; -import xyz.zcraft.ostella.util.format.ScoreFormatUtil; -import xyz.zcraft.osu.model.BeatmapExtended; -import xyz.zcraft.osu.model.Mod; -import xyz.zcraft.osu.model.Score; +import xyz.zcraft.osu.model.*; import xyz.zcraft.osu.parser.BeatmapAnalyzer; import xyz.zcraft.osu.parser.BeatmapParser; import xyz.zcraft.osu.parser.OsuParser; @@ -16,6 +12,7 @@ import xyz.zcraft.osu.parser.exception.ParseException; import java.util.*; +import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -25,7 +22,7 @@ public final class ScoreFilter { private static final Pattern FILTER_PATTERN = Pattern.compile( "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank|replay" - + "|any|title|artist|mapper|genre|language|video|storyboard|fullcombo|ar|od|cs|hp)" + + "|any|title|artist|mapper|genre|language|tag|source|nsfw|video|storyboard|fullcombo|ar|od|cs|hp)" + "(>=|<=|!=|!~|>|<|=|~)(.+)$" ); private static final Pattern DURATION_PATTERN = Pattern.compile("(?i)^(?:(\\d+)m)?(?:(\\d+(?:\\.\\d+)?)s)?$"); @@ -42,12 +39,7 @@ public final class ScoreFilter { private final String displayText; private ScoreFilter( - Field field, - Operator operator, - double numericValue, - Set textValues, - Pattern regexValue, - String displayText + Field field, Operator operator, double numericValue, Set textValues, Pattern regexValue, String displayText ) { this.field = field; this.operator = operator; @@ -101,11 +93,7 @@ public static ScoreFilter parse(String token) { double numericValue = field == Field.LENGTH ? parseDuration(value) : parseNumber(value, token); validateRange(field, numericValue); return new ScoreFilter( - field, - operator, - numericValue, - Set.of(), - null, + field, operator, numericValue, Set.of(), null, field.label + " " + operator.display + " " + formatValue(field, numericValue) ); } @@ -118,11 +106,7 @@ private static ScoreFilter parseMods(Field field, Operator operator, String valu Set mods = parseMods(value); String displayValue = mods.isEmpty() ? "NM" : String.join("", mods.stream().sorted().toList()); return new ScoreFilter( - field, - operator, - Double.NaN, - mods, - null, + field, operator, Double.NaN, mods, null, field.label + " " + operator.display + " " + displayValue ); } @@ -189,11 +173,7 @@ private static ScoreFilter parseMetadataText(Field field, Operator operator, Str } } return new ScoreFilter( - field, - operator, - Double.NaN, - Set.of(value.toLowerCase(Locale.ROOT)), - regex, + field, operator, Double.NaN, Set.of(value.toLowerCase(Locale.ROOT)), regex, field.label + " " + operator.display + " " + value ); } @@ -207,11 +187,7 @@ private static ScoreFilter parseBoolean(Field field, Operator operator, String v throw new IllegalArgumentException(field.label + " must be true or false"); } return new ScoreFilter( - field, - operator, - Double.NaN, - Set.of(normalized), - null, + field, operator, Double.NaN, Set.of(normalized), null, field.label + " " + operator.display + " " + normalized ); } @@ -327,7 +303,8 @@ private static String formatValue(Field field, double value) { case BPM -> formatNumber(value) + " BPM"; case MISS -> formatNumber(value) + " miss"; case SCORE, AR, CS, HP, OD -> formatNumber(value); - case MODS, RANK, ANY, TITLE, ARTIST, MAPPER, GENRE, LANGUAGE, VIDEO, STORYBOARD, FULL_COMBO, REPLAY -> + case MODS, RANK, ANY, TITLE, ARTIST, MAPPER, GENRE, LANGUAGE, TAG, SOURCE, + NSFW, VIDEO, STORYBOARD, FULL_COMBO, REPLAY -> throw new IllegalStateException("Text filter has no numeric value"); }; } @@ -344,12 +321,49 @@ private static String formatDuration(double value) { return "%d:%02d".formatted(seconds / 60, seconds % 60); } - public boolean matches(Score score) { + private static List allMetadataText(Score score, BeatmapExtended beatmap, Beatmapset beatmapset) { + List values = new ArrayList<>(); + + if (beatmap == null) { + beatmap = score.getBeatmap(); + } + + if (score.getBeatmap() != null) { + values.add(score.getBeatmap().getVersion()); + } + + if (beatmapset == null) { + beatmapset = score.getBeatmapset(); + } + + if (beatmapset != null) { + values.add(beatmapset.getTitle()); + values.add(beatmapset.getTitleUnicode()); + values.add(beatmapset.getArtist()); + values.add(beatmapset.getArtistUnicode()); + values.add(beatmapset.getCreator()); + values.add(beatmapset.getSource()); + values.add(beatmapset.getTags()); + if (beatmapset.getGenre() != null) values.add(beatmapset.getGenre().getName()); + if (beatmapset.getLanguage() != null) values.add(beatmapset.getLanguage().getName()); + if (beatmapset.getPackTags() != null) values.addAll(beatmapset.getPackTags()); + if (beatmapset.getRelatedTags() != null) { + beatmapset.getRelatedTags().forEach(tag -> { + values.add(tag.getName()); + values.add(tag.getDescription()); + }); + } + } + return values; + } + + public boolean matches(Score score, Supplier beatmapsetSupplier) { if (score == null) { return false; } - final BeatmapExtended beatmap = score.getBeatmap(); + BeatmapExtended beatmap = score.getBeatmap(); + Beatmapset beatmapset = score.getBeatmapset(); final DifficultyAttribute difficultyAttribute = BeatmapAnalyzer.calculateDifficulty(score); final DiffSpec diffSpec; @@ -367,6 +381,10 @@ public boolean matches(Score score) { diffSpec = null; } + if (field == Field.TAG || field == Field.ANY) { + beatmapset = CacheService.getBeatmapsetJsonCache(beatmapset.getId()).orElseGet(beatmapsetSupplier); + } + return switch (field) { case ACCURACY -> score.getAccuracy() != null && compare(score.getAccuracy() * 100); case COMBO -> score.getMaxCombo() != null && compare(score.getMaxCombo()); @@ -387,20 +405,24 @@ public boolean matches(Score score) { case SCORE -> score.getTotalScore() != null && compare(score.getTotalScore()); case MODS -> compareMods(score.getMods()); case RANK -> compareRank(score.getRank()); - case ANY -> compareText(allMetadataText(score)); - case TITLE -> score.getBeatmapset() != null - && compareText(score.getBeatmapset().getTitle(), score.getBeatmapset().getTitleUnicode()); - case ARTIST -> score.getBeatmapset() != null - && compareText(score.getBeatmapset().getArtist(), score.getBeatmapset().getArtistUnicode()); - case MAPPER -> score.getBeatmapset() != null && compareText(score.getBeatmapset().getCreator()); - case GENRE -> score.getBeatmapset() != null && score.getBeatmapset().getGenre() != null - && compareText(score.getBeatmapset().getGenre().getName()); - case LANGUAGE -> score.getBeatmapset() != null && score.getBeatmapset().getLanguage() != null - && compareText(score.getBeatmapset().getLanguage().getName()); - case VIDEO -> score.getBeatmapset() != null && compareBoolean(score.getBeatmapset().getVideo()); - case STORYBOARD -> score.getBeatmapset() != null && compareBoolean(score.getBeatmapset().getStoryboard()); + case ANY -> compareText(allMetadataText(score, beatmap, beatmapset)); + case TITLE -> beatmapset != null + && compareText(beatmapset.getTitle(), beatmapset.getTitleUnicode()); + case ARTIST -> beatmapset != null + && compareText(beatmapset.getArtist(), beatmapset.getArtistUnicode()); + case MAPPER -> beatmapset != null && compareText(beatmapset.getCreator()); + case GENRE -> beatmapset != null && beatmapset.getGenre() != null + && compareText(beatmapset.getGenre().getName()); + case LANGUAGE -> beatmapset != null && beatmapset.getLanguage() != null + && compareText(beatmapset.getLanguage().getName()); + case NSFW -> beatmapset != null && compareBoolean(beatmapset.getNsfw()); + case VIDEO -> beatmapset != null && compareBoolean(beatmapset.getVideo()); + case STORYBOARD -> beatmapset != null && compareBoolean(beatmapset.getStoryboard()); case REPLAY -> compareBoolean(score.getHasReplay() || CacheService.hasReplayCache(score.getId())); case FULL_COMBO -> compareBoolean(score.getIsPerfectCombo()); + case TAG -> beatmapset != null && beatmapset.getTags() != null + && compareText(beatmapset.getTags()); + case SOURCE -> beatmapset != null && compareText(beatmapset.getSource()); }; } @@ -464,7 +486,8 @@ private boolean compareText(Collection actualValues) { String normalized = actual.toLowerCase(Locale.ROOT); if (regexValue != null) { matches |= regexValue.matcher(actual).find(); - } else if (field == Field.ANY || operator == Operator.CONTAINS || operator == Operator.NOT_CONTAINS) { + } else if (field == Field.ANY || field == Field.TAG + || operator == Operator.CONTAINS || operator == Operator.NOT_CONTAINS) { matches |= normalized.contains(expected); } else { matches |= normalized.equals(expected); @@ -478,33 +501,6 @@ private boolean compareText(Collection actualValues) { }; } - private static List allMetadataText(Score score) { - List values = new ArrayList<>(); - if (score.getBeatmap() != null) { - values.add(score.getBeatmap().getVersion()); - } - if (score.getBeatmapset() != null) { - var beatmapset = score.getBeatmapset(); - values.add(beatmapset.getTitle()); - values.add(beatmapset.getTitleUnicode()); - values.add(beatmapset.getArtist()); - values.add(beatmapset.getArtistUnicode()); - values.add(beatmapset.getCreator()); - values.add(beatmapset.getSource()); - values.add(beatmapset.getTags()); - if (beatmapset.getGenre() != null) values.add(beatmapset.getGenre().getName()); - if (beatmapset.getLanguage() != null) values.add(beatmapset.getLanguage().getName()); - if (beatmapset.getPackTags() != null) values.addAll(beatmapset.getPackTags()); - if (beatmapset.getRelatedTags() != null) { - beatmapset.getRelatedTags().forEach(tag -> { - values.add(tag.getName()); - values.add(tag.getDescription()); - }); - } - } - return values; - } - private boolean compareBoolean(Boolean actual) { if (actual == null) return false; boolean equal = actual == Boolean.parseBoolean(textValues.iterator().next()); @@ -536,6 +532,9 @@ private enum Field { MAPPER("Mapper"), GENRE("Genre"), LANGUAGE("Language"), + TAG("Tag"), + SOURCE("Source"), + NSFW("NSFW"), VIDEO("Video"), STORYBOARD("Storyboard"), REPLAY("Replay"), @@ -569,6 +568,9 @@ static Field from(String value) { case "mapper" -> MAPPER; case "genre" -> GENRE; case "language" -> LANGUAGE; + case "source" -> SOURCE; + case "tag" -> TAG; + case "nsfw" -> NSFW; case "video" -> VIDEO; case "storyboard" -> STORYBOARD; case "replay" -> REPLAY; @@ -578,11 +580,13 @@ static Field from(String value) { } boolean isMetadataText() { - return this == ANY || this == TITLE || this == ARTIST || this == MAPPER || this == GENRE || this == LANGUAGE; + return this == ANY || this == TITLE || this == ARTIST + || this == MAPPER || this == GENRE || this == LANGUAGE + || this == TAG || this == SOURCE; } boolean isBoolean() { - return this == VIDEO || this == STORYBOARD || this == FULL_COMBO || this == REPLAY; + return this == NSFW || this == VIDEO || this == STORYBOARD || this == FULL_COMBO || this == REPLAY; } } diff --git a/src/main/java/xyz/zcraft/ostella/data/UserPerformanceSummary.java b/src/main/java/xyz/zcraft/ostella/data/UserPerformanceSummary.java index bc3e6a5..a095878 100644 --- a/src/main/java/xyz/zcraft/ostella/data/UserPerformanceSummary.java +++ b/src/main/java/xyz/zcraft/ostella/data/UserPerformanceSummary.java @@ -4,11 +4,7 @@ import xyz.zcraft.osu.model.Score; import xyz.zcraft.osu.model.UserExtended; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; diff --git a/src/main/java/xyz/zcraft/ostella/network/ApiActivity.java b/src/main/java/xyz/zcraft/ostella/network/ApiActivity.java new file mode 100644 index 0000000..63280df --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/network/ApiActivity.java @@ -0,0 +1,52 @@ +package xyz.zcraft.ostella.network; + +import java.time.Duration; +import java.util.function.BooleanSupplier; + +/** Tracks queued work as well as direct API calls. Foreground work never waits for prefetch. */ +public final class ApiActivity { + private static int foreground; + private static long lastActivity = System.nanoTime(); + private static final ThreadLocal BACKGROUND = new ThreadLocal<>(); + + private ApiActivity() {} + + public static synchronized void begin() { + foreground++; + lastActivity = System.nanoTime(); + } + + public static synchronized void end() { + foreground--; + lastActivity = System.nanoTime(); + } + + public static synchronized boolean idle() { + return foreground == 0 && System.nanoTime() - lastActivity >= Duration.ofSeconds(5).toNanos(); + } + + public static boolean isBackground() { + return BACKGROUND.get() != null; + } + + public static void checkBackground() { + BooleanSupplier allowed = BACKGROUND.get(); + if (allowed != null && (!allowed.getAsBoolean() || !idle() || Thread.currentThread().isInterrupted())) { + throw new Yield(); + } + } + + public static void runBackground(BooleanSupplier allowed, Runnable action) { + BACKGROUND.set(allowed); + try { + checkBackground(); + action.run(); + } finally { + BACKGROUND.remove(); + } + } + + public static final class Yield extends RuntimeException { + public Yield() { super(null, null, false, false); } + } +} diff --git a/src/main/java/xyz/zcraft/ostella/network/ErrorCode.java b/src/main/java/xyz/zcraft/ostella/network/ErrorCode.java index 543cc99..b35c79a 100644 --- a/src/main/java/xyz/zcraft/ostella/network/ErrorCode.java +++ b/src/main/java/xyz/zcraft/ostella/network/ErrorCode.java @@ -45,6 +45,33 @@ public enum ErrorCode { public JsonObject toJson() { final JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("code", code); + jsonObject.addProperty("httpStatus", getHttpCode()); return jsonObject; } + + public int getHttpCode() { + return switch (this) { + case ErrorCode.NO_BEATMAP_FOUND, + ErrorCode.NO_BEATMAPSET_FOUND, + ErrorCode.NO_SCORE_FOUND, + ErrorCode.NO_ROOM_FOUND, + ErrorCode.NO_USER_FOUND -> 404; + + case ErrorCode.UNAUTHORIZED -> 401; + + case ErrorCode.ILLEGAL_ARGUMENT, + ErrorCode.REPLAY_UNAVAILABLE -> 400; + + case ErrorCode.BEATMAP_FETCH_FAILED, + ErrorCode.BEATMAPSET_FETCH_FAILED, + ErrorCode.SCORE_FETCH_FAILED, + ErrorCode.USER_FETCH_FAILED, + ErrorCode.RENDER_QUEUE_FULL -> 429; + + case ErrorCode.RENDERER_UNAVAILABLE, + ErrorCode.PERFORMANCE_PLUS_UNAVAILABLE -> 502; + + default -> 500; + }; + } } diff --git a/src/main/java/xyz/zcraft/ostella/network/Headers.java b/src/main/java/xyz/zcraft/ostella/network/Headers.java new file mode 100644 index 0000000..32d47e2 --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/network/Headers.java @@ -0,0 +1,6 @@ +package xyz.zcraft.ostella.network; + +public final class Headers { + public static final String SERVICE_AUTHORIZATION = "Authorization"; + public static final String OSU_AUTHORIZATION = "X-Osu-Authorization"; +} diff --git a/src/main/java/xyz/zcraft/ostella/network/ImageResponse.java b/src/main/java/xyz/zcraft/ostella/network/ImageResponse.java new file mode 100644 index 0000000..dac1381 --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/network/ImageResponse.java @@ -0,0 +1,63 @@ +package xyz.zcraft.ostella.network; + +import io.javalin.http.Context; +import xyz.zcraft.ostella.util.RequestUtil; + +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.Function; + +/** Content negotiation shared by endpoints offering data and rendered images. */ +public final class ImageResponse { + private ImageResponse() { + } + + public static boolean wantsJson(Context context) { + String vary = context.res().getHeader("Vary"); + if (vary == null || vary.isBlank()) { + context.header("Vary", "Accept"); + } else if (Arrays.stream(vary.split(",")).map(String::trim) + .noneMatch(value -> value.equalsIgnoreCase("Accept") || value.equals("*"))) { + context.header("Vary", vary + ", Accept"); + } + return acceptsJson(context.header("Accept")); + } + + // Explicit JSON opt-in preserves image responses for browsers and wildcard clients. + static boolean acceptsJson(String accept) { + if (accept == null) return false; + for (String range : accept.split(",")) { + String[] parts = range.trim().split(";"); + if (parts.length == 0 || !parts[0].trim().equalsIgnoreCase("application/json")) continue; + double quality = 1; + for (int i = 1; i < parts.length; i++) { + String[] parameter = parts[i].trim().split("=", 2); + if (!parameter[0].trim().equalsIgnoreCase("q")) continue; + try { + quality = parameter.length == 2 ? Double.parseDouble(parameter[1].trim()) : 0; + } catch (NumberFormatException ignored) { + quality = 0; + } + } + if (quality > 0 && quality <= 1) return true; + } + return false; + } + + public static CompletableFuture respond( + Context context, T data, Function render, Executor renderExecutor) { + return respond(context, data, Function.identity(), render, renderExecutor); + } + + public static CompletableFuture respond( + Context context, T data, Function jsonData, + Function render, Executor renderExecutor) { + if (wantsJson(context)) { + RequestUtil.putResult(context, jsonData.apply(data)); + return CompletableFuture.completedFuture(null); + } + return CompletableFuture.supplyAsync(() -> render.apply(data), renderExecutor) + .thenAccept(bytes -> context.status(200).contentType("image/png").result(bytes)); + } +} diff --git a/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java b/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java index cdb0601..03d868c 100644 --- a/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java +++ b/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java @@ -1,17 +1,16 @@ package xyz.zcraft.ostella.network; +import xyz.zcraft.osu.model.multiplayer.Match; + import com.google.gson.*; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import xyz.zcraft.ostella.config.AppConfig; -import xyz.zcraft.ostella.data.ScoreType; -import xyz.zcraft.ostella.data.TokenData; -import xyz.zcraft.ostella.data.MultiplayerRoomDetails; -import xyz.zcraft.ostella.data.MultiplayerRoomScore; -import xyz.zcraft.ostella.data.MultiplayerMatchDetails; +import xyz.zcraft.ostella.data.*; import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.service.CacheService; import xyz.zcraft.osu.model.*; +import xyz.zcraft.osu.model.multiplayer.Room; import java.io.IOException; import java.net.URI; @@ -27,12 +26,28 @@ import java.util.List; public class OsuAPI { + public static final int MAX_USER_SCORES_LIMIT = 200; private static final Logger LOG = LogManager.getLogger(OsuAPI.class); private static final HttpClient CLIENT = HttpClient.newBuilder().build(); private static final String BASE_URL = "https://osu.ppy.sh/api/v2"; private static final Gson GSON = new Gson(); private static final int USER_SCORES_PAGE_LIMIT = 100; - public static final int MAX_USER_SCORES_LIMIT = 200; + + private static HttpResponse send(HttpRequest request, HttpResponse.BodyHandler handler) + throws IOException, InterruptedException { + if (ApiActivity.isBackground()) { + ApiActivity.checkBackground(); + HttpRequest bounded = HttpRequest.newBuilder(request, (name, value) -> true) + .timeout(Duration.ofSeconds(30)).build(); + return CLIENT.send(bounded, handler); + } + ApiActivity.begin(); + try { + return CLIENT.send(request, handler); + } finally { + ApiActivity.end(); + } + } public static TokenData getToken(AppConfig conf) { try { @@ -49,7 +64,7 @@ public static TokenData getToken(AppConfig conf) { .header("Accept", "application/json") .build(); - final String body = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); + final String body = send(request, HttpResponse.BodyHandlers.ofString()).body(); final JsonObject asJsonObject = JsonParser.parseString(body).getAsJsonObject(); return new TokenData( @@ -70,7 +85,7 @@ public static Score getScore(TokenData tokenData, long scoreId) { .GET() .build(); - final String body = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); + final String body = send(request, HttpResponse.BodyHandlers.ofString()).body(); if (JsonParser.parseString(body).getAsJsonObject().has("error")) { return null; @@ -99,7 +114,7 @@ public static Score getLegacyScore(TokenData tokenData, long legacyScoreId) { .GET() .build(); - final String body = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); + final String body = send(request, HttpResponse.BodyHandlers.ofString()).body(); if (JsonParser.parseString(body).getAsJsonObject().has("error")) { return null; @@ -132,7 +147,7 @@ public static List getUserScores(TokenData tokenData, long uid, ScoreType for (UserScoresPage page : userScoresPages(limit, offset)) { final String url = userScoresUrl(uid, type, fail, page); final var request = newRequestBuilder(tokenData, url).GET().build(); - final HttpResponse send = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse send = send(request, HttpResponse.BodyHandlers.ofString()); if (send.statusCode() == 404) { throw new ApiException(ErrorCode.NO_USER_FOUND, "User not found for uid " + uid); @@ -191,9 +206,6 @@ static String userScoresUrl(long uid, String type, boolean includeFails, UserSco ); } - record UserScoresPage(int limit, int offset) { - } - public static Score getUserScore(TokenData tokenData, long uid, long beatmapId, String mods) { LOG.debug("Fetching score for user id {} on beatmap id {}", uid, beatmapId); try { @@ -207,7 +219,7 @@ public static Score getUserScore(TokenData tokenData, long uid, long beatmapId, .GET() .build(); - final String body = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); + final String body = send(request, HttpResponse.BodyHandlers.ofString()).body(); if (JsonParser.parseString(body).getAsJsonObject().has("error")) { return null; @@ -223,14 +235,14 @@ public static Score getUserScore(TokenData tokenData, long uid, long beatmapId) return getUserScore(tokenData, uid, beatmapId, null); } - public static MultiplayerRoom getCurrentRoom(String auth) { + public static Room getCurrentRoom(String auth) { LOG.debug("Getting current room"); try { final var request = newRequestBuilder(auth, "/rooms?mode=participated&type_group=realtime&is_active=true") .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 401 || response.statusCode() == 403) { throw new ApiException( @@ -260,7 +272,7 @@ public static MultiplayerRoom getCurrentRoom(String auth) { return null; } - return GSON.fromJson(arr.get(0), MultiplayerRoom.class); + return GSON.fromJson(arr.get(0), Room.class); } catch (JsonSyntaxException | InterruptedException | IOException e) { throw new ApiException(ErrorCode.ROOM_FETCH_FAILED, "Failed to fetch current room", e); } @@ -273,7 +285,7 @@ public static UserExtended getUser(TokenData tokenData, long uid) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -299,7 +311,7 @@ public static UserExtended getUser(TokenData tokenData, String username) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -329,7 +341,7 @@ public static List getUsers(TokenData tokenData, List uids) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -360,7 +372,7 @@ public static List searchBeatmapset(TokenData tokenData, String quer .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -393,7 +405,7 @@ public static String searchBeatmapsetRaw(TokenData tokenData, String queryString .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -412,14 +424,14 @@ public static String searchBeatmapsetRaw(TokenData tokenData, String queryString } } - public static List getRooms(TokenData tokenData) { + public static List getRooms(TokenData tokenData) { LOG.debug("Fetching multiplayer rooms"); try { final var request = newRequestBuilder(tokenData, "/rooms") .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -434,9 +446,9 @@ public static List getRooms(TokenData tokenData) { final String body = response.body(); - final LinkedList rooms = new LinkedList<>(); + final LinkedList rooms = new LinkedList<>(); JsonParser.parseString(body).getAsJsonArray().forEach( - s -> rooms.add(GSON.fromJson(s, MultiplayerRoom.class))); + s -> rooms.add(GSON.fromJson(s, Room.class))); return rooms; } catch (IOException | InterruptedException e) { @@ -451,7 +463,7 @@ public static Beatmapset getBeatmapset(TokenData tokenData, long setId) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -481,7 +493,7 @@ public static Beatmapset getBeatmapsetFromBeatmap(TokenData tokenData, long beat .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -511,7 +523,7 @@ public static BeatmapExtended getBeatmap(TokenData tokenData, long beatmapId) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -534,13 +546,13 @@ public static BeatmapExtended getBeatmap(TokenData tokenData, long beatmapId) { } } - public static MultiplayerRoomDetails getRoom(TokenData tokenData, long roomId) { + public static Room getRoom(TokenData tokenData, long roomId) { LOG.debug("Fetching multiplayer room {}", roomId); try { final var request = newRequestBuilder(tokenData, "/rooms/" + roomId) .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { throw new ApiException(ErrorCode.NO_ROOM_FOUND, "Multiplayer room " + roomId + " was not found"); @@ -552,7 +564,7 @@ public static MultiplayerRoomDetails getRoom(TokenData tokenData, long roomId) { ); } - MultiplayerRoomDetails room = GSON.fromJson(response.body(), MultiplayerRoomDetails.class); + Room room = GSON.fromJson(response.body(), Room.class); if (room == null || room.getId() <= 0) { throw new ApiException(ErrorCode.ROOM_FETCH_FAILED, "Invalid response for room " + roomId); } @@ -562,10 +574,8 @@ public static MultiplayerRoomDetails getRoom(TokenData tokenData, long roomId) { } } - public static MultiplayerRoomDetails.PlaylistItem getRoomEventPlaylistItem( - TokenData tokenData, - long roomId, - long playlistItemId + public static Room.PlaylistItem getRoomEventPlaylistItem( + TokenData tokenData, long roomId, long playlistItemId ) { return getRoomEventPlaylistItems(tokenData, roomId).stream() .filter(item -> item.getId() == playlistItemId) @@ -573,16 +583,15 @@ public static MultiplayerRoomDetails.PlaylistItem getRoomEventPlaylistItem( .orElse(null); } - public static List getRoomEventPlaylistItems( - TokenData tokenData, - long roomId + public static List getRoomEventPlaylistItems( + TokenData tokenData, long roomId ) { LOG.debug("Fetching events for multiplayer room {}", roomId); try { final var request = newRequestBuilder(tokenData, "/rooms/" + roomId + "/events") .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return List.of(); @@ -604,39 +613,40 @@ public static List getRoomEventPlaylistItem } } - static MultiplayerRoomDetails.PlaylistItem eventPlaylistItem(String body, long playlistItemId) { + static Room.PlaylistItem eventPlaylistItem(String body, long playlistItemId) { return eventPlaylistItems(body).stream() .filter(item -> item.getId() == playlistItemId) .findFirst() .orElse(null); } - static List eventPlaylistItems(String body) { + static List eventPlaylistItems(String body) { JsonObject root = JsonParser.parseString(body).getAsJsonObject(); JsonArray playlistItems = root.has("playlist_items") && root.get("playlist_items").isJsonArray() ? root.getAsJsonArray("playlist_items") : new JsonArray(); - List result = new ArrayList<>(playlistItems.size()); + List result = new ArrayList<>(playlistItems.size()); for (JsonElement element : playlistItems) { if (!element.isJsonObject()) { continue; } - result.add(GSON.fromJson(element, MultiplayerRoomDetails.PlaylistItem.class)); + result.add(GSON.fromJson(element, Room.PlaylistItem.class)); } return List.copyOf(result); } - public static MultiplayerMatchDetails getMatch(TokenData tokenData, long matchId) { + public static Match getMatch(TokenData tokenData, long matchId) { LOG.debug("Fetching stable multiplayer match {}", matchId); try { final var request = newRequestBuilder(tokenData, "/matches/" + matchId + "?limit=101") .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { throw new ApiException(ErrorCode.NO_ROOM_FOUND, "Multiplayer match " + matchId + " was not found"); } + if (response.statusCode() >= 400) { throw new ApiException( ErrorCode.ROOM_FETCH_FAILED, @@ -644,7 +654,7 @@ public static MultiplayerMatchDetails getMatch(TokenData tokenData, long matchId ); } - MultiplayerMatchDetails match = GSON.fromJson(response.body(), MultiplayerMatchDetails.class); + Match match = GSON.fromJson(response.body(), Match.class); if (match == null || match.getMatch() == null || match.getMatch().getId() <= 0) { throw new ApiException(ErrorCode.ROOM_FETCH_FAILED, "Invalid response for match " + matchId); } @@ -655,16 +665,14 @@ public static MultiplayerMatchDetails getMatch(TokenData tokenData, long matchId } public static List getRoomPlaylistScores( - TokenData tokenData, - long roomId, - long playlistItemId + TokenData tokenData, long roomId, long playlistItemId ) { LOG.debug("Fetching scores for multiplayer room {} playlist item {}", roomId, playlistItemId); try { String endpoint = "/rooms/%d/playlist/%d/scores?limit=100&sort=score_desc" .formatted(roomId, playlistItemId); final var request = newRequestBuilder(tokenData, endpoint).GET().build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { throw new ApiException(ErrorCode.NO_SCORE_FOUND, "No scores for playlist item " + playlistItemId); @@ -702,6 +710,7 @@ public static List getRoomPlaylistScores( } } + private static String multiplayerTeam(JsonObject score) { if (score.has("team") && !score.get("team").isJsonNull()) { return score.get("team").getAsString(); @@ -715,7 +724,6 @@ private static String multiplayerTeam(JsonObject score) { return null; } - public static BeatmapExtended getBeatmapByChecksum(TokenData tokenData, String checksum) { LOG.debug("Fetching beatmap with checksum {}", checksum); try { @@ -724,7 +732,7 @@ public static BeatmapExtended getBeatmapByChecksum(TokenData tokenData, String c .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; } @@ -752,7 +760,7 @@ public static List getLatestPassedScores(TokenData tokenData) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -795,7 +803,7 @@ public static byte[] getBeatmapBytes(long beatmapId) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() == 404) { return null; @@ -834,7 +842,7 @@ public static byte[] getImageBytes(String url) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() == 404) { return null; @@ -859,7 +867,7 @@ public static byte[] getReplayBytes(TokenData tokenData, long id) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() == 404) { throw new ApiException( @@ -888,7 +896,7 @@ public static boolean isOsuApiHealthy(TokenData tokenData) { .GET() .build(); - HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.discarding()); + HttpResponse response = send(request, HttpResponse.BodyHandlers.discarding()); return response.statusCode() == 200; } catch (InterruptedException | IOException e) { return false; @@ -902,7 +910,7 @@ public static List getFriends(String auth) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -931,7 +939,7 @@ public static User getSelf(String auth) { .GET() .build(); - final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { return null; @@ -950,5 +958,8 @@ public static User getSelf(String auth) { throw new ApiException(ErrorCode.USER_FETCH_FAILED, "Network failed to get self data", e); } } + + record UserScoresPage(int limit, int offset) { + } } diff --git a/src/main/java/xyz/zcraft/ostella/network/PerfPlusApi.java b/src/main/java/xyz/zcraft/ostella/network/PerfPlusApi.java index ae977b4..af666fe 100644 --- a/src/main/java/xyz/zcraft/ostella/network/PerfPlusApi.java +++ b/src/main/java/xyz/zcraft/ostella/network/PerfPlusApi.java @@ -11,11 +11,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.concurrent.CompletableFuture; public final class PerfPlusApi { @@ -36,22 +32,6 @@ public PerfPlusApi(String endpoint) { : URI.create(endpoint.strip().replaceAll("/+$", "") + "/batch/calculation"); } - public boolean isConfigured() { - return calculationEndpoint != null; - } - - public CompletableFuture calculate(Score score) { - return calculate(toRequest(score)); - } - - public CompletableFuture calculateBeatmap(long beatmapId, String mods) { - if (beatmapId <= 0) throw new IllegalArgumentException("beatmapId must be positive"); - List modRequests = parseModAcronyms(mods).stream() - .map(acronym -> new ModRequest(acronym, Map.of())) - .toList(); - return calculate(new ScoreRequest(String.valueOf(beatmapId), modRequests, null, 0, 0, 0)); - } - public static List parseModAcronyms(String mods) { if (mods == null || mods.isBlank()) return List.of(); String normalized = mods.toUpperCase(Locale.ROOT).replaceAll("[+,\\s]", ""); @@ -65,21 +45,6 @@ public static List parseModAcronyms(String mods) { .toList(); } - private CompletableFuture calculate(ScoreRequest score) { - if (!isConfigured()) return CompletableFuture.completedFuture(null); - - HttpRequest request = HttpRequest.newBuilder(calculationEndpoint) - .timeout(Duration.ofSeconds(15)) - .header("Accept", "application/json") - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString( - GSON.toJson(List.of(score)), StandardCharsets.UTF_8)) - .build(); - - return client.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) - .thenApply(response -> parseResponse(response.statusCode(), response.body())); - } - private static ScoreRequest toRequest(Score score) { Objects.requireNonNull(score, "score"); Long beatmapId = score.getBeatmapId(); @@ -132,6 +97,37 @@ private static PerformancePlus parseResponse(int statusCode, String body) { } } + public boolean isConfigured() { + return calculationEndpoint != null; + } + + public CompletableFuture calculate(Score score) { + return calculate(toRequest(score)); + } + + public CompletableFuture calculateBeatmap(long beatmapId, String mods) { + if (beatmapId <= 0) throw new IllegalArgumentException("beatmapId must be positive"); + List modRequests = parseModAcronyms(mods).stream() + .map(acronym -> new ModRequest(acronym, Map.of())) + .toList(); + return calculate(new ScoreRequest(String.valueOf(beatmapId), modRequests, null, 0, 0, 0)); + } + + private CompletableFuture calculate(ScoreRequest score) { + if (!isConfigured()) return CompletableFuture.completedFuture(null); + + HttpRequest request = HttpRequest.newBuilder(calculationEndpoint) + .timeout(Duration.ofSeconds(15)) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + GSON.toJson(List.of(score)), StandardCharsets.UTF_8)) + .build(); + + return client.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) + .thenApply(response -> parseResponse(response.statusCode(), response.body())); + } + private record ScoreRequest( String beatmapId, List mods, diff --git a/src/main/java/xyz/zcraft/ostella/network/Response.java b/src/main/java/xyz/zcraft/ostella/network/Response.java index 5e89b17..f142776 100644 --- a/src/main/java/xyz/zcraft/ostella/network/Response.java +++ b/src/main/java/xyz/zcraft/ostella/network/Response.java @@ -23,8 +23,17 @@ public String toString() { final JsonObject obj = new JsonObject(); obj.addProperty("success", success); obj.addProperty("message", message); - obj.add("data", data); + obj.add("data", responseData()); return obj.toString(); } + + private JsonElement responseData() { + if (data != null && data.isJsonArray()) { + JsonObject result = new JsonObject(); + result.add("result", data); + return result; + } + return data; + } } diff --git a/src/main/java/xyz/zcraft/ostella/network/Router.java b/src/main/java/xyz/zcraft/ostella/network/Router.java index b0b95af..7ad592f 100644 --- a/src/main/java/xyz/zcraft/ostella/network/Router.java +++ b/src/main/java/xyz/zcraft/ostella/network/Router.java @@ -5,10 +5,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; -import xyz.zcraft.ostella.config.AppConfig; -import xyz.zcraft.ostella.data.ScoreId; +import xyz.zcraft.ostella.network.ImageResponse; import xyz.zcraft.ostella.cache.CacheControlRequest; import xyz.zcraft.ostella.cache.CacheControlResult; +import xyz.zcraft.ostella.config.AppConfig; +import xyz.zcraft.ostella.data.ScoreId; import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.network.controller.*; import xyz.zcraft.ostella.service.AsyncService; @@ -17,7 +18,9 @@ import xyz.zcraft.ostella.service.ReplayService; import xyz.zcraft.ostella.util.TokenManager; import xyz.zcraft.ostella.util.VersionInfo; -import xyz.zcraft.osu.model.*; +import xyz.zcraft.osu.model.BeatmapExtended; +import xyz.zcraft.osu.model.Mod; +import xyz.zcraft.osu.model.Score; import xyz.zcraft.osu.parser.BeatmapParser; import xyz.zcraft.osu.parser.OsuParser; import xyz.zcraft.osu.parser.data.beatmap.OsuBeatmap; @@ -34,13 +37,14 @@ public class Router implements Closeable { static final Logger LOG = LogManager.getLogger(Router.class); public final RenderService renderer; public final AsyncService executor; + public final xyz.zcraft.ostella.service.AutoCacheService autoCache; public final TokenManager tokenManager; public final ReplayService replayService; public final AppConfig conf; - final Gson GSON = new Gson(); public final ReplayController replayController; - final BeatmapController beatmapController; public final ScoreController scoreController; + final Gson GSON = new Gson(); + final BeatmapController beatmapController; final BeatmapsetController beatmapsetController; final LeaderboardController leaderboardController; final AnalyzeController analyzeController; @@ -56,6 +60,7 @@ public Router(AppConfig conf, TokenManager tokenManager) throws IOException { conf.ostella().replayMaxConcurrent()); CacheService.initialize(this.executor); + this.autoCache = new xyz.zcraft.ostella.service.AutoCacheService(executor, tokenManager); this.renderer = new RenderService(conf.ostella().renderWorkers()); @@ -74,6 +79,8 @@ public Router(AppConfig conf, TokenManager tokenManager) throws IOException { } protected void getServerStatus(@NotNull Context context) { + final int onlineWorkers = replayService.probeWorkers().size(); + final int configuredWorkers = conf.replayRender().workers().size(); context.future(() -> executor .enqueueAsync(() -> OsuAPI.isOsuApiHealthy(tokenManager.getTokenData())) .thenAccept(r -> context.status(200) @@ -83,6 +90,8 @@ protected void getServerStatus(@NotNull Context context) { GSON.toJsonTree(Map.of( "ostella", true, "ostella_version", VersionInfo.getVersion(), + "all_render_workers", configuredWorkers, + "online_render_workers", onlineWorkers, "osu_api", r ))).toString()))); @@ -103,6 +112,7 @@ public CacheControlResult controlCache(CacheControlRequest request) { CacheControlResult local = fetch ? CacheService.fetch(request, tokenManager.getTokenData()) : CacheService.control(request); + if (List.of("BEATMAP-JSON", "BEATMAPSET-JSON").contains(local.type())) return local; List nodes = new ArrayList<>(local.nodes()); Path fetchedPath = fetch && !local.nodes().isEmpty() && List.of("FETCHED", "PRESENT").contains(local.nodes().getFirst().status()) @@ -168,6 +178,7 @@ protected void getDaily(@NotNull Context context) { @Override public void close() { + autoCache.close(); executor.close(); renderer.close(); replayService.close(); @@ -207,7 +218,7 @@ public CompletableFuture getScore(long id) { } public void renderCustomTemplate(@NotNull Context context) { - final String s = context.pathParam("templateName"); + final String templateName = context.pathParam("templateName"); final JsonObject data = JsonParser.parseString(context.body()).getAsJsonObject(); @@ -282,8 +293,8 @@ public void renderCustomTemplate(@NotNull Context context) { } return variables; }) - .thenApplyAsync(variables -> renderer.renderCustomTemplate(s, variables), renderer.getRenderExecutor()) - .thenAccept(bytes -> context.status(200).result(bytes)) + .thenCompose(variables -> ImageResponse.respond(context, variables, + values -> renderer.renderCustomTemplate(templateName, values), renderer.getRenderExecutor())) ); } } diff --git a/src/main/java/xyz/zcraft/ostella/network/WebServer.java b/src/main/java/xyz/zcraft/ostella/network/WebServer.java index 99b6505..7cc590a 100644 --- a/src/main/java/xyz/zcraft/ostella/network/WebServer.java +++ b/src/main/java/xyz/zcraft/ostella/network/WebServer.java @@ -1,6 +1,7 @@ package xyz.zcraft.ostella.network; import io.javalin.Javalin; +import io.javalin.http.UnauthorizedResponse; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.eclipse.jetty.util.thread.QueuedThreadPool; @@ -38,6 +39,17 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { threadPool.setName("ServPool"); cfg.jetty.threadPool = threadPool; + cfg.routes.beforeMatched(ctx -> { + final String token = conf.ostella().token(); + if (token != null && !token.isBlank()) { + final String header = ctx.header(Headers.SERVICE_AUTHORIZATION); + if (header == null || !header.equals("Bearer " + token)) { + ctx.status(401).result("Unauthorized"); + throw new UnauthorizedResponse(); + } + } + }); + cfg.routes.before(ctx -> { requests.incrementAndGet(); LOG.debug("{} {} {}", ctx.method(), ctx.path(), ctx.queryString()); @@ -45,14 +57,15 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { cfg.routes .get("/beatmaps/lookup", router.beatmapController::lookupBeatmap) - .get("/beatmaps/{beatmapId}/analysis", router.beatmapController::renderBeatmapAnalysisById) - .get("/beatmaps/{beatmapId}", router.beatmapController::renderBeatmapById) + .get("/beatmaps/{beatmapId}/analysis", router.beatmapController::getBeatmapAnalysisById) + .get("/beatmaps/{beatmapId}", router.beatmapController::getBeatmapById) .post("/beatmaps/{beatmapId}/leaderboards", router.leaderboardController::getMapLeaderboard) .get("/beatmaps/{beatmapId}/background", router.beatmapController::getBackground) + .post("/beatmapsets", router.beatmapsetController::getBeatmapsets) .get("/beatmapsets/lookup", router.beatmapsetController::lookupBeatmapset) .get("/beatmapsets/search", router.beatmapsetController::searchBeatmapset) - .get("/beatmapsets/{beatmapsetId}", router.beatmapsetController::renderBeatmapsetById) + .get("/beatmapsets/{beatmapsetId}", router.beatmapsetController::getBeatmapsetById) .get("/beatmapsets/{beatmapsetId}/background", router.beatmapsetController::getBeatmapsetBg) .get("/beatmapsets/{beatmapsetId}/download", router.beatmapsetController::downloadBeatmapset) @@ -61,8 +74,8 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { .post("/scores/random/users", router.scoreController::randomScoreFromUsers) .get("/scores/random/users/{userId}/weights", router.scoreController::randomScoreFromUsersWeights) .post("/scores/random/users/{userId}/weights", router.scoreController::randomScoreFromUsersWeights) - .get("/scores/{scoreId}", router.scoreController::renderScoreById) - .get("/scores/{scoreId}/analysis", router.analyzeController::renderScoreAnalysisById) + .get("/scores/{scoreId}", router.scoreController::getScoreById) + .get("/scores/{scoreId}/analysis", router.analyzeController::getScoreAnalysisById) .get("/scores/{scoreId}/highlight", router.analyzeController::getScoreHighlight) .get("/scores/{scoreId}/misses", router.analyzeController::getMisses) .get("/scores/{scoreId}/misses/{missIndex}/visualize", router.analyzeController::visualizeMiss) @@ -71,7 +84,7 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { .get("/multiplayer/rooms/current/item", router.multiplayerController::getCurrentRoomItem) .get("/multiplayer/rooms/{roomId}/watch", router.multiplayerController::getRoomWatchState) .get("/multiplayer/rooms/{roomId}/playlist/{playlistItemId}/result", - router.multiplayerController::renderRoomResult) + router.multiplayerController::getRoomResult) .post("/users", router.userController::getUsers) .post("/users/lookup", router.userController::lookupUser) @@ -114,30 +127,17 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { cfg.routes .exception(ApiException.class, (e, ctx) -> { failures.incrementAndGet(); - switch (e.getErrorCode()) { - case ErrorCode.NO_BEATMAP_FOUND, - ErrorCode.NO_BEATMAPSET_FOUND, - ErrorCode.NO_SCORE_FOUND, - ErrorCode.NO_ROOM_FOUND, - ErrorCode.NO_USER_FOUND -> ctx.status(404); - - case ErrorCode.UNAUTHORIZED -> ctx.status(401); - - case ErrorCode.ILLEGAL_ARGUMENT, - ErrorCode.REPLAY_UNAVAILABLE -> ctx.status(400); - - case ErrorCode.BEATMAP_FETCH_FAILED, - ErrorCode.BEATMAPSET_FETCH_FAILED, - ErrorCode.SCORE_FETCH_FAILED, - ErrorCode.USER_FETCH_FAILED, - ErrorCode.RENDER_QUEUE_FULL -> ctx.status(429); - case ErrorCode.RENDERER_UNAVAILABLE, - ErrorCode.PERFORMANCE_PLUS_UNAVAILABLE -> ctx.status(502); - - default -> ctx.status(500); + if ("body".equalsIgnoreCase(ctx.header("X-Error-Mode"))) { + ctx.status(200) + .contentType("application/json") + .result(new Response(false, e.getMessage(), e.getErrorCode().toJson()).toString()); + } else { + ctx.status(e.getErrorCode().getHttpCode()) + .contentType("application/json") + .result(new Response(false, e.getMessage(), e.getErrorCode().toJson()).toString()); } - ctx.result(new Response(false, e.getMessage(), e.getErrorCode().toJson()).toString()); + if (e.getWrappedException() != null) { LOG.error("API error occurred while processing request: {} - {}", ctx.queryString(), e.getMessage(), e.getWrappedException()); } else { @@ -146,7 +146,7 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { }) .exception(Exception.class, (e, ctx) -> { failures.incrementAndGet(); - ctx.status(500).result(new Response(false, "An error occurred while processing the request!", null).toString()); + ctx.status(500).contentType("application/json").result(new Response(false, "An error occurred while processing the request!", null).toString()); LOG.error("An error occurred while processing request: {}", ctx.queryString(), e); }); }); @@ -155,6 +155,7 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { public void start() { app.start(conf.webserver().port()); running.set(true); + router.autoCache.start(); LOG.info("Started web server on port {}", conf.webserver().port()); } @@ -189,6 +190,10 @@ public void deleteReplayJob(String jobId) { router.replayService.deleteJob(jobId); } + public void setAutoCache(xyz.zcraft.ostella.service.AutoCacheService.Type type, boolean enabled) { + router.autoCache.setEnabled(type, enabled); + } + public int clearCache(CacheService.CacheArea area) { return CacheService.clear(area); } diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java b/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java index 8480d01..3d3ff7b 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java @@ -4,6 +4,9 @@ import com.google.gson.JsonObject; import desu.life.RosuFFI; import io.javalin.http.Context; +import xyz.zcraft.ostella.network.ImageResponse; +import xyz.zcraft.ostella.data.ScoreAnalysisData; +import xyz.zcraft.ostella.data.PerformanceGraphData; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; @@ -182,12 +185,9 @@ public static PerformanceState calculateFinalState( event -> event.objectIndex() == replaceMissIndex); } - public record PPLoss( - double withoutMiss, - double actual - ){}; - - /** Calculates the PP lost at the moment the target miss occurs. */ + /** + * Calculates the PP lost at the moment the target miss occurs. + */ public static PPLoss calculateRealtimePpLoss( OsuBeatmap beatmap, ReplayAnalyze analyze, int modBits, HitEvent targetMiss ) { @@ -205,7 +205,9 @@ public static PPLoss calculateRealtimePpLoss( return new PPLoss(withoutMissPp, actualPp); } - /** Calculates how much the target miss changes the PP at the end of the map. */ + /** + * Calculates how much the target miss changes the PP at the end of the map. + */ public static PPLoss calculateFinalPpLoss( OsuBeatmap beatmap, ReplayAnalyze analyze, int modBits, HitEvent targetMiss ) { @@ -225,7 +227,9 @@ public static PPLoss calculateFinalPpLoss( return new PPLoss(withoutMissPp, actualPp); } - /** Calculates the final PP lost to all object-start misses while preserving 100s and 50s. */ + /** + * Calculates the final PP lost to all object-start misses while preserving 100s and 50s. + */ public static PPLoss calculateTotalMissPpLoss( OsuBeatmap beatmap, ReplayAnalyze analyze, int modBits ) { @@ -314,7 +318,7 @@ private static List objectResultTimes(List events, .toList(); } - public void renderScoreAnalysisById(@NotNull Context context) { + public void getScoreAnalysisById(@NotNull Context context) { final long scoreId = requirePathScoreId(context, "scoreId"); context.future(() -> router.getScore(scoreId) .thenCompose(score -> { @@ -437,13 +441,12 @@ public void renderScoreAnalysisById(@NotNull Context context) { ScoreId.format(score), error); return null; }) - .thenApply(performancePlus -> new ScoreAnalyzeData( + .thenApply(performancePlus -> new ScoreAnalysisData( score, diffSpec, hitErrors, hitPos, hitPosAbs, missPos, missPosAbs, aimBias, avgTimingError, analyze, performanceGraph, performancePlus, isLazerScore, doSimMatch, simHitResult)); }) - .thenApplyAsync(renderer::renderScoreAnalysis, renderer.getRenderExecutor()) - .thenAccept(bytes -> context.status(200).result(bytes))); + .thenCompose(data -> ImageResponse.respond(context, data, ScoreAnalysisData::responseData, renderer::renderScoreAnalysis, renderer.getRenderExecutor()))); } public void getMisses(@NotNull Context context) { @@ -521,38 +524,18 @@ public void visualizeMiss(@NotNull Context context) { final int missIndex = requirePathInt(context, "missIndex"); context.future(() -> router.getScore(scoreId) .thenApply(score -> getReplayAnalyze(context, score)) - .thenApply(analyze -> MissVisualizeService.visualizeMiss(analyze, missIndex)) - .thenAccept(bytes -> context.status(200).result(bytes))); + .thenApply(analyze -> MissVisualizeService.prepareMiss(analyze, missIndex)) + .thenCompose(data -> ImageResponse.respond(context, data, + MissVisualizeService.MissVisualizationData::responseData, + MissVisualizeService::renderMiss, Runnable::run))); } - public record ScoreAnalyzeData( - Score score, - DiffSpec diffSpec, - List hitErrors, - List hitPositions, - List hitPositionsAbsolute, - List missPositions, - List missPositionsAbsolute, - double aimBias, - double avgTimingError, - ReplayAnalyze replayAnalyze, - PerformanceGraphData performanceGraph, - PerfPlusApi.PerformancePlus performancePlus, - boolean isLazerScore, - boolean doSimMatch, - String simHitResult + public record PPLoss( + double withoutMiss, + double actual ) { } - public record PerformanceGraphData( - List windowDifficulties, - List realtimePp, - List misses, - List hit50s, - List hit100s, - List sliderTickBreaks, - List sliderEndBreaks, - long mapEndTime - ) { - } + + } diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java b/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java index 9f44d0f..50e1d01 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java @@ -2,26 +2,26 @@ import com.google.gson.JsonObject; import io.javalin.http.Context; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jspecify.annotations.NonNull; +import xyz.zcraft.ostella.network.ImageResponse; import xyz.zcraft.ostella.data.BeatmapAnalysisData; +import xyz.zcraft.ostella.data.BeatmapData; import xyz.zcraft.ostella.data.ScoreType; import xyz.zcraft.ostella.exception.ApiException; -import xyz.zcraft.ostella.network.ErrorCode; -import xyz.zcraft.ostella.network.OsuAPI; -import xyz.zcraft.ostella.network.PerfPlusApi; -import xyz.zcraft.ostella.network.Response; -import xyz.zcraft.ostella.network.Router; +import xyz.zcraft.ostella.network.*; import xyz.zcraft.ostella.service.AsyncService; import xyz.zcraft.ostella.service.CacheService; import xyz.zcraft.ostella.service.RenderService; import xyz.zcraft.ostella.util.TokenManager; import xyz.zcraft.osu.model.BeatmapExtended; -import xyz.zcraft.osu.model.MultiplayerRoom; import xyz.zcraft.osu.model.Score; +import xyz.zcraft.osu.model.multiplayer.Room; import xyz.zcraft.osu.parser.BeatmapAnalyzer; -import xyz.zcraft.osu.parser.BeatmapPatternAnalyzer; import xyz.zcraft.osu.parser.BeatmapParser; +import xyz.zcraft.osu.parser.BeatmapPatternAnalyzer; import xyz.zcraft.osu.parser.OsuParser; import xyz.zcraft.osu.parser.data.beatmap.BeatmapPatternAnalysis; import xyz.zcraft.osu.parser.data.beatmap.DiffSpec; @@ -36,9 +36,11 @@ import java.util.List; import java.util.Objects; +import static xyz.zcraft.ostella.service.CacheService.tryCache; import static xyz.zcraft.ostella.util.RequestUtil.*; public class BeatmapController { + private static final Logger LOG = LogManager.getLogger(BeatmapController.class); final RenderService renderer; final AsyncService executor; final TokenManager tokenManager; @@ -72,10 +74,13 @@ private void lookupBeatmapOfSetAsync(@NotNull Context context) { .thenApply(beatmapset -> { if (beatmapset == null) throw new ApiException(ErrorCode.NO_BEATMAPSET_FOUND, "No beatmapset found"); + + tryCache(beatmapset); + final List beatmaps = beatmapset.getBeatmaps(); beatmaps.sort(Comparator.comparingDouble(BeatmapExtended::getDifficultyRating)); - final BeatmapExtended beatmapExtended = beatmaps.get(i - 1); - beatmapExtended.setBeatmapset(beatmapset); + BeatmapExtended beatmapExtended = beatmaps.get(i - 1); + beatmapExtended = BeatmapData.withBeatmapset(beatmapExtended, beatmapset); return beatmapExtended; }) .thenAccept(beatmapExtended -> context.status(200).result( @@ -94,7 +99,7 @@ private void lookupBeatmapOfRefAsync(@NotNull Context context) { } private void lookupBeatmapFromSomeRoom(@NotNull Context context) { - final String auth = context.header("Authorization"); + final String auth = context.header(Headers.OSU_AUTHORIZATION); if (auth == null) { throw new ApiException(ErrorCode.UNAUTHORIZED); @@ -106,7 +111,7 @@ private void lookupBeatmapFromSomeRoom(@NotNull Context context) { if (room == null) throw new ApiException(ErrorCode.NO_ROOM_FOUND, "No multiplayer room found"); - final MultiplayerRoom.CurrentPlaylistItem currentPlaylistItem = room.getCurrentPlaylistItem(); + final Room.PlaylistItem currentPlaylistItem = room.getCurrentPlaylistItem(); if (currentPlaylistItem == null || currentPlaylistItem.getBeatmap() == null) throw new ApiException(ErrorCode.NO_BEATMAP_FOUND, "No beatmap found for current multiplayer room"); @@ -146,10 +151,17 @@ private void lookupBeatmapFromSomeScore(@NonNull Context context, @NotNull Strin .thenApply(beatmapset -> { if (beatmapset == null) throw new ApiException(ErrorCode.NO_BEATMAPSET_FOUND, "No beatmapset found"); - final BeatmapExtended beatmapExtended = beatmapset.getBeatmaps() - .stream().filter(b -> Objects.equals(b.getId(), beatmapId)) - .findFirst().orElseThrow(() -> new ApiException(ErrorCode.NO_BEATMAP_FOUND, "No beatmap found")); - beatmapExtended.setBeatmapset(beatmapset); + + tryCache(beatmapset); + + BeatmapExtended beatmapExtended = beatmapset.getBeatmaps() + .stream() + .filter(b -> Objects.equals(b.getId(), beatmapId)) + .findFirst() + .orElseThrow(() -> new ApiException(ErrorCode.NO_BEATMAP_FOUND, "No beatmap found")); + + beatmapExtended = BeatmapData.withBeatmapset(beatmapExtended, beatmapset); + return beatmapExtended; }); }) @@ -173,12 +185,16 @@ private void lookupBeatmapOfIdAsync(@NotNull Context context) { .thenApply(beatmapset -> { if (beatmapset == null) throw new ApiException(ErrorCode.NO_BEATMAPSET_FOUND, "No beatmapset found"); - final BeatmapExtended beatmapExtended = beatmapset.getBeatmaps() + + tryCache(beatmapset); + + BeatmapExtended beatmapExtended = beatmapset.getBeatmaps() .stream() .filter(b -> Objects.equals(b.getId(), m)) .findFirst() .orElseThrow(() -> new ApiException(ErrorCode.NO_BEATMAP_FOUND, "No beatmap found")); - beatmapExtended.setBeatmapset(beatmapset); + beatmapExtended = BeatmapData.withBeatmapset(beatmapExtended, beatmapset); + return beatmapExtended; }) .thenAccept(beatmapExtended -> context.status(200).result( @@ -186,7 +202,7 @@ private void lookupBeatmapOfIdAsync(@NotNull Context context) { ))); } - public void renderBeatmapById(@NotNull Context context) { + public void getBeatmapById(@NotNull Context context) { final String mod = optionalString(context, "mod"); final long m = requirePathLong(context, "beatmapId"); @@ -195,38 +211,44 @@ public void renderBeatmapById(@NotNull Context context) { .thenApply(beatmapset -> { if (beatmapset == null) throw new ApiException(ErrorCode.NO_BEATMAPSET_FOUND, "No beatmapset found"); - final BeatmapExtended beatmapExtended = beatmapset.getBeatmaps() + + tryCache(beatmapset); + + BeatmapExtended beatmapExtended = beatmapset.getBeatmaps() .stream() .filter(b -> Objects.equals(b.getId(), m)) .findFirst() .orElseThrow(() -> new ApiException(ErrorCode.NO_BEATMAP_FOUND, "No beatmap found")); - beatmapExtended.setBeatmapset(beatmapset); + beatmapExtended = BeatmapData.withBeatmapset(beatmapExtended, beatmapset); + context.header("X-Beatmap-Id", String.valueOf(beatmapExtended.getId())); context.header("X-Beatmapset-Id", String.valueOf(beatmapExtended.getBeatmapsetId())); return beatmapExtended; }) - .thenApplyAsync(beatmap -> { - try { - final Path beatmapPath = CacheService.getBeatmapPath(beatmap.getId()); - final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(beatmapPath); - DiffSpec diffSpec = OsuParser.getDiffSpecForMap(osuBeatmap, mod); - - final List diff = BeatmapAnalyzer.getWindowDifficulties(osuBeatmap, Duration.ofSeconds((long) Math.max(3, (beatmap.getTotalLength() / 50.0)))) - .stream() - .map(WindowDifficulty::pp) - .map(pp -> pp * pp) - .toList(); - - return renderer.renderBeatmap(beatmap, diffSpec, diff); - } catch (Exception e) { - throw new ApiException(ErrorCode.BEATMAP_PARSE_FAILED, e); - } - }, renderer.getRenderExecutor()) - .thenAccept(bytes -> context.status(200).result(bytes))); + .thenCompose(beatmap -> ImageResponse.respond(context, beatmap, + data -> renderBeatmap(data, mod), renderer.getRenderExecutor()))); } - public void renderBeatmapAnalysisById(@NotNull Context context) { + private byte[] renderBeatmap(BeatmapExtended beatmap, String mod) { + try { + final Path beatmapPath = CacheService.getBeatmapPath(beatmap.getId()); + final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(beatmapPath); + DiffSpec diffSpec = OsuParser.getDiffSpecForMap(osuBeatmap, mod); + + final List diff = BeatmapAnalyzer.getWindowDifficulties(osuBeatmap, Duration.ofSeconds((long) Math.max(3, (beatmap.getTotalLength() / 50.0)))) + .stream() + .map(WindowDifficulty::pp) + .map(pp -> pp * pp) + .toList(); + + return renderer.renderBeatmap(beatmap, diffSpec, diff); + } catch (Exception e) { + throw new ApiException(ErrorCode.BEATMAP_PARSE_FAILED, e); + } + } + + public void getBeatmapAnalysisById(@NotNull Context context) { final long beatmapId = requirePathLong(context, "beatmapId"); final String requestedMods = optionalString(context, "mod"); final List mods; @@ -251,7 +273,7 @@ public void renderBeatmapAnalysisById(@NotNull Context context) { .findFirst() .orElseThrow(() -> new ApiException( ErrorCode.NO_BEATMAP_FOUND, "No beatmap found")); - beatmap.setBeatmapset(beatmapset); + beatmap = BeatmapData.withBeatmapset(beatmap, beatmapset); context.header("X-Beatmap-Id", String.valueOf(beatmap.getId())); context.header("X-Beatmapset-Id", String.valueOf(beatmap.getBeatmapsetId())); return beatmap; @@ -295,8 +317,7 @@ public void renderBeatmapAnalysisById(@NotNull Context context) { wrapped); }); }) - .thenApplyAsync(renderer::renderBeatmapAnalysis, renderer.getRenderExecutor()) - .thenAccept(bytes -> context.status(200).result(bytes))); + .thenCompose(data -> ImageResponse.respond(context, data, renderer::renderBeatmapAnalysis, renderer.getRenderExecutor()))); } public void getBackground(@NotNull Context context) { @@ -324,12 +345,22 @@ public void getBackground(@NotNull Context context) { fileName = fileName.substring(1, fileName.length() - 1); } - CacheService.cacheBeatmapsetFile(b.getBeatmapSetId()); - - return CacheService.extractFile(b.getBeatmapSetId(), fileName) + return new BackgroundData(b.getBeatmapId(), b.getBeatmapSetId(), fileName); + }) + .thenAccept(background -> { + if (ImageResponse.wantsJson(context)) { + putResult(context, background); + return; + } + CacheService.cacheBeatmapsetFile(background.beatmapsetId()); + byte[] bytes = CacheService.extractFile(background.beatmapsetId(), background.fileName()) .orElseThrow(() -> new ApiException(ErrorCode.NO_BACKGROUND_FOUND, "No background found")); + String contentType = java.net.URLConnection.guessContentTypeFromName(background.fileName()); + context.status(200).contentType(contentType == null ? "application/octet-stream" : contentType).result(bytes); }) - .thenAccept(bytes -> context.status(200).result(bytes)) ); } + + private record BackgroundData(long beatmapId, long beatmapsetId, String fileName) { + } } diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapsetController.java b/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapsetController.java index 80d82a9..f474c4e 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapsetController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapsetController.java @@ -1,9 +1,9 @@ package xyz.zcraft.ostella.network.controller; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; +import com.google.gson.*; import io.javalin.http.Context; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jspecify.annotations.NonNull; import xyz.zcraft.ostella.data.SearchResultItem; @@ -19,13 +19,18 @@ import java.io.IOException; import java.net.URI; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.stream.Collectors; import static xyz.zcraft.ostella.util.RequestUtil.*; public class BeatmapsetController { + private static final Gson GSON = new Gson(); + private static final Logger LOG = LogManager.getLogger(BeatmapsetController.class); public final RenderService renderer; public final AsyncService executor; public final TokenManager tokenManager; @@ -48,8 +53,82 @@ public void lookupBeatmapset(@NotNull Context context) { } } + public void getBeatmapsets(@NotNull Context context) { + final JsonElement body = JsonParser.parseString(context.body()); + final var idArr = body.getAsJsonObject().getAsJsonArray("ids"); + + if (idArr == null || idArr.isEmpty()) { + context.status(400).result(Response.error("Missing 'ids' array in request body", ErrorCode.ILLEGAL_ARGUMENT).toString()); + return; + } + + context.future(() -> { + List> beatmapsetFutures = new ArrayList<>(idArr.size()); + for (JsonElement jsonElement : idArr) { + final long id = jsonElement.getAsLong(); + beatmapsetFutures.add( + CompletableFuture.supplyAsync( + () -> CacheService.getBeatmapsetJsonCache(id) + .orElseGet(() -> OsuAPI.getBeatmapset(tokenManager.getTokenData(), id)) + ) + ); + } + + return CompletableFuture.allOf(beatmapsetFutures.toArray(new CompletableFuture[0])) + .thenApply(_ -> { + JsonArray resultArr = new JsonArray(); + for (var future : beatmapsetFutures) { + try { + final Beatmapset beatmapset = future.join(); + if (beatmapset == null) { + continue; + } + CacheService.tryCache(beatmapset); + + final List beatmaps = new ArrayList<>(beatmapset.getBeatmaps().size()); + for (BeatmapExtended beatmap : beatmapset.getBeatmaps()) { + final Beatmap e = convertToShort(beatmap); + beatmaps.add(e); + } + + beatmapset.setConverts(null); + beatmapset.setRecentFavourites(null); + beatmapset.setRelatedUsers(null); + + final var obj = GSON.toJsonTree(beatmapset).getAsJsonObject(); + obj.add("beatmaps", GSON.toJsonTree(beatmaps)); + + resultArr.add(obj); + } catch (CompletionException e) { + if (e.getCause() instanceof ApiException apiEx) { + if (apiEx.getErrorCode() == ErrorCode.NO_BEATMAPSET_FOUND || apiEx.getErrorCode() == ErrorCode.BEATMAPSET_FETCH_FAILED) { + LOG.warn("Beatmapset not found for one of the requested ids: {}", apiEx.getMessage()); + } + } + LOG.error("Error fetching beatmapset data", e); + } + } + return resultArr; + }).thenAccept(usersArr -> context.status(200).result(new Response(true, "Success", usersArr).toString())); + }); + } + + private @NonNull Beatmap convertToShort(BeatmapExtended beatmap) { + final Beatmap e = new Beatmap(); + e.setBeatmapsetId(beatmap.getBeatmapsetId()); + e.setDifficultyRating(beatmap.getDifficultyRating()); + e.setId(beatmap.getBeatmapsetId()); + e.setMode(beatmap.getMode()); + e.setStatus(beatmap.getStatus()); + e.setTopUserTagIds(beatmap.getTopUserTagIds()); + e.setTotalLength(beatmap.getTotalLength()); + e.setUserId(beatmap.getUserId()); + e.setVersion(beatmap.getVersion()); + return e; + } + private void lookupBeatmapsetFromCurrentRoom(@NonNull Context context) { - final String auth = context.header("Authorization"); + final String auth = context.header(Headers.OSU_AUTHORIZATION); if (auth == null) { throw new ApiException(ErrorCode.UNAUTHORIZED); @@ -93,20 +172,13 @@ private void lookupBeatmapsetOfIdAsync(@NotNull Context context) { lookupBeatmapsetOfIdAsync(context, requireLong(context, "ms")); } - public void renderBeatmapsetById(@NotNull Context context) { + public void getBeatmapsetById(@NotNull Context context) { final long ms = requirePathLong(context, "beatmapsetId"); - final String header = context.header("Accept"); - if (header != null && header.contains("application/json")) { - context.future( - () -> executor.enqueueAsync(() -> OsuAPI.getBeatmapset(tokenManager.getTokenData(), ms)) - .thenAccept(beatmapset -> putResult(context, beatmapset)) - ); - } else { - context.future(() -> executor.enqueueAsync(() -> OsuAPI.getBeatmapset(tokenManager.getTokenData(), ms)) - .thenApplyAsync(beatmapset -> finalizeBeatmapset(beatmapset, context), renderer.getRenderExecutor()) - .thenAccept(bytes -> context.status(200).result(bytes))); - } + context.future(() -> executor.enqueueAsync(() -> OsuAPI.getBeatmapset(tokenManager.getTokenData(), ms)) + .thenApply(beatmapset -> prepareBeatmapsetResponse(beatmapset, context)) + .thenCompose(beatmapset -> ImageResponse.respond( + context, beatmapset, renderer::renderBeatmapset, renderer.getRenderExecutor()))); } private void lookupBeatmapsetOfIdAsync(@NotNull Context context, long ms) { @@ -141,7 +213,7 @@ private void lookupBeatmapsetOfRefAsync(@NotNull Context context) { } } - private byte[] finalizeBeatmapset(Beatmapset beatmapset, Context context) { + private Beatmapset prepareBeatmapsetResponse(Beatmapset beatmapset, Context context) { if (beatmapset == null) throw new ApiException(ErrorCode.NO_BEATMAPSET_FOUND); beatmapset.getBeatmaps().sort(Comparator.comparingDouble(Beatmap::getDifficultyRating)); context.header("X-Beatmapset-Id", beatmapset.getId().toString()) @@ -154,7 +226,8 @@ private byte[] finalizeBeatmapset(Beatmapset beatmapset, Context context) { .map(d -> String.format("%.2f", d)) .collect(Collectors.joining(","))); - return renderer.renderBeatmapset(beatmapset); + CacheService.tryCache(beatmapset); + return beatmapset; } public void downloadBeatmapset(@NotNull Context context) { @@ -173,23 +246,27 @@ public void downloadBeatmapset(@NotNull Context context) { public void getBeatmapsetBg(@NotNull Context context) { final long ms = requirePathLong(context, "beatmapsetId"); - context.contentType("image/png"); + final boolean json = ImageResponse.wantsJson(context); context.future(() -> executor.enqueueAsync(() -> OsuAPI.getBeatmapset(tokenManager.getTokenData(), ms)) .thenAccept(beatmapset -> { - if (beatmapset != null) { - context.header("X-Beatmapset-Id", beatmapset.getId().toString()); - final String cover = beatmapset.getCovers().getCover(); - try { - context.result(URI.create(cover).toURL().openStream()); - } catch (IOException e) { - context.status(500).result(Response.error("Failed to parse bg url", ErrorCode.IMAGE_FETCH_FAILED).toString()); - } + if (beatmapset == null) throw new ApiException(ErrorCode.NO_BEATMAPSET_FOUND); + context.header("X-Beatmapset-Id", beatmapset.getId().toString()); + final String cover = beatmapset.getCovers().getCover(); + if (json) { + putResult(context, java.util.Map.of("beatmapset_id", ms, "url", cover)); + return; + } + try { + var connection = URI.create(cover).toURL().openConnection(); + String contentType = connection.getContentType(); + context.contentType(contentType == null ? "application/octet-stream" : contentType) + .result(connection.getInputStream()); + } catch (IOException e) { + context.status(500).contentType("application/json").result(Response.error("Failed to parse bg url", ErrorCode.IMAGE_FETCH_FAILED).toString()); } })); } - private static final Gson GSON = new Gson(); - public void searchBeatmapset(@NotNull Context context) { final String query = requireString(context, "q"); diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/LeaderboardController.java b/src/main/java/xyz/zcraft/ostella/network/controller/LeaderboardController.java index 17fa26f..44a23a7 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/LeaderboardController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/LeaderboardController.java @@ -3,6 +3,7 @@ import com.google.gson.Gson; import io.javalin.http.Context; import org.jetbrains.annotations.NotNull; +import xyz.zcraft.ostella.network.ImageResponse; import xyz.zcraft.ostella.data.Placement; import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.network.ErrorCode; @@ -56,20 +57,24 @@ public void getMapLeaderboard(@NotNull Context context) { }) .thenCompose(placements -> executor.enqueueAsync(() -> OsuAPI.getBeatmap(tokenManager.getTokenData(), m)) - .thenApplyAsync(beatmap -> { + .thenApply(beatmap -> { if (beatmap == null) throw new ApiException(ErrorCode.NO_BEATMAP_FOUND); - final Path rosuBeatmapPath = CacheService.getBeatmapPath(m); + return new MapLeaderboardData(beatmap, placements); + })) + .thenCompose(data -> ImageResponse.respond(context, data, + this::renderMapLeaderboard, renderer.getRenderExecutor()))); + } - return finalizeMapLeaderboard(placements, beatmap, rosuBeatmapPath); - }, renderer.getRenderExecutor())) - .thenAccept(imgByte -> context.status(200).result(imgByte))); + private record MapLeaderboardData(BeatmapExtended beatmap, List placements) { } - private byte[] finalizeMapLeaderboard(LinkedList placements, BeatmapExtended beatmap, Path beatmapPath) { + private byte[] renderMapLeaderboard(MapLeaderboardData data) { + BeatmapExtended beatmap = data.beatmap(); + Path beatmapPath = CacheService.getBeatmapPath(beatmap.getId()); try { final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(beatmapPath); final DiffSpec diffSpecForMap = OsuParser.getDiffSpecForMap(osuBeatmap, ""); - return renderer.renderMapLeaderboard(beatmap, placements, diffSpecForMap.getPpSS()); + return renderer.renderMapLeaderboard(beatmap, data.placements(), diffSpecForMap.getPpSS()); } catch (ParseException | AnalyzeException e) { throw new ApiException(ErrorCode.BEATMAP_PARSE_FAILED, "Failed to calculate difficulty", e); } @@ -136,7 +141,7 @@ public void getLeaderboard(@NotNull Context context) { .filter(Objects::nonNull) .flatMap(List::stream) .collect(Collectors.toCollection(LinkedList::new))) - .thenApplyAsync(users -> { + .thenApply(users -> { users.sort(Comparator.comparingDouble((User user) -> Optional.ofNullable(user) .map(User::getStatisticsRulesets) @@ -144,10 +149,11 @@ public void getLeaderboard(@NotNull Context context) { .map(User.Statistics::getPp) .orElse(0.0) ).reversed()); - return renderer.renderLeaderboard(users); + return users; - }, renderer.getRenderExecutor()) - .thenAccept(imgByte -> context.status(200).result(imgByte)) + }) + .thenCompose(users -> ImageResponse.respond(context, users, + renderer::renderLeaderboard, renderer.getRenderExecutor())) ); } diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/MultiplayerController.java b/src/main/java/xyz/zcraft/ostella/network/controller/MultiplayerController.java index 6c314c1..dee3576 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/MultiplayerController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/MultiplayerController.java @@ -1,45 +1,33 @@ package xyz.zcraft.ostella.network.controller; import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; import com.google.gson.JsonObject; import io.javalin.http.Context; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; -import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.data.MultiplayerResultData; -import xyz.zcraft.ostella.data.MultiplayerMatchDetails; -import xyz.zcraft.ostella.data.MultiplayerRoomDetails; import xyz.zcraft.ostella.data.MultiplayerRoomScore; import xyz.zcraft.ostella.data.MultiplayerRoomWatchState; +import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.network.*; import xyz.zcraft.ostella.service.AsyncService; import xyz.zcraft.ostella.service.MultiplayerResultFactory; import xyz.zcraft.ostella.service.RenderService; import xyz.zcraft.ostella.util.TokenManager; import xyz.zcraft.osu.model.BeatmapExtended; -import xyz.zcraft.osu.model.MultiplayerRoom; import xyz.zcraft.osu.model.Score; import xyz.zcraft.osu.model.User; import xyz.zcraft.osu.model.UserExtended; +import xyz.zcraft.osu.model.multiplayer.Match; +import xyz.zcraft.osu.model.multiplayer.MatchScore; +import xyz.zcraft.osu.model.multiplayer.Room; -import java.util.Comparator; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; public class MultiplayerController { - private static final Logger LOG = LogManager.getLogger(MultiplayerController.class); public static final Gson GSON = new Gson(); - + private static final Logger LOG = LogManager.getLogger(MultiplayerController.class); public final RenderService renderer; public final AsyncService executor; public final TokenManager tokenManager; @@ -52,593 +40,677 @@ public MultiplayerController(Router router) { this.tokenManager = router.tokenManager; } - public void getCurrentRoom(@NotNull Context context) { - final String auth = context.header("Authorization"); - - if (auth == null) { - throw new ApiException(ErrorCode.UNAUTHORIZED); - } - - context.future(() -> executor - .enqueueAsync(() -> OsuAPI.getCurrentRoom(auth)) - .thenApply(room -> { - if (room == null) { - throw new ApiException(ErrorCode.NO_ROOM_FOUND, "User is not in a room!"); - } - return room; - }) - .thenAccept(room -> context.status(200).result(new Response(true, "Success", GSON.toJsonTree(room)).toString())) - ); - } - - public void getCurrentRoomItem(@NotNull Context context) { - final String auth = context.header("Authorization"); - - if (auth == null) { - throw new ApiException(ErrorCode.UNAUTHORIZED); - } - - context.future(() -> executor - .enqueueAsync(() -> OsuAPI.getCurrentRoom(auth)) - .thenApply(room -> { - if (room == null) { - throw new ApiException(ErrorCode.NO_ROOM_FOUND, "User is not in a room!"); - } - final var currentPlaylistItem = room.getCurrentPlaylistItem(); - if (currentPlaylistItem == null) { - throw new ApiException(ErrorCode.NO_BEATMAPSET_FOUND, "Room has no current playlist item!"); - } - return currentPlaylistItem; - }) - .thenApply((MultiplayerRoom.CurrentPlaylistItem c) -> { - final BeatmapExtended beatmap = c.getBeatmap(); - if (beatmap == null) { - throw new ApiException(ErrorCode.NO_BEATMAP_FOUND, "Beatmap is null!"); - } - JsonObject res = new JsonObject(); - res.addProperty("beatmap_id", beatmap.getId()); - res.addProperty("beatmapset_id", beatmap.getBeatmapsetId()); - return res; - }) - .thenAccept(obj -> context.status(200).result(new Response(true, "Success", obj).toString())) - ); - } - - public void getRoomWatchState(@NotNull Context context) { - long roomId = positivePathId(context, "roomId"); - RoomVersion version = roomVersion(context); - context.future(() -> executor - .enqueueAsync(() -> switch (version) { - case LAZER -> toWatchState(OsuAPI.getRoom(tokenManager.getTokenData(), roomId)); - case STABLE -> toWatchState(OsuAPI.getMatch(tokenManager.getTokenData(), roomId)); - }) - .thenAccept(state -> context.status(200) - .contentType("application/json") - .result(new Response(true, "Success", GSON.toJsonTree(state)).toString())) - ); - } - - public void renderRoomResult(@NotNull Context context) { - long roomId = positivePathId(context, "roomId"); - long playlistItemId = positivePathId(context, "playlistItemId"); - RoomVersion version = roomVersion(context); - context.future(() -> executor - .enqueueAsync(() -> switch (version) { - case LAZER -> getLazerResultData(roomId, playlistItemId); - case STABLE -> getStableResultData(roomId, playlistItemId); - }) - .thenApplyAsync(renderer::renderMultiplayerResult, renderer.getRenderExecutor()) - .thenAccept(bytes -> context.status(200).contentType("image/png").result(bytes)) - ); + private static String scoreTeam(MatchScore score) { + if (score.getTeam() != null) return score.getTeam(); + return score.getMatch() == null ? null : score.getMatch().team(); } - private MultiplayerResultData getLazerResultData(long roomId, long playlistItemId) { - MultiplayerRoomDetails room = OsuAPI.getRoom(tokenManager.getTokenData(), roomId); - MultiplayerRoomDetails.PlaylistItem item = findPlaylistItem(room, playlistItemId); - enrichPlaylistItem(item); - - List roomScores = OsuAPI.getRoomPlaylistScores( - tokenManager.getTokenData(), roomId, playlistItemId - ); - List eventItems = isTeamMode(room.getType()) - ? OsuAPI.getRoomEventPlaylistItems(tokenManager.getTokenData(), roomId) - : List.of(); - enrichLazerTeamSnapshot(room, item, roomScores, eventItems); - MultiplayerResultData.SeriesScore seriesScore = lazerSeriesScore( - room, item, roomScores, eventItems); - enrichScores(roomScores, item); - enrichDuelProfiles(roomScores); - User owner = resolveOwner(room, item.getOwnerId()); - return MultiplayerResultFactory.create( - room, - item, - roomScores, - owner, - "lazer", - "scorev2", - room.getType(), - seriesScore - ); + private static Comparator stableScoreComparator(String scoringType) { + return switch (scoringType == null ? "score" : scoringType.toLowerCase(Locale.ROOT)) { + case "accuracy" -> Comparator.comparing( + Score::getAccuracy, Comparator.nullsLast(Comparator.reverseOrder()) + ); + case "combo" -> Comparator.comparing( + Score::getMaxCombo, Comparator.nullsLast(Comparator.reverseOrder()) + ); + default -> Comparator.comparing( + Score::getTotalScore, Comparator.nullsLast(Comparator.reverseOrder()) + ); + }; } - private void enrichLazerTeamSnapshot( - MultiplayerRoomDetails room, - MultiplayerRoomDetails.PlaylistItem item, - List roomScores, - List eventItems + private static List completedItemsThrough( + Room room, + Room.PlaylistItem currentItem ) { - String roomType = room.getType(); - boolean teamVs = roomType != null - && roomType.toLowerCase(Locale.ROOT).replace('-', '_').contains("team"); - boolean missingTeam = roomScores.stream().anyMatch(roomScore -> - roomScore.team() == null || roomScore.team().isBlank()); - if (!teamVs || !missingTeam) { - return; + Map items = new LinkedHashMap<>(); + if (room.getPlaylist() != null) { + room.getPlaylist().stream().filter(Objects::nonNull) + .forEach(item -> items.putIfAbsent(item.getId(), item)); } + items.putIfAbsent(currentItem.getId(), currentItem); - MultiplayerRoomDetails.PlaylistItem eventItem = eventItems.stream() - .filter(value -> value.getId() == item.getId()) - .findFirst() - .orElse(null); - if (eventItem == null || eventItem.getDetails() == null) { - LOG.warn("Room events contain no details for playlist item {} in room {}", item.getId(), room.getId()); - return; + List completed = new ArrayList<>(); + for (Room.PlaylistItem item : items.values()) { + if (item.getId() == currentItem.getId() + || item.getPlayedAt() != null && !item.getPlayedAt().isBlank()) { + completed.add(item); + } + if (item.getId() == currentItem.getId()) break; } - item.setDetails(eventItem.getDetails()); + return List.copyOf(completed); } - private MultiplayerResultData getStableResultData(long matchId, long gameId) { - MultiplayerMatchDetails match = OsuAPI.getMatch(tokenManager.getTokenData(), matchId); - MultiplayerMatchDetails.MatchGame game = findMatchGame(match, gameId); - - MultiplayerRoomDetails room = new MultiplayerRoomDetails(); - room.setId(match.getMatch().getId()); - room.setName(match.getMatch().getName()); - room.setActive(match.getMatch().getEndTime() == null || match.getMatch().getEndTime().isBlank()); - room.setRecentParticipants(match.getUsers()); - - MultiplayerRoomDetails.PlaylistItem item = new MultiplayerRoomDetails.PlaylistItem(); - item.setId(game.getId()); - item.setRoomId(matchId); - item.setBeatmapId(game.getBeatmapId()); - item.setPlayedAt(game.getEndTime()); - item.setBeatmap(game.getBeatmap()); - enrichPlaylistItem(item); + private static String lazerTeamWinner( + List scores, + Room.PlaylistItem item, + Room.PlaylistItem eventItem + ) { + long red = 0; + long blue = 0; + boolean hasRed = false; + boolean hasBlue = false; + for (MultiplayerRoomScore roomScore : scores) { + Score score = roomScore.score(); + if (score == null || score.getTotalScore() == null) continue; + Long userId = scoreUserId(score); + String team = normalizedTeam(firstNonBlank( + roomScore.team(), + item.teamFor(userId), + eventItem == null ? null : eventItem.teamFor(userId) + )); + if ("red".equals(team)) { + red += score.getTotalScore(); + hasRed = true; + } else if ("blue".equals(team)) { + blue += score.getTotalScore(); + hasBlue = true; + } + } + if (!hasRed || !hasBlue || red == blue) return null; + return red > blue ? "red" : "blue"; + } - List scores = stableScores(match, game, item); - MultiplayerResultData.SeriesScore seriesScore = stableSeriesScore(match, game); - enrichDuelProfiles(scores); - User stableLobby = new User(); - stableLobby.setUsername("Stable lobby"); - return MultiplayerResultFactory.create( - room, - item, - scores, - stableLobby, - "stable", - game.getScoringType(), - game.getTeamType(), - seriesScore - ); + private static Long lazerDuelWinner(List scores, Set duelUsers) { + Map values = new HashMap<>(); + for (MultiplayerRoomScore roomScore : scores) { + Score score = roomScore.score(); + Long userId = scoreUserId(score); + if (userId != null && score.getTotalScore() != null) { + values.put(userId, score.getTotalScore()); + } + } + if (!values.keySet().equals(duelUsers)) return null; + List> ordered = values.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return ordered.get(0).getValue().equals(ordered.get(1).getValue()) + ? null : ordered.get(0).getKey(); } - private List stableScores( - MultiplayerMatchDetails match, - MultiplayerMatchDetails.MatchGame game, - MultiplayerRoomDetails.PlaylistItem item + private static MultiplayerResultData.SeriesScore stableSeriesScore( + Match match, + Match.MatchGame currentGame ) { - Map users = new HashMap<>(); - if (match.getUsers() != null) { - match.getUsers().stream().filter(Objects::nonNull).forEach(user -> users.put(user.getId(), user)); + boolean teamMode = isTeamMode(currentGame.getTeamType()); + Set duelUsers = teamMode ? Set.of() : stableUserIds(currentGame.getScores()); + if (!teamMode && duelUsers.size() != 2) { + return MultiplayerResultData.SeriesScore.empty(); } - Comparator scoreComparator = stableScoreComparator(game.getScoringType()); - List scores = (game.getScores() == null - ? List.of() - : game.getScores()).stream() - .map(value -> new MultiplayerRoomScore( - stableScore(value, game, item, users), - null, - scoreTeam(value) - )) - .sorted((left, right) -> scoreComparator.compare(left.score(), right.score())) - .toList(); - - List result = new java.util.ArrayList<>(scores.size()); - for (int index = 0; index < scores.size(); index++) { - MultiplayerRoomScore roomScore = scores.get(index); - result.add(new MultiplayerRoomScore(roomScore.score(), index + 1, roomScore.team())); + Map playerWins = new HashMap<>(); + int redWins = 0; + int blueWins = 0; + for (Match.MatchGame game : completedGamesThrough(match, currentGame)) { + if (teamMode) { + if (!isTeamMode(game.getTeamType())) continue; + String winner = stableTeamWinner(game); + if ("red".equals(winner)) redWins++; + if ("blue".equals(winner)) blueWins++; + } else { + if (isTeamMode(game.getTeamType())) continue; + Long winner = stableDuelWinner(game, duelUsers); + if (winner != null) playerWins.merge(winner, 1, Integer::sum); + } } - return List.copyOf(result); + return new MultiplayerResultData.SeriesScore(playerWins, redWins, blueWins); } - private static String scoreTeam(JsonObject score) { - if (score.has("team") && !score.get("team").isJsonNull()) { - return score.get("team").getAsString(); + private static List completedGamesThrough( + Match match, + Match.MatchGame currentGame + ) { + Map games = new LinkedHashMap<>(); + if (match.getEvents() != null) { + match.getEvents().stream() + .filter(Objects::nonNull) + .map(Match.MatchEvent::getGame) + .filter(Objects::nonNull) + .forEach(game -> games.putIfAbsent(game.getId(), game)); } - if (score.has("match") && score.get("match").isJsonObject()) { - JsonObject match = score.getAsJsonObject("match"); - if (match.has("team") && !match.get("team").isJsonNull()) { - return match.get("team").getAsString(); + games.putIfAbsent(currentGame.getId(), currentGame); + + List completed = new ArrayList<>(); + for (Match.MatchGame game : games.values()) { + if (game.getId() == currentGame.getId() + || game.getEndTime() != null && !game.getEndTime().isBlank()) { + completed.add(game); } + if (game.getId() == currentGame.getId()) break; } - return null; + return List.copyOf(completed); } - private Score stableScore( - JsonObject value, - MultiplayerMatchDetails.MatchGame game, - MultiplayerRoomDetails.PlaylistItem item, - Map users - ) { - JsonObject normalized = value.deepCopy(); - normalizeStableMods(normalized); - if (!normalized.has("total_score")) { - if (normalized.has("legacy_total_score")) { - normalized.add("total_score", normalized.get("legacy_total_score")); - } else if (normalized.has("classic_total_score")) { - normalized.add("total_score", normalized.get("classic_total_score")); - } else if (normalized.has("score")) { - normalized.add("total_score", normalized.get("score")); - } - } - if (!normalized.has("beatmap_id")) { - normalized.addProperty("beatmap_id", game.getBeatmapId()); - } - if (!normalized.has("ended_at") && game.getEndTime() != null) { - normalized.addProperty("ended_at", game.getEndTime()); - } - if (!normalized.has("passed") && normalized.has("match") && normalized.get("match").isJsonObject()) { - JsonObject scoreMatch = normalized.getAsJsonObject("match"); - if (scoreMatch.has("pass")) { - normalized.add("passed", scoreMatch.get("pass")); + private static String stableTeamWinner(Match.MatchGame game) { + double red = 0; + double blue = 0; + boolean hasRed = false; + boolean hasBlue = false; + for (MatchScore score : nullSafeScores(game.getScores())) { + Double value = stableScoringValue(score, game.getScoringType()); + String team = normalizedTeam(scoreTeam(score)); + if (value == null) continue; + if ("red".equals(team)) { + red += value; + hasRed = true; + } else if ("blue".equals(team)) { + blue += value; + hasBlue = true; } } + if (!hasRed || !hasBlue || Double.compare(red, blue) == 0) return null; + return red > blue ? "red" : "blue"; + } - Score score = GSON.fromJson(normalized, Score.class); - score.setBeatmap(item.getBeatmap()); - if (item.getBeatmap() != null) { - score.setBeatmapset(item.getBeatmap().getBeatmapset()); - } - if (score.getUserId() != null && score.getUserId() > 0) { - User user = users.computeIfAbsent( - score.getUserId(), - id -> OsuAPI.getUser(tokenManager.getTokenData(), id) - ); - score.setUser(user); - } - if (score.getPassed() == null) { - score.setPassed(true); - } - if (score.getRank() == null || score.getRank().isBlank()) { - score.setRank(Boolean.TRUE.equals(score.getPassed()) ? "-" : "F"); - } - if (score.getBeatmap() != null) { - try { - router.ensurePp(score); - } catch (RuntimeException e) { - LOG.warn("Failed to estimate pp for stable multiplayer score {}", score.getId(), e); - } + private static Long stableDuelWinner( + Match.MatchGame game, + Set duelUsers + ) { + Map values = new HashMap<>(); + for (MatchScore score : nullSafeScores(game.getScores())) { + Long userId = stableUserId(score); + Double value = stableScoringValue(score, game.getScoringType()); + if (userId != null && value != null) values.put(userId, value); } - return score; + if (!values.keySet().equals(duelUsers)) return null; + List> ordered = values.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return Double.compare(ordered.get(0).getValue(), ordered.get(1).getValue()) == 0 + ? null : ordered.get(0).getKey(); } - private static void normalizeStableMods(JsonObject score) { - if (!score.has("mods") || !score.get("mods").isJsonArray()) { - return; + private static Double stableScoringValue(MatchScore score, String scoringType) { + String normalized = scoringType == null ? "score" : scoringType.toLowerCase(Locale.ROOT); + if ("accuracy".equals(normalized)) return score.getAccuracy(); + if ("combo".equals(normalized)) return score.getMaxCombo() == null ? null : score.getMaxCombo().doubleValue(); + for (Long value : Arrays.asList(score.getTotalScore(), score.getLegacyTotalScore(), + score.getClassicTotalScore())) { + if (value != null) return value.doubleValue(); } - JsonArray mods = score.getAsJsonArray("mods"); - if (mods.isEmpty() || mods.get(0).isJsonObject()) { - return; + return null; + } + + private static Set scoreUserIds(List scores) { + Set ids = new LinkedHashSet<>(); + for (MultiplayerRoomScore roomScore : scores) { + Score score = roomScore.score(); + Long userId = scoreUserId(score); + if (userId != null) ids.add(userId); } - JsonArray normalized = new JsonArray(); - for (JsonElement mod : mods) { - JsonObject value = new JsonObject(); - value.addProperty("acronym", mod.getAsString()); - normalized.add(value); + return Set.copyOf(ids); + } + + private static Long scoreUserId(Score score) { + if (score == null) return null; + if (score.getUserId() != null && score.getUserId() > 0) return score.getUserId(); + return score.getUser() == null || score.getUser().getId() <= 0 ? null : score.getUser().getId(); + } + + private static Set stableUserIds(List scores) { + Set ids = new LinkedHashSet<>(); + for (MatchScore score : nullSafeScores(scores)) { + Long userId = stableUserId(score); + if (userId != null) ids.add(userId); } - score.add("mods", normalized); + return Set.copyOf(ids); } - private static Comparator stableScoreComparator(String scoringType) { - return switch (scoringType == null ? "score" : scoringType.toLowerCase(Locale.ROOT)) { - case "accuracy" -> Comparator.comparing( - Score::getAccuracy, Comparator.nullsLast(Comparator.reverseOrder()) - ); - case "combo" -> Comparator.comparing( - Score::getMaxCombo, Comparator.nullsLast(Comparator.reverseOrder()) - ); - default -> Comparator.comparing( - Score::getTotalScore, Comparator.nullsLast(Comparator.reverseOrder()) - ); + private static Long stableUserId(MatchScore score) { + return scoreUserId(score); + } + + private static List nullSafeScores(List scores) { + return scores == null ? List.of() : scores.stream().filter(Objects::nonNull).toList(); + } + + private static boolean isTeamMode(String teamType) { + if (teamType == null) return false; + String normalized = teamType.toLowerCase(Locale.ROOT).replace('_', '-'); + return normalized.contains("team") && !normalized.contains("head"); + } + + private static String normalizedTeam(String team) { + if (team == null) return null; + return switch (team.toLowerCase(Locale.ROOT)) { + case "red", "1" -> "red"; + case "blue", "2" -> "blue"; + default -> null; }; } - private MultiplayerResultData.SeriesScore lazerSeriesScore( - MultiplayerRoomDetails room, - MultiplayerRoomDetails.PlaylistItem currentItem, - List currentScores, - List eventItems - ) { - boolean teamMode = isTeamMode(room.getType()); - Set duelUsers = teamMode ? Set.of() : scoreUserIds(currentScores); - if (!teamMode && duelUsers.size() != 2) { - return MultiplayerResultData.SeriesScore.empty(); + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) return value; } + return null; + } - Map eventsById = new HashMap<>(); - eventItems.forEach(item -> eventsById.put(item.getId(), item)); - Map playerWins = new HashMap<>(); - int redWins = 0; - int blueWins = 0; + static MultiplayerRoomWatchState toWatchState(Room room) { + Map items = new LinkedHashMap<>(); + if (room.getPlaylist() != null) { + room.getPlaylist().stream() + .filter(Objects::nonNull) + .forEach(item -> items.put(item.getId(), item)); + } + if (room.getCurrentPlaylistItem() != null) { + items.putIfAbsent(room.getCurrentPlaylistItem().getId(), room.getCurrentPlaylistItem()); + } - for (MultiplayerRoomDetails.PlaylistItem item : completedItemsThrough(room, currentItem)) { - List scores; - if (item.getId() == currentItem.getId()) { - scores = currentScores; - } else { - try { - scores = OsuAPI.getRoomPlaylistScores( - tokenManager.getTokenData(), room.getId(), item.getId()); - } catch (ApiException e) { - LOG.warn("Failed to include playlist item {} in room {} series score", - item.getId(), room.getId(), e); - continue; - } - } + List completed = items.values().stream() + .filter(item -> item.getId() > 0 && item.getPlayedAt() != null && !item.getPlayedAt().isBlank()) + .sorted(Comparator + .comparing(Room.PlaylistItem::getPlayedAt) + .thenComparingLong(Room.PlaylistItem::getId)) + .map(item -> new MultiplayerRoomWatchState.CompletedPlay(item.getId(), item.getPlayedAt())) + .toList(); + boolean active = room.isActive() + && (room.getStatus() == null || !room.getStatus().equalsIgnoreCase("ended")); + return new MultiplayerRoomWatchState(room.getId(), room.getName(), active, completed); + } - if (teamMode) { - String winner = lazerTeamWinner(scores, item, eventsById.get(item.getId())); - if ("red".equals(winner)) redWins++; - if ("blue".equals(winner)) blueWins++; - } else { - Long winner = lazerDuelWinner(scores, duelUsers); - if (winner != null) playerWins.merge(winner, 1, Integer::sum); + static MultiplayerRoomWatchState toWatchState(Match match) { + Map games = new LinkedHashMap<>(); + if (match.getEvents() != null) { + match.getEvents().stream() + .filter(Objects::nonNull) + .map(Match.MatchEvent::getGame) + .filter(Objects::nonNull) + .forEach(game -> games.put(game.getId(), game)); + } + List completed = games.values().stream() + .filter(game -> game.getId() > 0 && game.getEndTime() != null && !game.getEndTime().isBlank()) + .sorted(Comparator + .comparing(Match.MatchGame::getEndTime) + .thenComparingLong(Match.MatchGame::getId)) + .map(game -> new MultiplayerRoomWatchState.CompletedPlay(game.getId(), game.getEndTime())) + .toList(); + Match.MatchInfo info = match.getMatch(); + boolean active = info.getEndTime() == null || info.getEndTime().isBlank(); + return new MultiplayerRoomWatchState(info.getId(), info.getName(), active, completed); + } + + private static Match.MatchGame findMatchGame( + Match match, + long gameId + ) { + if (match.getEvents() != null) { + Match.MatchGame game = match.getEvents().stream() + .filter(Objects::nonNull) + .map(Match.MatchEvent::getGame) + .filter(Objects::nonNull) + .filter(value -> value.getId() == gameId) + .findFirst() + .orElse(null); + if (game != null) { + return game; } } - return new MultiplayerResultData.SeriesScore(playerWins, redWins, blueWins); + throw new ApiException(ErrorCode.NO_BEATMAP_FOUND, "Game was not found in match"); } - private static List completedItemsThrough( - MultiplayerRoomDetails room, - MultiplayerRoomDetails.PlaylistItem currentItem + private static Room.PlaylistItem findPlaylistItem( + Room room, + long playlistItemId ) { - Map items = new LinkedHashMap<>(); if (room.getPlaylist() != null) { - room.getPlaylist().stream().filter(Objects::nonNull) - .forEach(item -> items.putIfAbsent(item.getId(), item)); + Room.PlaylistItem item = room.getPlaylist().stream() + .filter(Objects::nonNull) + .filter(value -> value.getId() == playlistItemId) + .findFirst() + .orElse(null); + if (item != null) { + return item; + } } - items.putIfAbsent(currentItem.getId(), currentItem); + if (room.getCurrentPlaylistItem() != null + && room.getCurrentPlaylistItem().getId() == playlistItemId) { + return room.getCurrentPlaylistItem(); + } + throw new ApiException(ErrorCode.NO_BEATMAP_FOUND, "Playlist item was not found in room"); + } - List completed = new ArrayList<>(); - for (MultiplayerRoomDetails.PlaylistItem item : items.values()) { - if (item.getId() == currentItem.getId() - || item.getPlayedAt() != null && !item.getPlayedAt().isBlank()) { - completed.add(item); + private static long positivePathId(Context context, String name) { + String value = context.pathParam(name); + try { + long id = Long.parseLong(value); + if (id > 0) { + return id; } - if (item.getId() == currentItem.getId()) break; + } catch (NumberFormatException ignored) { } - return List.copyOf(completed); + throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, name + " must be a positive integer"); } - private static String lazerTeamWinner( - List scores, - MultiplayerRoomDetails.PlaylistItem item, - MultiplayerRoomDetails.PlaylistItem eventItem - ) { - long red = 0; - long blue = 0; - boolean hasRed = false; - boolean hasBlue = false; - for (MultiplayerRoomScore roomScore : scores) { - Score score = roomScore.score(); - if (score == null || score.getTotalScore() == null) continue; - Long userId = scoreUserId(score); - String team = normalizedTeam(firstNonBlank( - roomScore.team(), - item.teamFor(userId), - eventItem == null ? null : eventItem.teamFor(userId) - )); - if ("red".equals(team)) { - red += score.getTotalScore(); - hasRed = true; - } else if ("blue".equals(team)) { - blue += score.getTotalScore(); - hasBlue = true; - } + private static RoomVersion roomVersion(Context context) { + String value = context.queryParam("version"); + if (value == null || value.isBlank() || value.equalsIgnoreCase("lazer")) { + return RoomVersion.LAZER; } - if (!hasRed || !hasBlue || red == blue) return null; - return red > blue ? "red" : "blue"; + if (value.equalsIgnoreCase("stable")) { + return RoomVersion.STABLE; + } + throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "version must be stable or lazer"); } - private static Long lazerDuelWinner(List scores, Set duelUsers) { - Map values = new HashMap<>(); - for (MultiplayerRoomScore roomScore : scores) { - Score score = roomScore.score(); - Long userId = scoreUserId(score); - if (userId != null && score.getTotalScore() != null) { - values.put(userId, score.getTotalScore()); - } + public void getCurrentRoom(@NotNull Context context) { + final String auth = context.header(Headers.OSU_AUTHORIZATION); + + if (auth == null) { + throw new ApiException(ErrorCode.UNAUTHORIZED); } - if (!values.keySet().equals(duelUsers)) return null; - List> ordered = values.entrySet().stream() - .sorted(Map.Entry.comparingByValue().reversed()) - .toList(); - return ordered.get(0).getValue().equals(ordered.get(1).getValue()) - ? null : ordered.get(0).getKey(); + + context.future(() -> executor + .enqueueAsync(() -> OsuAPI.getCurrentRoom(auth)) + .thenApply(room -> { + if (room == null) { + throw new ApiException(ErrorCode.NO_ROOM_FOUND, "User is not in a room!"); + } + return room; + }) + .thenAccept(room -> context.status(200).result(new Response(true, "Success", GSON.toJsonTree(room)).toString())) + ); } - private static MultiplayerResultData.SeriesScore stableSeriesScore( - MultiplayerMatchDetails match, - MultiplayerMatchDetails.MatchGame currentGame - ) { - boolean teamMode = isTeamMode(currentGame.getTeamType()); - Set duelUsers = teamMode ? Set.of() : stableUserIds(currentGame.getScores()); - if (!teamMode && duelUsers.size() != 2) { - return MultiplayerResultData.SeriesScore.empty(); + public void getCurrentRoomItem(@NotNull Context context) { + final String auth = context.header(Headers.OSU_AUTHORIZATION); + + if (auth == null) { + throw new ApiException(ErrorCode.UNAUTHORIZED); } - Map playerWins = new HashMap<>(); - int redWins = 0; - int blueWins = 0; - for (MultiplayerMatchDetails.MatchGame game : completedGamesThrough(match, currentGame)) { - if (teamMode) { - if (!isTeamMode(game.getTeamType())) continue; - String winner = stableTeamWinner(game); - if ("red".equals(winner)) redWins++; - if ("blue".equals(winner)) blueWins++; - } else { - if (isTeamMode(game.getTeamType())) continue; - Long winner = stableDuelWinner(game, duelUsers); - if (winner != null) playerWins.merge(winner, 1, Integer::sum); + context.future(() -> executor + .enqueueAsync(() -> OsuAPI.getCurrentRoom(auth)) + .thenApply(room -> { + if (room == null) { + throw new ApiException(ErrorCode.NO_ROOM_FOUND, "User is not in a room!"); + } + final var currentPlaylistItem = room.getCurrentPlaylistItem(); + if (currentPlaylistItem == null) { + throw new ApiException(ErrorCode.NO_BEATMAPSET_FOUND, "Room has no current playlist item!"); + } + return currentPlaylistItem; + }) + .thenApply((Room.PlaylistItem c) -> { + final BeatmapExtended beatmap = c.getBeatmap(); + if (beatmap == null) { + throw new ApiException(ErrorCode.NO_BEATMAP_FOUND, "Beatmap is null!"); + } + JsonObject res = new JsonObject(); + res.addProperty("beatmap_id", beatmap.getId()); + res.addProperty("beatmapset_id", beatmap.getBeatmapsetId()); + return res; + }) + .thenAccept(obj -> context.status(200).result(new Response(true, "Success", obj).toString())) + ); + } + + public void getRoomWatchState(@NotNull Context context) { + long roomId = positivePathId(context, "roomId"); + RoomVersion version = roomVersion(context); + context.future(() -> executor + .enqueueAsync(() -> switch (version) { + case LAZER -> toWatchState(OsuAPI.getRoom(tokenManager.getTokenData(), roomId)); + case STABLE -> toWatchState(OsuAPI.getMatch(tokenManager.getTokenData(), roomId)); + }) + .thenAccept(state -> context.status(200) + .contentType("application/json") + .result(new Response(true, "Success", GSON.toJsonTree(state)).toString())) + ); + } + + public void getRoomResult(@NotNull Context context) { + long roomId = positivePathId(context, "roomId"); + long playlistItemId = positivePathId(context, "playlistItemId"); + final String obj = context.queryParam("bo"); + + final Integer customBo; + if (obj != null) { + try { + customBo = Integer.parseInt(obj); + } catch (IllegalArgumentException | NullPointerException e) { + throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "Invalid parameter: bo"); } + } else { + customBo = null; } - return new MultiplayerResultData.SeriesScore(playerWins, redWins, blueWins); + + RoomVersion version = roomVersion(context); + context.future(() -> executor + .enqueueAsync(() -> switch (version) { + case LAZER -> getLazerResultData(roomId, playlistItemId, customBo); + case STABLE -> getStableResultData(roomId, playlistItemId, customBo); + }) + .thenCompose(data -> ImageResponse.respond(context, data, renderer::renderMultiplayerResult, renderer.getRenderExecutor())) + ); + } + + private MultiplayerResultData getLazerResultData(long roomId, long playlistItemId, Integer customBo) { + Room room = OsuAPI.getRoom(tokenManager.getTokenData(), roomId); + Room.PlaylistItem item = findPlaylistItem(room, playlistItemId); + enrichPlaylistItem(item); + + List roomScores = OsuAPI.getRoomPlaylistScores( + tokenManager.getTokenData(), roomId, playlistItemId + ); + List eventItems = isTeamMode(room.getType()) + ? OsuAPI.getRoomEventPlaylistItems(tokenManager.getTokenData(), roomId) + : List.of(); + enrichLazerTeamSnapshot(room, item, roomScores, eventItems); + MultiplayerResultData.SeriesScore seriesScore = lazerSeriesScore( + room, item, roomScores, eventItems); + enrichScores(roomScores, item); + enrichDuelProfiles(roomScores); + User owner = resolveOwner(room, item.getOwnerId()); + return MultiplayerResultFactory.create( + room, + item, + roomScores, + owner, + "lazer", + "scorev2", + room.getType(), + seriesScore, + customBo + ); } - private static List completedGamesThrough( - MultiplayerMatchDetails match, - MultiplayerMatchDetails.MatchGame currentGame + private void enrichLazerTeamSnapshot( + Room room, + Room.PlaylistItem item, + List roomScores, + List eventItems ) { - Map games = new LinkedHashMap<>(); - if (match.getEvents() != null) { - match.getEvents().stream() - .filter(Objects::nonNull) - .map(MultiplayerMatchDetails.MatchEvent::getGame) - .filter(Objects::nonNull) - .forEach(game -> games.putIfAbsent(game.getId(), game)); + String roomType = room.getType(); + boolean teamVs = roomType != null + && roomType.toLowerCase(Locale.ROOT).replace('-', '_').contains("team"); + boolean missingTeam = roomScores.stream().anyMatch(roomScore -> + roomScore.team() == null || roomScore.team().isBlank()); + if (!teamVs || !missingTeam) { + return; } - games.putIfAbsent(currentGame.getId(), currentGame); - List completed = new ArrayList<>(); - for (MultiplayerMatchDetails.MatchGame game : games.values()) { - if (game.getId() == currentGame.getId() - || game.getEndTime() != null && !game.getEndTime().isBlank()) { - completed.add(game); - } - if (game.getId() == currentGame.getId()) break; + Room.PlaylistItem eventItem = eventItems.stream() + .filter(value -> value.getId() == item.getId()) + .findFirst() + .orElse(null); + if (eventItem == null || eventItem.getDetails() == null) { + LOG.warn("Room events contain no details for playlist item {} in room {}", item.getId(), room.getId()); + return; } - return List.copyOf(completed); + item.setDetails(eventItem.getDetails()); } - private static String stableTeamWinner(MultiplayerMatchDetails.MatchGame game) { - double red = 0; - double blue = 0; - boolean hasRed = false; - boolean hasBlue = false; - for (JsonObject score : nullSafeScores(game.getScores())) { - Double value = stableScoringValue(score, game.getScoringType()); - String team = normalizedTeam(scoreTeam(score)); - if (value == null) continue; - if ("red".equals(team)) { - red += value; - hasRed = true; - } else if ("blue".equals(team)) { - blue += value; - hasBlue = true; - } - } - if (!hasRed || !hasBlue || Double.compare(red, blue) == 0) return null; - return red > blue ? "red" : "blue"; + private MultiplayerResultData getStableResultData(long matchId, long gameId, Integer customBo) { + Match match = OsuAPI.getMatch(tokenManager.getTokenData(), matchId); + Match.MatchGame game = findMatchGame(match, gameId); + + Room room = new Room(); + room.setId(match.getMatch().getId()); + room.setName(match.getMatch().getName()); + room.setActive(match.getMatch().getEndTime() == null || match.getMatch().getEndTime().isBlank()); + room.setRecentParticipants(match.getUsers()); + + Room.PlaylistItem item = new Room.PlaylistItem(); + item.setId(game.getId()); + item.setRoomId(matchId); + item.setBeatmapId(game.getBeatmapId()); + item.setPlayedAt(game.getEndTime()); + item.setBeatmap(game.getBeatmap()); + enrichPlaylistItem(item); + + List scores = stableScores(match, game, item); + MultiplayerResultData.SeriesScore seriesScore = stableSeriesScore(match, game); + enrichDuelProfiles(scores); + User stableLobby = new User(); + stableLobby.setUsername("Stable lobby"); + return MultiplayerResultFactory.create( + room, + item, + scores, + stableLobby, + "stable", + game.getScoringType(), + game.getTeamType(), + seriesScore, + customBo + ); } - private static Long stableDuelWinner( - MultiplayerMatchDetails.MatchGame game, - Set duelUsers + private List stableScores( + Match match, + Match.MatchGame game, + Room.PlaylistItem item ) { - Map values = new HashMap<>(); - for (JsonObject score : nullSafeScores(game.getScores())) { - Long userId = stableUserId(score); - Double value = stableScoringValue(score, game.getScoringType()); - if (userId != null && value != null) values.put(userId, value); + Map users = new HashMap<>(); + if (match.getUsers() != null) { + match.getUsers().stream().filter(Objects::nonNull).forEach(user -> users.put(user.getId(), user)); } - if (!values.keySet().equals(duelUsers)) return null; - List> ordered = values.entrySet().stream() - .sorted(Map.Entry.comparingByValue().reversed()) + + Comparator scoreComparator = stableScoreComparator(game.getScoringType()); + List scores = (game.getScores() == null + ? List.of() + : game.getScores()).stream() + .filter(Objects::nonNull) + .map(value -> new MultiplayerRoomScore( + stableScore(value, game, item, users), + null, + scoreTeam(value) + )) + .sorted((left, right) -> scoreComparator.compare(left.score(), right.score())) .toList(); - return Double.compare(ordered.get(0).getValue(), ordered.get(1).getValue()) == 0 - ? null : ordered.get(0).getKey(); - } - private static Double stableScoringValue(JsonObject score, String scoringType) { - String normalized = scoringType == null ? "score" : scoringType.toLowerCase(Locale.ROOT); - if ("accuracy".equals(normalized)) return jsonNumber(score, "accuracy"); - if ("combo".equals(normalized)) return jsonNumber(score, "max_combo"); - return jsonNumber(score, "total_score", "legacy_total_score", "classic_total_score", "score"); + List result = new java.util.ArrayList<>(scores.size()); + for (int index = 0; index < scores.size(); index++) { + MultiplayerRoomScore roomScore = scores.get(index); + result.add(new MultiplayerRoomScore(roomScore.score(), index + 1, roomScore.team())); + } + return List.copyOf(result); } - private static Double jsonNumber(JsonObject object, String... names) { - for (String name : names) { - if (object.has(name) && !object.get(name).isJsonNull()) { - return object.get(name).getAsDouble(); + private Score stableScore( + MatchScore value, + Match.MatchGame game, + Room.PlaylistItem item, + Map users + ) { + JsonObject normalized = GSON.toJsonTree(value).getAsJsonObject(); + if (!normalized.has("total_score")) { + if (normalized.has("legacy_total_score")) { + normalized.add("total_score", normalized.get("legacy_total_score")); + } else if (normalized.has("classic_total_score")) { + normalized.add("total_score", normalized.get("classic_total_score")); + } else if (normalized.has("score")) { + normalized.add("total_score", normalized.get("score")); } } - return null; - } - - private static Set scoreUserIds(List scores) { - Set ids = new LinkedHashSet<>(); - for (MultiplayerRoomScore roomScore : scores) { - Score score = roomScore.score(); - Long userId = scoreUserId(score); - if (userId != null) ids.add(userId); + if (!normalized.has("beatmap_id")) { + normalized.addProperty("beatmap_id", game.getBeatmapId()); } - return Set.copyOf(ids); - } - - private static Long scoreUserId(Score score) { - if (score == null) return null; - if (score.getUserId() != null && score.getUserId() > 0) return score.getUserId(); - return score.getUser() == null || score.getUser().getId() <= 0 ? null : score.getUser().getId(); - } - - private static Set stableUserIds(List scores) { - Set ids = new LinkedHashSet<>(); - for (JsonObject score : nullSafeScores(scores)) { - Long userId = stableUserId(score); - if (userId != null) ids.add(userId); + if (!normalized.has("ended_at") && game.getEndTime() != null) { + normalized.addProperty("ended_at", game.getEndTime()); + } + if (!normalized.has("passed") && normalized.has("match") && normalized.get("match").isJsonObject()) { + JsonObject scoreMatch = normalized.getAsJsonObject("match"); + if (scoreMatch.has("pass")) { + normalized.add("passed", scoreMatch.get("pass")); + } } - return Set.copyOf(ids); - } - private static Long stableUserId(JsonObject score) { - if (score.has("user_id") && !score.get("user_id").isJsonNull()) { - return score.get("user_id").getAsLong(); + Score score = GSON.fromJson(normalized, Score.class); + score.setBeatmap(item.getBeatmap()); + if (item.getBeatmap() != null) { + score.setBeatmapset(item.getBeatmap().getBeatmapset()); + } + if (score.getUserId() != null && score.getUserId() > 0) { + User user = users.computeIfAbsent( + score.getUserId(), + id -> OsuAPI.getUser(tokenManager.getTokenData(), id) + ); + score.setUser(user); } - if (score.has("user") && score.get("user").isJsonObject()) { - JsonObject user = score.getAsJsonObject("user"); - if (user.has("id") && !user.get("id").isJsonNull()) return user.get("id").getAsLong(); + if (score.getPassed() == null) { + score.setPassed(true); } - return null; + if (score.getRank() == null || score.getRank().isBlank()) { + score.setRank(Boolean.TRUE.equals(score.getPassed()) ? "-" : "F"); + } + if (score.getBeatmap() != null) { + try { + router.ensurePp(score); + } catch (RuntimeException e) { + LOG.warn("Failed to estimate pp for stable multiplayer score {}", score.getId(), e); + } + } + return score; } - private static List nullSafeScores(List scores) { - return scores == null ? List.of() : scores.stream().filter(Objects::nonNull).toList(); - } + private MultiplayerResultData.SeriesScore lazerSeriesScore( + Room room, + Room.PlaylistItem currentItem, + List currentScores, + List eventItems + ) { + boolean teamMode = isTeamMode(room.getType()); + Set duelUsers = teamMode ? Set.of() : scoreUserIds(currentScores); + if (!teamMode && duelUsers.size() != 2) { + return MultiplayerResultData.SeriesScore.empty(); + } - private static boolean isTeamMode(String teamType) { - if (teamType == null) return false; - String normalized = teamType.toLowerCase(Locale.ROOT).replace('_', '-'); - return normalized.contains("team") && !normalized.contains("head"); - } + Map eventsById = new HashMap<>(); + eventItems.forEach(item -> eventsById.put(item.getId(), item)); + Map playerWins = new HashMap<>(); + int redWins = 0; + int blueWins = 0; - private static String normalizedTeam(String team) { - if (team == null) return null; - return switch (team.toLowerCase(Locale.ROOT)) { - case "red", "1" -> "red"; - case "blue", "2" -> "blue"; - default -> null; - }; - } + for (Room.PlaylistItem item : completedItemsThrough(room, currentItem)) { + List scores; + if (item.getId() == currentItem.getId()) { + scores = currentScores; + } else { + try { + scores = OsuAPI.getRoomPlaylistScores( + tokenManager.getTokenData(), room.getId(), item.getId()); + } catch (ApiException e) { + LOG.warn("Failed to include playlist item {} in room {} series score", + item.getId(), room.getId(), e); + continue; + } + } - private static String firstNonBlank(String... values) { - for (String value : values) { - if (value != null && !value.isBlank()) return value; + if (teamMode) { + String winner = lazerTeamWinner(scores, item, eventsById.get(item.getId())); + if ("red".equals(winner)) redWins++; + if ("blue".equals(winner)) blueWins++; + } else { + Long winner = lazerDuelWinner(scores, duelUsers); + if (winner != null) playerWins.merge(winner, 1, Integer::sum); + } } - return null; + return new MultiplayerResultData.SeriesScore(playerWins, redWins, blueWins); } - private void enrichPlaylistItem(MultiplayerRoomDetails.PlaylistItem item) { + private void enrichPlaylistItem(Room.PlaylistItem item) { if (item.getBeatmap() == null || item.getBeatmap().getBeatmapset() == null) { BeatmapExtended beatmap = OsuAPI.getBeatmap(tokenManager.getTokenData(), item.getBeatmapId()); if (beatmap != null) { @@ -649,7 +721,7 @@ private void enrichPlaylistItem(MultiplayerRoomDetails.PlaylistItem item) { private void enrichScores( List roomScores, - MultiplayerRoomDetails.PlaylistItem item + Room.PlaylistItem item ) { Map beatmaps = new HashMap<>(); if (item.getBeatmap() != null) { @@ -696,7 +768,7 @@ private void enrichScores( } } - private User resolveOwner(MultiplayerRoomDetails room, long ownerId) { + private User resolveOwner(Room room, long ownerId) { if (room.getRecentParticipants() != null) { User participant = room.getRecentParticipants().stream() .filter(Objects::nonNull) @@ -735,113 +807,6 @@ private void enrichDuelProfiles(List roomScores) { } } - static MultiplayerRoomWatchState toWatchState(MultiplayerRoomDetails room) { - Map items = new LinkedHashMap<>(); - if (room.getPlaylist() != null) { - room.getPlaylist().stream() - .filter(Objects::nonNull) - .forEach(item -> items.put(item.getId(), item)); - } - if (room.getCurrentPlaylistItem() != null) { - items.putIfAbsent(room.getCurrentPlaylistItem().getId(), room.getCurrentPlaylistItem()); - } - - List completed = items.values().stream() - .filter(item -> item.getId() > 0 && item.getPlayedAt() != null && !item.getPlayedAt().isBlank()) - .sorted(Comparator - .comparing(MultiplayerRoomDetails.PlaylistItem::getPlayedAt) - .thenComparingLong(MultiplayerRoomDetails.PlaylistItem::getId)) - .map(item -> new MultiplayerRoomWatchState.CompletedPlay(item.getId(), item.getPlayedAt())) - .toList(); - boolean active = room.isActive() - && (room.getStatus() == null || !room.getStatus().equalsIgnoreCase("ended")); - return new MultiplayerRoomWatchState(room.getId(), room.getName(), active, completed); - } - - static MultiplayerRoomWatchState toWatchState(MultiplayerMatchDetails match) { - Map games = new LinkedHashMap<>(); - if (match.getEvents() != null) { - match.getEvents().stream() - .filter(Objects::nonNull) - .map(MultiplayerMatchDetails.MatchEvent::getGame) - .filter(Objects::nonNull) - .forEach(game -> games.put(game.getId(), game)); - } - List completed = games.values().stream() - .filter(game -> game.getId() > 0 && game.getEndTime() != null && !game.getEndTime().isBlank()) - .sorted(Comparator - .comparing(MultiplayerMatchDetails.MatchGame::getEndTime) - .thenComparingLong(MultiplayerMatchDetails.MatchGame::getId)) - .map(game -> new MultiplayerRoomWatchState.CompletedPlay(game.getId(), game.getEndTime())) - .toList(); - MultiplayerMatchDetails.MatchInfo info = match.getMatch(); - boolean active = info.getEndTime() == null || info.getEndTime().isBlank(); - return new MultiplayerRoomWatchState(info.getId(), info.getName(), active, completed); - } - - private static MultiplayerMatchDetails.MatchGame findMatchGame( - MultiplayerMatchDetails match, - long gameId - ) { - if (match.getEvents() != null) { - MultiplayerMatchDetails.MatchGame game = match.getEvents().stream() - .filter(Objects::nonNull) - .map(MultiplayerMatchDetails.MatchEvent::getGame) - .filter(Objects::nonNull) - .filter(value -> value.getId() == gameId) - .findFirst() - .orElse(null); - if (game != null) { - return game; - } - } - throw new ApiException(ErrorCode.NO_BEATMAP_FOUND, "Game was not found in match"); - } - - private static MultiplayerRoomDetails.PlaylistItem findPlaylistItem( - MultiplayerRoomDetails room, - long playlistItemId - ) { - if (room.getPlaylist() != null) { - MultiplayerRoomDetails.PlaylistItem item = room.getPlaylist().stream() - .filter(Objects::nonNull) - .filter(value -> value.getId() == playlistItemId) - .findFirst() - .orElse(null); - if (item != null) { - return item; - } - } - if (room.getCurrentPlaylistItem() != null - && room.getCurrentPlaylistItem().getId() == playlistItemId) { - return room.getCurrentPlaylistItem(); - } - throw new ApiException(ErrorCode.NO_BEATMAP_FOUND, "Playlist item was not found in room"); - } - - private static long positivePathId(Context context, String name) { - String value = context.pathParam(name); - try { - long id = Long.parseLong(value); - if (id > 0) { - return id; - } - } catch (NumberFormatException ignored) { - } - throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, name + " must be a positive integer"); - } - - private static RoomVersion roomVersion(Context context) { - String value = context.queryParam("version"); - if (value == null || value.isBlank() || value.equalsIgnoreCase("lazer")) { - return RoomVersion.LAZER; - } - if (value.equalsIgnoreCase("stable")) { - return RoomVersion.STABLE; - } - throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "version must be stable or lazer"); - } - private enum RoomVersion { LAZER, STABLE diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java b/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java index 8cd9dbb..78ab1ec 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java @@ -14,11 +14,7 @@ import xyz.zcraft.ostella.network.OsuAPI; import xyz.zcraft.ostella.network.Response; import xyz.zcraft.ostella.network.Router; -import xyz.zcraft.ostella.service.AsyncService; -import xyz.zcraft.ostella.service.BeatmapPreviewService; -import xyz.zcraft.ostella.service.CacheService; -import xyz.zcraft.ostella.service.LocalScoreService; -import xyz.zcraft.ostella.service.ReplayService; +import xyz.zcraft.ostella.service.*; import xyz.zcraft.ostella.util.TokenManager; import xyz.zcraft.osu.model.BeatmapExtended; import xyz.zcraft.osu.model.Score; @@ -60,6 +56,16 @@ public ReplayController(Router router) { this.localScoreService = new LocalScoreService(tokenManager); } + static JsonObject terminalStatusData(String jobId, String status, String error) { + JsonObject obj = new JsonObject(); + obj.addProperty("status", status); + obj.addProperty("id", jobId); + if (error != null && !error.isBlank()) { + obj.addProperty("error", error); + } + return obj; + } + public void getReplayRenderStatus(@NotNull Context context) { String jobId = context.pathParam("jobId"); ReplayService.JobProgress jobProgress = replayService.getJobProgress(jobId); @@ -165,7 +171,7 @@ public void cancelReplayRender(@NotNull Context context) { } private CompletionStage finalizeReplay(@NotNull Context context, Score score, - ReplayService.QqUploadRequest qqUpload) { + ReplayService.QqUploadRequest qqUpload) { final double start = optionalDouble(context, "start"); final double end = optionalDouble(context, "end"); final boolean obscured = optionalBoolean(context, "obscured", false); @@ -254,24 +260,17 @@ private void respondTerminalStatus(Context context, String jobId, String status, context.status(200).result(new Response(true, message, obj).toString()); } - static JsonObject terminalStatusData(String jobId, String status, String error) { - JsonObject obj = new JsonObject(); - obj.addProperty("status", status); - obj.addProperty("id", jobId); - if (error != null && !error.isBlank()) { - obj.addProperty("error", error); - } - return obj; - } - public void renderBeatmapPreview(@NotNull Context context) { if (replayService == null) return; final long beatmapId = requirePathLong(context, "beatmapId"); + final double start = optionalDouble(context, "start"); + final double end = optionalDouble(context, "end"); + final PreviewRequest request; final String mods; try { - request = context.body() == null || context.body().isBlank() + request = context.body().isBlank() ? new PreviewRequest(null, null) : GSON.fromJson(context.body(), PreviewRequest.class); mods = BeatmapPreviewService.normalizeMods(request == null ? null : request.mods()); @@ -297,16 +296,15 @@ public void renderBeatmapPreview(@NotNull Context context) { try { OsuBeatmap parsed = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmapId)); - BeatmapPreviewService.PreviewSegment segment = - BeatmapPreviewService.selectSegment(parsed, mods); + BeatmapPreviewService.PreviewSegment segment; + if (Double.isNaN(start) || Double.isNaN(end)) { + segment = BeatmapPreviewService.selectSegment(parsed, mods); + } else { + segment = new BeatmapPreviewService.PreviewSegment(start, end, null); + } Path beatmapset = CacheService.getBeatmapsetArchivePath(beatmapsetId); ReplayService.QueuedJob queued = replayService.queueRenderPreview( - beatmapId, - beatmapsetId, - beatmapset, - segment.start(), - segment.end(), - mods, + beatmapId, beatmapsetId, beatmapset, segment.start(), segment.end(), mods, request == null ? null : request.qqUpload() ); @@ -361,8 +359,8 @@ private CompletableFuture finalizeShowcase(@NotNull Context context, } private CompletableFuture renderScoreForAsync(@NotNull Context context, Score score, Double start, - Double end, boolean obscured, - ReplayService.QqUploadRequest qqUpload) { + Double end, boolean obscured, + ReplayService.QqUploadRequest qqUpload) { if (replayService == null) return CompletableFuture.completedFuture(null); if (!CacheService.hasReplayCache(score.getId()) && !score.getHasReplay()) { @@ -405,7 +403,7 @@ private CompletableFuture renderScoreForAsync(@NotNull Context context, Sc } private CompletableFuture renderShowcaseForAsync(@NotNull Context context, LinkedList scores, - ReplayService.QqUploadRequest qqUpload) { + ReplayService.QqUploadRequest qqUpload) { if (replayService == null) return CompletableFuture.completedFuture(null); if (scores.isEmpty()) { @@ -559,11 +557,8 @@ private UploadedReplay storeUploadedReplay(OsuReplay replay, byte[] bytes) { return new UploadedReplay(local.id(), local.score()); } - private record UploadedReplay(String id, Score score) { - } - private ReplayService.QqUploadRequest parseQqUpload(Context context) { - if (context.body() == null || context.body().isBlank()) { + if (context.body().isBlank()) { return null; } try { @@ -577,6 +572,9 @@ private ReplayService.QqUploadRequest parseQqUpload(Context context) { } } + private record UploadedReplay(String id, Score score) { + } + public record ShowcaseRequest(List ids, ReplayService.QqUploadRequest qqUpload) { } diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java index 68886e7..837db4b 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -9,6 +9,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; +import xyz.zcraft.ostella.network.ImageResponse; import xyz.zcraft.ostella.data.ScoreFilter; import xyz.zcraft.ostella.data.ScoreId; import xyz.zcraft.ostella.data.ScoreType; @@ -24,10 +25,7 @@ import xyz.zcraft.ostella.util.TokenManager; import xyz.zcraft.ostella.util.WeightedRandom; import xyz.zcraft.ostella.util.format.ScoreFormatUtil; -import xyz.zcraft.osu.model.BeatmapExtended; -import xyz.zcraft.osu.model.Mod; -import xyz.zcraft.osu.model.Score; -import xyz.zcraft.osu.model.UserExtended; +import xyz.zcraft.osu.model.*; import xyz.zcraft.osu.parser.BeatmapAnalyzer; import xyz.zcraft.osu.parser.BeatmapParser; import xyz.zcraft.osu.parser.BeatmapPatternAnalyzer; @@ -43,6 +41,7 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +import static xyz.zcraft.ostella.service.CacheService.tryCache; import static xyz.zcraft.ostella.util.RequestUtil.*; public class ScoreController { @@ -76,12 +75,17 @@ private static List requireScoreFilters(Context context) { } } - static List applyFilters(List scores, List filters) { + private List applyFilters(List scores, List filters) { if (filters.isEmpty()) { return scores; } return scores.stream() - .filter(score -> filters.stream().allMatch(filter -> filter.matches(score))) + .filter(score -> filters.stream().allMatch(filter -> filter.matches(score, () -> { + final Long id = score.getBeatmapset().getId(); + final var beatmapset = executor.enqueueAsync(() -> OsuAPI.getBeatmapset(tokenManager.getTokenData(), id)).join(); + tryCache(beatmapset); + return beatmapset; + }))) .toList(); } @@ -135,6 +139,22 @@ public static long getModBits(List mods) { return bits; } + private static double getArFactor(double ar) { + if (ar >= 8.25) { + return 0.0; + } + + return 2.5 * (1.0 - Math.exp(-1.3 * (8.25 - ar))); + } + + private static double getCsFactor(double cs) { + if (cs <= 8.0) { + return 0.0; + } + + return Math.pow((cs - 8.0) / 4.0, 1.25); + } + public void lookupScore(@NotNull Context context) { if (context.queryParam("of") != null) { lookupScoreOfRefAsync(context); @@ -147,38 +167,40 @@ public void lookupScore(@NotNull Context context) { } } - public void renderScoreById(@NotNull Context context) { + public void getScoreById(@NotNull Context context) { final long scoreId = requirePathScoreId(context, "scoreId"); context.future(() -> router.getScore(scoreId) - .thenApplyAsync(score -> { + .thenApply(score -> { if (score == null) throw new ApiException(ErrorCode.NO_SCORE_FOUND); - final BeatmapExtended beatmap = score.getBeatmap(); - - context.header("X-Beatmap-Id", String.valueOf(beatmap.getId())) + context.header("X-Beatmap-Id", String.valueOf(score.getBeatmap().getId())) .header("X-Score-Id", ScoreId.format(score)); + return score; + }) + .thenCompose(score -> ImageResponse.respond(context, score, this::renderScore, renderer.getRenderExecutor()))); + } - try { - final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmap.getId())); - final DiffSpec diffSpec = OsuParser.getDiffSpecForMap(osuBeatmap, score.getMods().stream().map(Mod::getAcronym).reduce("", String::concat)); + private byte[] renderScore(Score score) { + final BeatmapExtended beatmap = score.getBeatmap(); + try { + final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmap.getId())); + final DiffSpec diffSpec = OsuParser.getDiffSpecForMap(osuBeatmap, score.getMods().stream().map(Mod::getAcronym).reduce("", String::concat)); - Double calPp = null; - try { - calPp = OsuParser.estimatePp(score, osuBeatmap); - } catch (AnalyzeException e) { - LOG.error("Failed to estimate pp for score id: {}", score.getId(), e); - } + Double calPp = null; + try { + calPp = OsuParser.estimatePp(score, osuBeatmap); + } catch (AnalyzeException e) { + LOG.error("Failed to estimate pp for score id: {}", score.getId(), e); + } - final boolean replayPresent = score.getHasReplay() || CacheService.hasReplayCache(score.getId()); + final boolean replayPresent = score.getHasReplay() || CacheService.hasReplayCache(score.getId()); - return renderer.renderScore(score, diffSpec, calPp, replayPresent); - } catch (ParseException e) { - throw new ApiException(ErrorCode.BEATMAP_PARSE_FAILED, e); - } catch (AnalyzeException e) { - throw new ApiException(ErrorCode.SCORE_PARSE_FAILED, e); - } - }, renderer.getRenderExecutor()) - .thenAccept(bytes -> context.status(200).result(bytes))); + return renderer.renderScore(score, diffSpec, calPp, replayPresent); + } catch (ParseException e) { + throw new ApiException(ErrorCode.BEATMAP_PARSE_FAILED, e); + } catch (AnalyzeException e) { + throw new ApiException(ErrorCode.SCORE_PARSE_FAILED, e); + } } private void lookupScoreOfIdAsync(@NotNull Context context) { @@ -623,22 +645,6 @@ private double getAttributeFactor(DifficultyAttribute difficultyAttribute) { return attributeFactor; } - private static double getArFactor(double ar) { - if (ar >= 8.25) { - return 0.0; - } - - return 2.5 * (1.0 - Math.exp(-1.3 * (8.25 - ar))); - } - - private static double getCsFactor(double cs) { - if (cs <= 8.0) { - return 0.0; - } - - return Math.pow((cs - 8.0) / 4.0, 1.25); - } - private record ModSet(Set acronyms) { public boolean has(Collection acronyms) { return this.acronyms.containsAll(acronyms); diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java b/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java index 01dd641..1df0d38 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java @@ -6,11 +6,13 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; +import xyz.zcraft.ostella.network.ImageResponse; import xyz.zcraft.ostella.data.ScoreFilter; import xyz.zcraft.ostella.data.ScoreType; import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.network.ErrorCode; import xyz.zcraft.ostella.network.OsuAPI; +import xyz.zcraft.ostella.network.Headers; import xyz.zcraft.ostella.network.Response; import xyz.zcraft.ostella.network.Router; import xyz.zcraft.ostella.service.AsyncService; @@ -27,11 +29,11 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; -import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.stream.Collectors; +import static xyz.zcraft.ostella.service.CacheService.tryCache; import static xyz.zcraft.ostella.util.RequestUtil.*; public class UserController { @@ -71,26 +73,6 @@ private static int requireScoreListLimit(Context context) { return limit; } - static FilteredScores applyFilters(List scores, List filters) { - return applyFilters(scores, filters, 1); - } - - static FilteredScores applyFilters(List scores, List filters, int firstPosition) { - List result = new ArrayList<>(); - List originalPositions = new ArrayList<>(); - for (int index = 0; index < scores.size(); index++) { - Score score = scores.get(index); - if (filters.stream().allMatch(filter -> filter.matches(score))) { - result.add(score); - originalPositions.add(index + firstPosition); - } - } - if (!filters.isEmpty() && result.isEmpty()) { - throw new ApiException(ErrorCode.NO_SCORE_FOUND, "No scores matched the filters"); - } - return new FilteredScores(List.copyOf(result), List.copyOf(originalPositions)); - } - private static int requireScoreListStart(Context context, int endPosition) { String startParam = context.queryParam("start"); int start = startParam == null ? 1 : requirePositiveInt(context, "start"); @@ -121,6 +103,31 @@ private static List filterLabels(List filters) { return filters.stream().map(ScoreFilter::displayText).toList(); } + private FilteredScores applyFilters(List scores, List filters) { + return applyFilters(scores, filters, 1); + } + + private FilteredScores applyFilters(List scores, List filters, int firstPosition) { + List result = new ArrayList<>(); + List originalPositions = new ArrayList<>(); + for (int index = 0; index < scores.size(); index++) { + Score score = scores.get(index); + if (filters.stream().allMatch(filter -> filter.matches(score, () -> { + final Long id = score.getBeatmapset().getId(); + final var beatmapset = executor.enqueueAsync(() -> OsuAPI.getBeatmapset(tokenManager.getTokenData(), id)).join(); + tryCache(beatmapset); + return beatmapset; + }))) { + result.add(score); + originalPositions.add(index + firstPosition); + } + } + if (!filters.isEmpty() && result.isEmpty()) { + throw new ApiException(ErrorCode.NO_SCORE_FOUND, "No scores matched the filters"); + } + return new FilteredScores(List.copyOf(result), List.copyOf(originalPositions)); + } + public void getUsers(@NotNull Context context) { final JsonElement body = JsonParser.parseString(context.body()); final var uidArr = body.getAsJsonObject().getAsJsonArray("ids"); @@ -170,11 +177,11 @@ public void getUsers(@NotNull Context context) { } public void getSelf(@NotNull Context context) { - final String auth = context.header("Authorization"); + final String auth = context.header(Headers.OSU_AUTHORIZATION); if (auth == null) { context.status(401) - .result(Response.error("Missing Authorization header", ErrorCode.UNAUTHORIZED).toString()); + .result(Response.error("Missing X-Osu-Authorization header", ErrorCode.UNAUTHORIZED).toString()); return; } @@ -202,7 +209,7 @@ public void getRecentScores(@NotNull Context context) { tokenManager.getTokenData(), u, type, n - start + 1, start - 1) ) .thenCompose(scores -> executor.enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), u)) - .thenApplyAsync(user -> { + .thenApply(user -> { if (user == null) { throw new ApiException(ErrorCode.NO_USER_FOUND, "No user found"); } @@ -211,18 +218,9 @@ public void getRecentScores(@NotNull Context context) { } FilteredScores filteredScores = applyFilters(scores, filters, start); - context.header("X-User-Id", String.valueOf(user.getId())); - context.header("X-Score-Ids", filteredScores.scores().stream().map(Score::getId).map(String::valueOf).collect(Collectors.joining(","))); - - return renderer.renderScores( - user, - filteredScores.scores(), - type, - filterLabels(filters), - filteredScores.originalPositions() - ); - }, renderer.getRenderExecutor())) - .thenAccept(bytes -> context.status(200).result(bytes))); + return prepareScoreList(context, user, filteredScores, type, filterLabels(filters), null); + })) + .thenCompose(data -> ImageResponse.respond(context, data, this::renderScoreList, renderer.getRenderExecutor()))); } public void getRecentScoresBatch(@NotNull Context context) { @@ -278,32 +276,23 @@ public void getBestOfN(@NotNull Context context) { .thenCompose(scores -> { if (scores == null || scores.isEmpty()) throw new ApiException(ErrorCode.NO_SCORE_FOUND); return executor.enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), u)) - .thenApplyAsync(user -> { + .thenApply(user -> { if (user == null) throw new ApiException(ErrorCode.NO_USER_FOUND); for (Score score : scores) { router.ensurePp(score); } FilteredScores filteredScores = applyFilters(scores, filters, start); - context.header("X-User-Id", String.valueOf(user.getId())); - context.header("X-Score-Ids", filteredScores.scores().stream().map(Score::getId).map(String::valueOf).collect(Collectors.joining(","))); - return renderer.renderScores( - user, - filteredScores.scores(), - ScoreType.BEST, - filterLabels(filters), - filteredScores.originalPositions() - ); - }, renderer.getRenderExecutor()); + return prepareScoreList(context, user, filteredScores, ScoreType.BEST, filterLabels(filters), null); + }); }) - .thenAccept(bytes -> context.status(200).result(bytes))); + .thenCompose(data -> ImageResponse.respond(context, data, this::renderScoreList, renderer.getRenderExecutor()))); } public void getUserInfo(@NotNull Context context) { final long userId = requirePathLong(context, "userId"); - final String accept = context.header("Accept"); - if (accept != null && accept.toLowerCase().contains("application/json")) { - handleUserInfoRaw(context); + if (ImageResponse.wantsJson(context)) { + getUserInfoJson(context, userId); return; } @@ -339,14 +328,16 @@ public void getUserInfo(@NotNull Context context) { ); return renderer.renderUserInfo(data.user(), data.topScores()); }, renderer.getRenderExecutor()) - .thenAccept(bytes -> context.status(200).result(bytes))); + .thenAccept(bytes -> context.status(200).contentType("image/png").result(bytes))); } - private void handleUserInfoRaw(@NotNull Context context) { - final long userId = requirePathLong(context, "userId"); - + private void getUserInfoJson(@NotNull Context context, long userId) { context.future(() -> executor.enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), userId)) - .thenAccept(user -> putResult(context, user))); + .thenAccept(user -> { + if (user == null) throw new ApiException(ErrorCode.NO_USER_FOUND); + context.header("X-User-Id", String.valueOf(user.getId())); + putResult(context, user); + })); } public void getUserRank(@NotNull Context context) { @@ -383,38 +374,24 @@ public void getTodayBestScores(@NotNull Context context) { throw new ApiException(ErrorCode.NO_SCORE_FOUND, "No best scores found in the last " + days + " days"); } return executor.enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), userId)) - .thenApplyAsync(user -> { + .thenApply(user -> { if (user == null) throw new ApiException(ErrorCode.NO_USER_FOUND); for (Score score : recentBestScores.scores()) { router.ensurePp(score); } - context.header("X-User-Id", String.valueOf(user.getId())); - context.header( - "X-Score-Ids", - recentBestScores.scores().stream() - .map(Score::getId) - .map(String::valueOf) - .collect(Collectors.joining(",")) - ); - return renderer.renderScores( - user, - recentBestScores.scores(), - ScoreType.BEST, - List.of(), - recentBestScores.originalPositions(), - "Best Scores Achieved in the Last " + days + (days == 1 ? " Day" : " Days") - ); - }, renderer.getRenderExecutor()); + return prepareScoreList(context, user, recentBestScores, ScoreType.BEST, List.of(), + "Best Scores Achieved in the Last " + days + (days == 1 ? " Day" : " Days")); + }); }) - .thenAccept(bytes -> context.status(200).result(bytes))); + .thenCompose(data -> ImageResponse.respond(context, data, this::renderScoreList, renderer.getRenderExecutor()))); } public void getFriends(@NotNull Context context) { - final String auth = context.header("Authorization"); + final String auth = context.header(Headers.OSU_AUTHORIZATION); if (auth == null) { context.status(401) - .result(Response.error("Missing Authorization header", ErrorCode.UNAUTHORIZED).toString()); + .result(Response.error("Missing X-Osu-Authorization header", ErrorCode.UNAUTHORIZED).toString()); return; } @@ -455,6 +432,24 @@ public void lookupUser(@NotNull Context context) { ); } + private ScoreListData prepareScoreList(Context context, UserExtended user, FilteredScores scores, + ScoreType type, List filters, String title) { + context.header("X-User-Id", String.valueOf(user.getId())); + context.header("X-Score-Ids", scores.scores().stream().map(Score::getId) + .map(String::valueOf).collect(Collectors.joining(","))); + return new ScoreListData(user, scores.scores(), type, filters, scores.originalPositions(), title); + } + + private byte[] renderScoreList(ScoreListData data) { + return data.title() == null + ? renderer.renderScores(data.user(), data.scores(), data.type(), data.filters(), data.positions()) + : renderer.renderScores(data.user(), data.scores(), data.type(), data.filters(), data.positions(), data.title()); + } + + private record ScoreListData(UserExtended user, List scores, ScoreType type, + List filters, List positions, String title) { + } + record FilteredScores(List scores, List originalPositions) { } diff --git a/src/main/java/xyz/zcraft/ostella/runtime/OstellaApplication.java b/src/main/java/xyz/zcraft/ostella/runtime/OstellaApplication.java index bd7fe26..70b9293 100644 --- a/src/main/java/xyz/zcraft/ostella/runtime/OstellaApplication.java +++ b/src/main/java/xyz/zcraft/ostella/runtime/OstellaApplication.java @@ -2,9 +2,9 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import xyz.zcraft.ostella.config.AppConfig; import xyz.zcraft.ostella.cache.CacheControlRequest; import xyz.zcraft.ostella.cache.CacheControlResult; +import xyz.zcraft.ostella.config.AppConfig; import xyz.zcraft.ostella.console.JLineConsole; import xyz.zcraft.ostella.console.OstellaConsoleAccess; import xyz.zcraft.ostella.console.OstellaConsoleProcessor; @@ -94,6 +94,11 @@ public CacheControlResult controlCache(CacheControlRequest request) { return requireServer().controlCache(request); } + @Override + public void setAutoCache(xyz.zcraft.ostella.service.AutoCacheService.Type type, boolean enabled) { + requireServer().setAutoCache(type, enabled); + } + @Override public void requestStop() { stopSignal.countDown(); diff --git a/src/main/java/xyz/zcraft/ostella/service/AsyncService.java b/src/main/java/xyz/zcraft/ostella/service/AsyncService.java index 2a47db5..5d21df9 100644 --- a/src/main/java/xyz/zcraft/ostella/service/AsyncService.java +++ b/src/main/java/xyz/zcraft/ostella/service/AsyncService.java @@ -1,5 +1,7 @@ package xyz.zcraft.ostella.service; +import xyz.zcraft.ostella.network.ApiActivity; + import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -43,35 +45,49 @@ private CompletableFuture enqueueAsync( AtomicInteger active ) { submitted.incrementAndGet(); - return CompletableFuture.supplyAsync(() -> { - boolean acquired = false; + ApiActivity.begin(); + try { + return CompletableFuture.supplyAsync(() -> { + boolean acquired = false; - try { - if (concurrencyLimit != null) { - concurrencyLimit.acquire(); - acquired = true; - } - rateGate.acquire(); - active.incrementAndGet(); try { - T result = supplier.get(); - completed.incrementAndGet(); - return result; - } catch (RuntimeException e) { - failed.incrementAndGet(); - throw e; + if (concurrencyLimit != null) { + concurrencyLimit.acquire(); + acquired = true; + } + rateGate.acquire(); + active.incrementAndGet(); + try { + T result = supplier.get(); + completed.incrementAndGet(); + return result; + } catch (RuntimeException e) { + failed.incrementAndGet(); + throw e; + } finally { + active.decrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CompletionException(e); } finally { - active.decrementAndGet(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new CompletionException(e); - } finally { - if (acquired) { - concurrencyLimit.release(); + if (acquired) { + concurrencyLimit.release(); + } + ApiActivity.end(); } - } - }, targetExecutor); + }, targetExecutor).thenApply(value -> value); + } catch (RuntimeException e) { + ApiActivity.end(); + throw e; + } + } + + /** One prefetch step, only when foreground work and the shared rate gate are idle. */ + public boolean tryBackground(java.util.function.BooleanSupplier allowed, Runnable action) { + if (!ApiActivity.idle() || !allowed.getAsBoolean() || !requestRateGate.tryAcquire()) return false; + ApiActivity.runBackground(allowed, action); + return true; } public Status status() { @@ -91,6 +107,12 @@ private StrictRateGate(long intervalNanos) { this.intervalNanos = intervalNanos; } + private synchronized boolean tryAcquire() { + if (System.nanoTime() < nextRequestNanos) return false; + nextRequestNanos = System.nanoTime() + intervalNanos; + return true; + } + private synchronized void acquire() throws InterruptedException { long waitNanos; while ((waitNanos = nextRequestNanos - System.nanoTime()) > 0) { diff --git a/src/main/java/xyz/zcraft/ostella/service/AutoCacheService.java b/src/main/java/xyz/zcraft/ostella/service/AutoCacheService.java new file mode 100644 index 0000000..035b1cc --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/service/AutoCacheService.java @@ -0,0 +1,150 @@ +package xyz.zcraft.ostella.service; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import xyz.zcraft.ostella.data.ScoreType; +import xyz.zcraft.ostella.network.ApiActivity; +import xyz.zcraft.ostella.network.OsuAPI; +import xyz.zcraft.ostella.util.TokenManager; +import xyz.zcraft.osu.model.Score; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; + +/** Bounded, resumable prefetch: at most one page or file per idle tick. */ +public final class AutoCacheService implements AutoCloseable { + private static final Logger LOG = LogManager.getLogger(AutoCacheService.class); + public enum Type { + BEATMAPSET, BEATMAPSET_JSON, BEATMAP, BEATMAP_JSON; + + public static Type parse(String text) { + try { + return valueOf(text.toUpperCase(Locale.ROOT).replace('-', '_')); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Auto cache type must be beatmapset, beatmapset-json, beatmap, or beatmap-json."); + } + } + } + + interface Backend { + Set users(); + List best(long user, int offset); + void cache(Type type, long id); + } + + private record Target(Type type, long id) {} + private final Set enabled = ConcurrentHashMap.newKeySet(); + private final AtomicLong revision = new AtomicLong(); + private final AsyncService executor; + private final BooleanSupplier ready; + private final Backend backend; + private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor( + Thread.ofPlatform().daemon().name("auto-cache").factory()); + private final Deque users = new ArrayDeque<>(); + private final Deque targets = new ArrayDeque<>(); + private final Map refreshed = new HashMap<>(); + private long seenRevision = -1; + private long nextScan; + private int offset; + private volatile boolean closed; + + public AutoCacheService(AsyncService executor, TokenManager tokens) { + this(executor, tokens::isValid, new Backend() { + public Set users() { return CacheService.cachedScoreUsers(); } + public List best(long user, int offset) { + return OsuAPI.getUserScores(tokens.getTokenData(), user, ScoreType.BEST, 100, offset); + } + public void cache(Type type, long id) { + CacheService.prefetch(type, id, tokens.getTokenData()); + } + }); + } + + AutoCacheService(AsyncService executor, BooleanSupplier ready, Backend backend) { + this.executor = executor; + this.ready = ready; + this.backend = backend; + } + + public void start() { + worker.scheduleWithFixedDelay(this::tick, 1, 1, TimeUnit.SECONDS); + } + + public void setEnabled(Type type, boolean value) { + if (closed) throw new IllegalStateException("Auto cache is stopped"); + boolean changed = value ? enabled.add(type) : enabled.remove(type); + if (changed) revision.incrementAndGet(); + } + + void tick() { + long currentRevision = revision.get(); + BooleanSupplier allowed = () -> !closed && !enabled.isEmpty() && ready.getAsBoolean() + && revision.get() == currentRevision; + if (!allowed.getAsBoolean()) return; + try { + executor.tryBackground(allowed, () -> step(currentRevision)); + } catch (ApiActivity.Yield ignored) { + // Keep the current page/file queued; foreground work or a toggle interrupted admission. + } catch (Exception e) { + LOG.warn("Auto cache step failed; retrying on the next refresh", e); + if (!targets.isEmpty()) targets.removeFirst(); + else if (!users.isEmpty()) finishUser(); + } + } + + private void step(long currentRevision) { + if (seenRevision != currentRevision) { + users.clear(); + targets.clear(); + refreshed.clear(); + offset = 0; + nextScan = 0; + seenRevision = currentRevision; + } + if (!targets.isEmpty()) { + Target target = targets.getFirst(); + ApiActivity.checkBackground(); + if (enabled.contains(target.type())) backend.cache(target.type(), target.id()); + targets.removeFirst(); + return; + } + long now = System.currentTimeMillis(); + if (users.isEmpty() && now >= nextScan) { + nextScan = now + Duration.ofMinutes(5).toMillis(); + Set currentUsers = backend.users(); + refreshed.keySet().retainAll(currentUsers); + currentUsers.stream().filter(id -> now >= refreshed.getOrDefault(id, 0L)).forEach(users::addLast); + } + if (users.isEmpty()) return; + ApiActivity.checkBackground(); + List scores = backend.best(users.getFirst(), offset); + if (scores == null) throw new IllegalStateException("Missing best-score response"); + Set unique = new LinkedHashSet<>(); + for (Score score : scores) { + if (score.getBeatmap() == null) continue; + Long map = score.getBeatmap().getId(); + Long set = score.getBeatmap().getBeatmapsetId(); + if (score.getBeatmapset() != null) set = score.getBeatmapset().getId(); + for (Type type : enabled) { + Long id = type == Type.BEATMAP || type == Type.BEATMAP_JSON ? map : set; + if (id != null && id > 0) unique.add(new Target(type, id)); + } + } + targets.addAll(unique); + if (offset == 100 || scores.size() < 100) finishUser(); + else offset = 100; + } + + private void finishUser() { + refreshed.put(users.removeFirst(), System.currentTimeMillis() + Duration.ofHours(1).toMillis()); + offset = 0; + } + + @Override public void close() { + closed = true; + worker.shutdownNow(); + } +} diff --git a/src/main/java/xyz/zcraft/ostella/service/BeatmapPreviewService.java b/src/main/java/xyz/zcraft/ostella/service/BeatmapPreviewService.java index e4c2a10..054bda3 100644 --- a/src/main/java/xyz/zcraft/ostella/service/BeatmapPreviewService.java +++ b/src/main/java/xyz/zcraft/ostella/service/BeatmapPreviewService.java @@ -5,12 +5,7 @@ import xyz.zcraft.osu.parser.data.beatmap.WindowDifficulty; import xyz.zcraft.osu.parser.exception.AnalyzeException; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; +import java.util.*; public final class BeatmapPreviewService { static final double OUTPUT_DURATION_SECONDS = 30.0; diff --git a/src/main/java/xyz/zcraft/ostella/service/CacheService.java b/src/main/java/xyz/zcraft/ostella/service/CacheService.java index 3f9be9d..7530920 100644 --- a/src/main/java/xyz/zcraft/ostella/service/CacheService.java +++ b/src/main/java/xyz/zcraft/ostella/service/CacheService.java @@ -11,6 +11,8 @@ import xyz.zcraft.ostella.cache.CacheControlResult; import xyz.zcraft.ostella.data.TokenData; import xyz.zcraft.ostella.network.OsuAPI; +import xyz.zcraft.osu.model.BeatmapExtended; +import xyz.zcraft.osu.model.Beatmapset; import xyz.zcraft.osu.model.Score; import java.io.IOException; @@ -43,8 +45,8 @@ public class CacheService { private static final Path JSON_CACHE = CACHE_PATH.resolve("json"); private static final Path SCORE_JSON_CACHE = JSON_CACHE.resolve("score"); -// private static final Path BEATMAP_JSON_CACHE = JSON_CACHE.resolve("beatmap"); -// private static final Path BEATMAPSET_JSON_CACHE = JSON_CACHE.resolve("beatmapset"); + private static final Path BEATMAP_JSON_CACHE = JSON_CACHE.resolve("beatmap"); + private static final Path BEATMAPSET_JSON_CACHE = JSON_CACHE.resolve("beatmapset"); private static final Path BEATMAPSET_CACHE = CACHE_PATH.resolve("beatmapset"); @@ -61,8 +63,8 @@ public static void initialize(AsyncService asyncService) throws IOException { Files.createDirectories(REPLAY_CACHE); Files.createDirectories(BEATMAPSET_CACHE); Files.createDirectories(SCORE_JSON_CACHE); -// Files.createDirectories(BEATMAP_JSON_CACHE); -// Files.createDirectories(BEATMAPSET_JSON_CACHE); + Files.createDirectories(BEATMAP_JSON_CACHE); + Files.createDirectories(BEATMAPSET_JSON_CACHE); } private static String bytesToHex(byte[] bytes) { @@ -194,16 +196,31 @@ public static boolean cacheBeatmapsetFile(long id) { Path beatmapsetPath = BEATMAPSET_CACHE.resolve(id + ".osz"); - LOG.debug("Downloading beatmapset {} via Sayobot", id); - if (!downloadSayobot(id, beatmapsetPath)) { - LOG.warn("Switching to Nekoha"); - if (!downloadNekoha(id, beatmapsetPath)) { - LOG.error("Failed to download beatmapset {} via both Sayobot and Nekoha!", id); - return false; + try { + Path temporary = Files.createTempFile(BEATMAPSET_CACHE, "prefetch-" + id + "-", ".tmp"); + try { + LOG.debug("Downloading beatmapset {} via Sayobot", id); + if (!downloadSayobot(id, temporary)) { + LOG.warn("Switching to Nekoha"); + if (!downloadNekoha(id, temporary)) { + LOG.error("Failed to download beatmapset {} via both mirrors", id); + return false; + } + } + if (!Files.exists(beatmapsetPath)) { + try { + Files.move(temporary, beatmapsetPath); + } catch (FileAlreadyExistsException ignored) { + // A foreground request finished downloading the same archive. + } + } + return true; + } finally { + Files.deleteIfExists(temporary); } + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); } - - return true; } public static void extractBeatmapset(long id, OutputStream out) throws IOException { @@ -294,16 +311,21 @@ private static boolean downloadNekoha(long id, Path beatmapsetPath) { .GET() .build(); + xyz.zcraft.ostella.network.ApiActivity.checkBackground(); HttpResponse fileResponse = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); if (fileResponse.statusCode() == 200) { - Files.copy(fileResponse.body(), beatmapsetPath, StandardCopyOption.REPLACE_EXISTING); + try (InputStream body = fileResponse.body()) { + Files.copy(body, beatmapsetPath, StandardCopyOption.REPLACE_EXISTING); + } LOG.debug("Beatmapset {} cached via Nekoha", beatmapsetPath); return true; } else { + fileResponse.body().close(); LOG.error("Failed to download beatmapset! Nekoha responded with status code: {}", fileResponse.statusCode()); } } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); LOG.error("Failed to download beatmapset!", e); } return false; @@ -318,6 +340,7 @@ private static boolean downloadSayobot(long id, Path beatmapsetPath) { .GET() .build(); + xyz.zcraft.ostella.network.ApiActivity.checkBackground(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.discarding()); if (response.statusCode() == 301 || response.statusCode() == 302) { @@ -332,20 +355,26 @@ private static boolean downloadSayobot(long id, Path beatmapsetPath) { HttpRequest actualDownloadRequest = HttpRequest.newBuilder() .uri(URI.create(cleanLocation)) + .timeout(Duration.ofMinutes(3)) .GET() .build(); + xyz.zcraft.ostella.network.ApiActivity.checkBackground(); HttpResponse fileResponse = client.send(actualDownloadRequest, HttpResponse.BodyHandlers.ofInputStream()); if (fileResponse.statusCode() == 200) { - Files.copy(fileResponse.body(), beatmapsetPath, StandardCopyOption.REPLACE_EXISTING); + try (InputStream body = fileResponse.body()) { + Files.copy(body, beatmapsetPath, StandardCopyOption.REPLACE_EXISTING); + } LOG.debug("Beatmapset {} cached via Sayobot", beatmapsetPath); return true; } else { + fileResponse.body().close(); LOG.error("Failed to download beatmapset! Sayobot responded with status code: {}", fileResponse.statusCode()); } } } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); LOG.error("Failed to download beatmapset!", e); } return false; @@ -372,6 +401,82 @@ public static Optional getReplayCache(long id) { } } + public static void cacheScoreJson(Score score) throws IOException { + Files.writeString(SCORE_JSON_CACHE.resolve(score.getId() + ".json"), GSON.toJson(score)); + } + + public static Set cachedScoreUsers() { + return cachedScoreUsers(SCORE_JSON_CACHE); + } + + static Set cachedScoreUsers(Path directory) { + Set users = new LinkedHashSet<>(); + try (Stream files = Files.list(directory)) { + for (Path path : files.filter(p -> p.getFileName().toString().endsWith(".json")).toList()) { + try { + var score = JsonParser.parseString(Files.readString(path)).getAsJsonObject(); + long id = score.has("user_id") && !score.get("user_id").isJsonNull() + ? score.get("user_id").getAsLong() + : score.getAsJsonObject("user").get("id").getAsLong(); + if (id > 0) users.add(id); + } catch (IOException | RuntimeException e) { + LOG.debug("Skipping unreadable cached score {}", path, e); + } + } + } catch (IOException e) { + LOG.warn("Unable to discover auto cache users", e); + } + return users; + } + + static void prefetch(AutoCacheService.Type type, long id, TokenData token) { + try { + switch (type) { + case BEATMAPSET -> { + if (!cacheBeatmapsetFile(id)) throw new IOException("Beatmapset download failed: " + id); + } + case BEATMAPSET_JSON -> { + if (getBeatmapsetJsonCache(id).isEmpty()) { + var value = OsuAPI.getBeatmapset(token, id); + if (value != null) publishPrefetch(BEATMAPSET_JSON_CACHE.resolve(id + ".json"), + GSON.toJson(value).getBytes(StandardCharsets.UTF_8)); + } + } + case BEATMAP_JSON -> { + if (getBeatmapJsonCache(id).isEmpty()) { + var value = OsuAPI.getBeatmap(token, id); + if (value != null) publishPrefetch(BEATMAP_JSON_CACHE.resolve(id + ".json"), + GSON.toJson(value).getBytes(StandardCharsets.UTF_8)); + } + } + case BEATMAP -> { + Path path = beatmapCachePath(id); + if (!Files.exists(path)) { + byte[] bytes = OsuAPI.getBeatmapBytes(id); + if (bytes != null) publishPrefetch(path, bytes); + } + } + } + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + } + + static void publishPrefetch(Path path, byte[] bytes) throws IOException { + // Publish complete files without replacing concurrent foreground results. + Path temporary = Files.createTempFile(path.getParent(), "prefetch-", ".tmp"); + try { + Files.write(temporary, bytes); + try { + Files.move(temporary, path); + } catch (FileAlreadyExistsException ignored) { + // The foreground request won the race. + } + } finally { + Files.deleteIfExists(temporary); + } + } + public static Optional getScoreJsonCache(long id) throws IOException { if (!Files.exists(SCORE_JSON_CACHE.resolve(id + ".json"))) { return Optional.empty(); @@ -381,8 +486,55 @@ public static Optional getScoreJsonCache(long id) throws IOException { return Optional.of(GSON.fromJson(jsonElement, Score.class)); } - public static void cacheScoreJson(Score score) throws IOException { - Files.writeString(SCORE_JSON_CACHE.resolve(score.getId() + ".json"), GSON.toJson(score)); + public static void cacheBeatmapsetJson(Beatmapset beatmapset) throws IOException { + Files.writeString(BEATMAPSET_JSON_CACHE.resolve(beatmapset.getId() + ".json"), GSON.toJson(beatmapset)); + } + + public static boolean tryCache(Object obj) { + try { + if (obj instanceof Beatmapset beatmapset) { + cacheBeatmapsetJson(beatmapset); + } else if (obj instanceof BeatmapExtended beatmap) { + cacheBeatmapJson(beatmap); + } else if (obj instanceof Score score) { + cacheScoreJson(score); + } + return true; + } catch (IOException e) { + LOG.error("Failed to cache object", e); + return false; + } + } + + public static Optional getBeatmapsetJsonCache(long id) { + if (!Files.exists(BEATMAPSET_JSON_CACHE.resolve(id + ".json"))) { + return Optional.empty(); + } + + try { + final var json = JsonParser.parseString(Files.readString(BEATMAPSET_JSON_CACHE.resolve(id + ".json"))); + return Optional.of(GSON.fromJson(json, Beatmapset.class)); + } catch (IOException e) { + LOG.error("Failed to read beatmapset JSON cache", e); + return Optional.empty(); + } + } + + public static void cacheBeatmapJson(BeatmapExtended beatmap) throws IOException { + Files.writeString(BEATMAP_JSON_CACHE.resolve(beatmap.getId() + ".json"), GSON.toJson(beatmap)); + } + + public static Optional getBeatmapJsonCache(long id) { + if (!Files.exists(BEATMAP_JSON_CACHE.resolve(id + ".json"))) { + return Optional.empty(); + } + + try { + final var json = JsonParser.parseString(Files.readString(BEATMAP_JSON_CACHE.resolve(id + ".json"))); + return Optional.of(GSON.fromJson(json, BeatmapExtended.class)); + } catch (IOException e) { + throw new RuntimeException(e); + } } public static void transferReplay(Long id, byte[] bytes) throws IOException { @@ -428,7 +580,8 @@ public static CacheSummary summary() { AreaStats replays = areaStats(REPLAY_CACHE); AreaStats scoreJson = areaStats(SCORE_JSON_CACHE); AreaStats beatmapsets = areaStats(BEATMAPSET_CACHE); - return new CacheSummary(beatmaps, images, replays, scoreJson, beatmapsets); + return new CacheSummary(beatmaps, images, replays, scoreJson, beatmapsets, + areaStats(BEATMAP_JSON_CACHE), areaStats(BEATMAPSET_JSON_CACHE)); } catch (IOException e) { throw new IllegalStateException("Failed to inspect oStella cache", e); } @@ -442,6 +595,8 @@ public static int clear(CacheArea area) { if (area == CacheArea.REPLAYS || area == CacheArea.ALL) removed += clearChildren(REPLAY_CACHE); if (area == CacheArea.SCORE_JSON || area == CacheArea.ALL) removed += clearChildren(SCORE_JSON_CACHE); if (area == CacheArea.BEATMAPSETS || area == CacheArea.ALL) removed += clearChildren(BEATMAPSET_CACHE); + if (area == CacheArea.BEATMAP_JSON || area == CacheArea.ALL) removed += clearChildren(BEATMAP_JSON_CACHE); + if (area == CacheArea.BEATMAPSET_JSON || area == CacheArea.ALL) removed += clearChildren(BEATMAPSET_JSON_CACHE); return removed; } catch (IOException e) { throw new IllegalStateException("Failed to clear oStella cache", e); @@ -503,6 +658,8 @@ public static CacheControlResult fetch(CacheControlRequest request, TokenData to } switch (type) { case "SCORE" -> cacheScoreJson(OsuAPI.getScore(tokenData, request.id())); + case "BEATMAP-JSON" -> cacheBeatmapJson(OsuAPI.getBeatmap(tokenData, request.id())); + case "BEATMAPSET-JSON" -> cacheBeatmapsetJson(OsuAPI.getBeatmapset(tokenData, request.id())); case "BEATMAP" -> getBeatmapPath(request.id(), true); case "BEATMAPSET" -> getBeatmapsetArchivePath(request.id()); case "REPLAY" -> getReplayBlocking(tokenData, request.id()); @@ -534,6 +691,8 @@ public static Path existingCachePath(String typeValue, long id) { private static List cachePaths(String type, long id) throws IOException { return switch (type) { case "SCORE" -> List.of(SCORE_JSON_CACHE.resolve(id + ".json")); + case "BEATMAP-JSON" -> List.of(BEATMAP_JSON_CACHE.resolve(id + ".json")); + case "BEATMAPSET-JSON" -> List.of(BEATMAPSET_JSON_CACHE.resolve(id + ".json")); case "BEATMAP" -> List.of(beatmapCachePath(id)); case "REPLAY" -> List.of(REPLAY_CACHE.resolve(id + ".osr")); case "BEATMAPSET" -> { @@ -585,8 +744,8 @@ private static String normalizeOperation(String value) { private static String normalizeType(String value) { String normalized = value == null ? "" : value.toUpperCase(Locale.ROOT); - if (!List.of("SCORE", "BEATMAP", "BEATMAPSET", "REPLAY").contains(normalized)) - throw new IllegalArgumentException("Cache type must be score, beatmap, beatmapset, or replay"); + if (!List.of("SCORE", "BEATMAP", "BEATMAPSET", "REPLAY", "BEATMAP-JSON", "BEATMAPSET-JSON").contains(normalized)) + throw new IllegalArgumentException("Cache type must be score, beatmap, beatmapset, replay, beatmap-json, or beatmapset-json"); return normalized; } @@ -627,6 +786,8 @@ public enum CacheArea { REPLAYS, SCORE_JSON, BEATMAPSETS, + BEATMAP_JSON, + BEATMAPSET_JSON, ALL } @@ -638,14 +799,18 @@ public record CacheSummary( AreaStats images, AreaStats replays, AreaStats scoreJson, - AreaStats beatmapsets + AreaStats beatmapsets, + AreaStats beatmapJson, + AreaStats beatmapsetJson ) { public long totalFiles() { - return beatmaps.files + images.files + replays.files + scoreJson.files + beatmapsets.files; + return beatmaps.files + images.files + replays.files + scoreJson.files + beatmapsets.files + + beatmapJson.files + beatmapsetJson.files; } public long totalBytes() { - return beatmaps.bytes + images.bytes + replays.bytes + scoreJson.bytes + beatmapsets.bytes; + return beatmaps.bytes + images.bytes + replays.bytes + scoreJson.bytes + beatmapsets.bytes + + beatmapJson.bytes + beatmapsetJson.bytes; } } } diff --git a/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java b/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java index 779be04..2d6a3ad 100644 --- a/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java +++ b/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java @@ -22,9 +22,7 @@ import java.io.IOException; import java.time.Duration; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedList; +import java.util.*; import java.util.List; public class MissVisualizeService { @@ -46,6 +44,10 @@ public class MissVisualizeService { } public static byte[] visualizeMiss(ReplayAnalyze replayAnalyze, int missIndex) { + return renderMiss(prepareMiss(replayAnalyze, missIndex)); + } + + public static MissVisualizationData prepareMiss(ReplayAnalyze replayAnalyze, int missIndex) { final List missEvents = replayAnalyze.events().stream() .filter(hitEvent -> !hitEvent.wasHit()) .filter(hitEvent -> hitEvent.hitObject().getObjectType() != HitObject.ObjectType.SPINNER) @@ -60,26 +62,42 @@ public static byte[] visualizeMiss(ReplayAnalyze replayAnalyze, int missIndex) { final var keyFrames = replayAnalyze.replay().timedKeyFrames(); - final int mods = replayAnalyze.replay().mods(); - final var realtimePpLoss = AnalyzeController.calculateRealtimePpLoss( - replayAnalyze.beatmap(), replayAnalyze, mods, targetMiss); - final var finalPpLoss = AnalyzeController.calculateFinalPpLoss( - replayAnalyze.beatmap(), replayAnalyze, mods, targetMiss); - final var totalMissPpLoss = AnalyzeController.calculateTotalMissPpLoss( - replayAnalyze.beatmap(), replayAnalyze, mods); - - return ImageHelper.drawMiss( - missIndex, - targetMiss, - extractNearbyKeyFrames(keyFrames, targetMiss.hitObject()), - replayAnalyze.beatmap(), - replayAnalyze.calculatedDifficulty(), - ReplayAnalyzer.hasHardRock(replayAnalyze.replay()), - realtimePpLoss, finalPpLoss, totalMissPpLoss + final var realtimePpLoss = AnalyzeController.calculateRealtimePpLoss(replayAnalyze.beatmap(), replayAnalyze, mods, targetMiss); + final var finalPpLoss = AnalyzeController.calculateFinalPpLoss(replayAnalyze.beatmap(), replayAnalyze, mods, targetMiss); + final var totalMissPpLoss = AnalyzeController.calculateTotalMissPpLoss(replayAnalyze.beatmap(), replayAnalyze, mods); + final var nearbyHitEvents = extractNearbyHitEvents(replayAnalyze.events(), targetMiss); + final var nearbyKeyFrames = extractNearbyKeyFrames(keyFrames, targetMiss.hitObject()); + + return new MissVisualizationData( + missIndex, targetMiss, nearbyHitEvents, nearbyKeyFrames, + replayAnalyze.beatmap(), replayAnalyze.calculatedDifficulty(), + ReplayAnalyzer.hasHardRock(replayAnalyze.replay()), realtimePpLoss, finalPpLoss, totalMissPpLoss ); } + public static byte[] renderMiss(MissVisualizationData data) { + return ImageHelper.drawMiss(data.index(), data.target(), data.keyFrames(), data.beatmap(), + data.difficulty(), data.hardRock(), data.realtimePpLoss(), data.finalPpLoss(), data.totalMissPpLoss()); + } + + private static List extractNearbyHitEvents(List events, HitEvent target) { + int index = -1; + + for (int i = 0; i < events.size(); i++) { + if (events.get(i).eventTime() == target.eventTime()) { + index = i; + break; + } + } + + if (index == -1) { + return List.of(); + } + + return events.subList(Math.max(0, index - 5), Math.min(events.size(), index + 5)); + } + private static List extractNearbyKeyFrames(List keyFrames, HitObject hitObject) { if (keyFrames == null || keyFrames.isEmpty()) { throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "Replay contains no keyframes"); @@ -106,6 +124,36 @@ private static List extractNearbyKeyFrames(List nearbyHitEvents, List keyFrames, + OsuBeatmap beatmap, DifficultyAttribute difficulty, boolean hardRock, + AnalyzeController.PPLoss realtimePpLoss, AnalyzeController.PPLoss finalPpLoss, + AnalyzeController.PPLoss totalMissPpLoss + ) { + public Map responseData() { + Map result = new HashMap<>(); + result.put("index", index); + result.put("beatmapId", beatmap.getBeatmapId()); + result.put("targetMiss", target); + result.put("nearbyHitEvents", nearbyHitEvents); + result.put("nearbyKeyFrames", keyFrames); + result.put("difficulty", difficulty); + result.put("difficultySpecs", Map.of( + "circleRadius", difficulty.getCircleRadiusInPixel(), + "windows", Map.of( + "perfect", difficulty.getPerfectWindow(), + "ok", difficulty.getOkWindow(), + "meh", difficulty.getMehWindow(), + "miss", difficulty.getMissWindow() + ) + )); + result.put("realtimePpLoss", realtimePpLoss); + result.put("finalPpLoss", finalPpLoss); + result.put("totalMissPpLoss", totalMissPpLoss); + return result; + } + } + private static final class Colors { private static final Color PRESSED_COLOR = new Color(255, 204, 34); private static final Color UNPRESSED_COLOR = new Color(68, 68, 68); diff --git a/src/main/java/xyz/zcraft/ostella/service/MultiplayerResultFactory.java b/src/main/java/xyz/zcraft/ostella/service/MultiplayerResultFactory.java index a235a8f..610608d 100644 --- a/src/main/java/xyz/zcraft/ostella/service/MultiplayerResultFactory.java +++ b/src/main/java/xyz/zcraft/ostella/service/MultiplayerResultFactory.java @@ -1,15 +1,9 @@ package xyz.zcraft.ostella.service; import xyz.zcraft.ostella.data.MultiplayerResultData; -import xyz.zcraft.ostella.data.MultiplayerRoomDetails; +import xyz.zcraft.osu.model.multiplayer.Room; import xyz.zcraft.ostella.data.MultiplayerRoomScore; -import xyz.zcraft.osu.model.Beatmap; -import xyz.zcraft.osu.model.BeatmapExtended; -import xyz.zcraft.osu.model.Beatmapset; -import xyz.zcraft.osu.model.Mod; -import xyz.zcraft.osu.model.Score; -import xyz.zcraft.osu.model.User; -import xyz.zcraft.osu.model.UserExtended; +import xyz.zcraft.osu.model.*; import java.util.*; @@ -18,37 +12,39 @@ private MultiplayerResultFactory() { } public static MultiplayerResultData create( - MultiplayerRoomDetails room, - MultiplayerRoomDetails.PlaylistItem item, + Room room, + Room.PlaylistItem item, List roomScores, User owner ) { return create(room, item, roomScores, owner, "lazer", "scorev2", room.getType(), - MultiplayerResultData.SeriesScore.empty()); + MultiplayerResultData.SeriesScore.empty(), null); } public static MultiplayerResultData create( - MultiplayerRoomDetails room, - MultiplayerRoomDetails.PlaylistItem item, + Room room, + Room.PlaylistItem item, List roomScores, User owner, String client, String scoringType, - String teamType + String teamType, + Integer customBo ) { return create(room, item, roomScores, owner, client, scoringType, teamType, - MultiplayerResultData.SeriesScore.empty()); + MultiplayerResultData.SeriesScore.empty(), customBo); } public static MultiplayerResultData create( - MultiplayerRoomDetails room, - MultiplayerRoomDetails.PlaylistItem item, + Room room, + Room.PlaylistItem item, List roomScores, User owner, String client, String scoringType, String teamType, - MultiplayerResultData.SeriesScore seriesScore + MultiplayerResultData.SeriesScore seriesScore, + Integer customBo ) { BeatmapExtended map = item.getBeatmap(); MultiplayerResultData.BeatmapInfo mapInfo = toBeatmapInfo(item.getBeatmapId(), map); @@ -139,9 +135,9 @@ public static MultiplayerResultData create( long blueTotal = teamTotal(bluePlayers); List teams = teamVs ? List.of( - new MultiplayerResultData.TeamResult("red", "Red Team", redTotal, redPlayers), - new MultiplayerResultData.TeamResult("blue", "Blue Team", blueTotal, bluePlayers) - ) + new MultiplayerResultData.TeamResult("red", "Red Team", redTotal, redPlayers), + new MultiplayerResultData.TeamResult("blue", "Blue Team", blueTotal, bluePlayers) + ) : List.of(); String winningTeam = !teamVs || redTotal == blueTotal ? "tie" : redTotal > blueTotal ? "red" : "blue"; long higherTeamScore = Math.max(redTotal, blueTotal); @@ -166,7 +162,8 @@ public static MultiplayerResultData create( queuedBy, players, teams, - unassignedPlayers + unassignedPlayers, + customBo ); } diff --git a/src/main/java/xyz/zcraft/ostella/service/RenderService.java b/src/main/java/xyz/zcraft/ostella/service/RenderService.java index 27c6075..870148c 100644 --- a/src/main/java/xyz/zcraft/ostella/service/RenderService.java +++ b/src/main/java/xyz/zcraft/ostella/service/RenderService.java @@ -11,14 +11,10 @@ import org.thymeleaf.templatemode.TemplateMode; import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; import org.thymeleaf.templateresolver.FileTemplateResolver; -import xyz.zcraft.ostella.data.Placement; -import xyz.zcraft.ostella.data.BeatmapAnalysisData; -import xyz.zcraft.ostella.data.MultiplayerResultData; -import xyz.zcraft.ostella.data.ScoreType; -import xyz.zcraft.ostella.data.UserPerformanceSummary; +import xyz.zcraft.ostella.data.*; import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.network.ErrorCode; -import xyz.zcraft.ostella.network.controller.AnalyzeController; +import xyz.zcraft.ostella.data.ScoreAnalysisData; import xyz.zcraft.ostella.util.Colors; import xyz.zcraft.ostella.util.MiscUtil; import xyz.zcraft.ostella.util.format.*; @@ -42,15 +38,14 @@ public class RenderService implements AutoCloseable { private static final Logger LOG = LogManager.getLogger(RenderService.class); + private static final Path LOCAL_TEMPLATES_PATH = Path.of("templates"); + private static final Path LOCAL_ASSETS_PATH = LOCAL_TEMPLATES_PATH.resolve("assets"); @Getter private final ExecutorService renderExecutor; - private final ThreadLocal workerStateLocal = new ThreadLocal<>(); + private final ThreadLocal workerStateLocal = new ThreadLocal<>(); private final TemplateEngine templateEngine; private final TemplateEngine templateEngineLocal; - private static final Path LOCAL_TEMPLATES_PATH = Path.of("templates"); - private static final Path LOCAL_ASSETS_PATH = LOCAL_TEMPLATES_PATH.resolve("assets"); - public RenderService(int maxWorkers) { if (maxWorkers <= 0) { throw new IllegalArgumentException("maxWorkers must be greater than 0: " + maxWorkers); @@ -89,6 +84,20 @@ public RenderService(int maxWorkers) { } } + private static String scoreListTitle(ScoreType type, List filters, int scoreCount) { + return filters.isEmpty() + ? switch (type) { + case BEST -> "Best of " + scoreCount + " Scores"; + case RECENT -> "Most recent " + scoreCount + " Scores"; + case RECENT_PASS -> "Most recent " + scoreCount + " Passed Scores"; + } + : switch (type) { + case BEST -> "Filtered Scores From Best Scores"; + case RECENT -> "Filtered Scores From Recent Scores"; + case RECENT_PASS -> "Filtered Scores From Recent Passed Scores"; + }; + } + private @NonNull ThreadFactory getThreadFactory() { AtomicInteger workerId = new AtomicInteger(); @@ -124,7 +133,7 @@ private byte[] takeScreenshot(String html) { } var context = workerState.context(); - + try (Page page = context.newPage()) { page.setContent(html); page.waitForLoadState(LoadState.NETWORKIDLE); @@ -133,7 +142,7 @@ private byte[] takeScreenshot(String html) { } catch (TimeoutError timeoutError) { throw new ApiException(ErrorCode.IMAGE_RENDER_TIMEOUT, timeoutError.getMessage()); } - + // page.waitForFunction(""" // () => document.fonts.status === 'loaded' // && Array.from(document.images).every(img => img.complete) @@ -166,6 +175,8 @@ private Context createContext() { ctx.setVariable("cache", new CacheService()); //noinspection InstantiationOfUtilityClass ctx.setVariable("MiscUtil", new MiscUtil()); + //noinspection InstantiationOfUtilityClass + ctx.setVariable("MPs", new MultiplayerFormatUtil()); return ctx; } @@ -232,20 +243,6 @@ public byte[] renderScores( return takeScreenshot(finalHtml); } - private static String scoreListTitle(ScoreType type, List filters, int scoreCount) { - return filters.isEmpty() - ? switch (type) { - case BEST -> "Best of " + scoreCount + " Scores"; - case RECENT -> "Most recent " + scoreCount + " Scores"; - case RECENT_PASS -> "Most recent " + scoreCount + " Passed Scores"; - } - : switch (type) { - case BEST -> "Filtered Scores From Best Scores"; - case RECENT -> "Filtered Scores From Recent Scores"; - case RECENT_PASS -> "Filtered Scores From Recent Passed Scores"; - }; - } - public byte[] renderMapLeaderboard(BeatmapExtended map, List placements, double ppMax) { Context ctx = createContext(); ctx.setVariable("beatmap", map); @@ -316,7 +313,7 @@ public byte[] renderScore(Score score, DiffSpec spec, Double calPp, boolean repl return takeScreenshot(finalHtml); } - public byte[] renderScoreAnalysis(AnalyzeController.ScoreAnalyzeData analyzeData) { + public byte[] renderScoreAnalysis(ScoreAnalysisData analyzeData) { Context ctx = createContext(); ctx.setVariable("score", analyzeData.score()); ctx.setVariable("diff", analyzeData.diffSpec()); diff --git a/src/main/java/xyz/zcraft/ostella/service/ReplayService.java b/src/main/java/xyz/zcraft/ostella/service/ReplayService.java index 2703c23..f20c9b9 100644 --- a/src/main/java/xyz/zcraft/ostella/service/ReplayService.java +++ b/src/main/java/xyz/zcraft/ostella/service/ReplayService.java @@ -5,9 +5,9 @@ import com.google.gson.JsonParser; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import xyz.zcraft.ostella.config.AppConfig; import xyz.zcraft.ostella.cache.CacheControlRequest; import xyz.zcraft.ostella.cache.CacheControlResult; +import xyz.zcraft.ostella.config.AppConfig; import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.network.ErrorCode; import xyz.zcraft.ostella.util.MiscUtil; @@ -51,13 +51,37 @@ public ReplayService(AppConfig config) { .build(); } + private static String rootMessage(Throwable error) { + Throwable current = error; + while (current.getCause() != null) current = current.getCause(); + return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + } + + private static String stringOrNull(JsonObject json, String name) { + return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : null; + } + + private static void requireSuccess(int statusCode, String body) { + if (statusCode >= 200 && statusCode < 300) { + return; + } + String suffix = body == null || body.isBlank() ? "" : ": " + body; + throw new ApiException(ErrorCode.RENDERER_UNAVAILABLE, + "osuRenderer returned HTTP " + statusCode + suffix); + } + + private static ApiException unavailable(String message, Exception error) { + LOG.warn(message, error); + return new ApiException(ErrorCode.RENDERER_UNAVAILABLE, message, error); + } + public QueuedJob queueRender(long scoreId, Path replay, long beatmapsetId, Path beatmapset, - double start, double end, boolean obscured) { + double start, double end, boolean obscured) { return queueRender(scoreId, replay, beatmapsetId, beatmapset, start, end, obscured, null); } public QueuedJob queueRender(long scoreId, Path replay, long beatmapsetId, Path beatmapset, - double start, double end, boolean obscured, QqUploadRequest qqUpload) { + double start, double end, boolean obscured, QqUploadRequest qqUpload) { return upload("single", null, beatmapsetId, beatmapset, List.of(new ReplayInput(scoreId, replay)), start, end, "", obscured, qqUpload); } @@ -69,15 +93,15 @@ public QueuedJob queueRenderShowcase(String beatmapId, long beatmapsetId, } public QueuedJob queueRenderShowcase(String beatmapId, long beatmapsetId, - List replays, Path beatmapset, - QqUploadRequest qqUpload) { + List replays, Path beatmapset, + QqUploadRequest qqUpload) { return upload("showcase", beatmapId, beatmapsetId, beatmapset, replays, Double.NaN, Double.NaN, "", false, qqUpload); } public QueuedJob queueRenderPreview(long beatmapId, long beatmapsetId, Path beatmapset, - double start, double end, String mods, - QqUploadRequest qqUpload) { + double start, double end, String mods, + QqUploadRequest qqUpload) { return upload("autoplay", String.valueOf(beatmapId), beatmapsetId, beatmapset, List.of(), start, end, mods, false, qqUpload); } @@ -300,12 +324,6 @@ private CacheControlResult.CacheNodeResult controlWorkerCache( } } - private static String rootMessage(Throwable error) { - Throwable current = error; - while (current.getCause() != null) current = current.getCause(); - return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); - } - public InputStream openJobResult(String jobId) { RendererWorker assigned = jobWorkers.get(jobId); if (assigned != null) { @@ -521,7 +539,7 @@ private CacheStatus getCacheStatus(RendererWorker worker, Set beatmapsetId private WorkerStatus getWorkerStatus(RendererWorker worker) { HttpResponse response = sendString(worker, request(worker, "renders/status") - .timeout(Duration.ofSeconds(10)) + .timeout(Duration.ofSeconds(5)) .GET() .build()); if (response.statusCode() == 404) { @@ -541,7 +559,7 @@ private WorkerStatus getWorkerStatus(RendererWorker worker) { } } - private List probeWorkers() { + public List probeWorkers() { List> probes = rotatedWorkers().stream() .map(worker -> CompletableFuture.supplyAsync(() -> getWorkerStatus(worker), workerProbeExecutor) .exceptionally(_ -> { @@ -615,24 +633,6 @@ private List rotatedWorkers() { return rotated; } - private static String stringOrNull(JsonObject json, String name) { - return json.has(name) && !json.get(name).isJsonNull() ? json.get(name).getAsString() : null; - } - - private static void requireSuccess(int statusCode, String body) { - if (statusCode >= 200 && statusCode < 300) { - return; - } - String suffix = body == null || body.isBlank() ? "" : ": " + body; - throw new ApiException(ErrorCode.RENDERER_UNAVAILABLE, - "osuRenderer returned HTTP " + statusCode + suffix); - } - - private static ApiException unavailable(String message, Exception error) { - LOG.warn(message, error); - return new ApiException(ErrorCode.RENDERER_UNAVAILABLE, message, error); - } - @Override public void close() { workerProbeExecutor.close(); @@ -693,7 +693,7 @@ private record CacheStatus(Set beatmapsetIds, Set replayIds) { private record RendererWorker(URI uri) { } - private record WorkerStatus(RendererWorker worker, int queue, int active) { + public record WorkerStatus(RendererWorker worker, int queue, int active) { } private record JobLookup(boolean found, JobProgress progress) { diff --git a/src/main/java/xyz/zcraft/ostella/util/MiscUtil.java b/src/main/java/xyz/zcraft/ostella/util/MiscUtil.java index e35e470..51d63c4 100644 --- a/src/main/java/xyz/zcraft/ostella/util/MiscUtil.java +++ b/src/main/java/xyz/zcraft/ostella/util/MiscUtil.java @@ -56,4 +56,8 @@ public static JsonObject deepMergeJson(JsonObject first, JsonObject... others) { public static boolean strEquals(String a, String b) { return Objects.equals(a, b); } + + public static > T max(T a, T b) { + return a.compareTo(b) >= 0 ? a : b; + } } diff --git a/src/main/java/xyz/zcraft/ostella/util/RequestUtil.java b/src/main/java/xyz/zcraft/ostella/util/RequestUtil.java index a761ef9..6ee541a 100644 --- a/src/main/java/xyz/zcraft/ostella/util/RequestUtil.java +++ b/src/main/java/xyz/zcraft/ostella/util/RequestUtil.java @@ -1,10 +1,11 @@ package xyz.zcraft.ostella.util; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import io.javalin.http.Context; -import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.data.ScoreId; +import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.network.ErrorCode; import xyz.zcraft.ostella.network.Response; @@ -13,10 +14,10 @@ import java.util.Objects; public class RequestUtil { - private final static Gson GSON = new Gson(); + private final static Gson GSON = new GsonBuilder().serializeSpecialFloatingPointValues().create(); public static void putResult(Context context, Object result) { - context.status(200); + context.status(200).contentType("application/json"); JsonElement data; if (result instanceof JsonElement) { diff --git a/src/main/java/xyz/zcraft/ostella/util/TokenManager.java b/src/main/java/xyz/zcraft/ostella/util/TokenManager.java index 219f69c..9a6a5e1 100644 --- a/src/main/java/xyz/zcraft/ostella/util/TokenManager.java +++ b/src/main/java/xyz/zcraft/ostella/util/TokenManager.java @@ -8,9 +8,9 @@ import xyz.zcraft.ostella.network.OsuAPI; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; public class TokenManager implements AutoCloseable { diff --git a/src/main/java/xyz/zcraft/ostella/util/format/MultiplayerFormatUtil.java b/src/main/java/xyz/zcraft/ostella/util/format/MultiplayerFormatUtil.java new file mode 100644 index 0000000..b7c19ed --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/util/format/MultiplayerFormatUtil.java @@ -0,0 +1,25 @@ +package xyz.zcraft.ostella.util.format; + +import xyz.zcraft.ostella.data.MultiplayerResultData; + +public class MultiplayerFormatUtil { + public static int getLeftLoseIndicatorCount(MultiplayerResultData result) { + final var leftScore = result.versusScores().getFirst(); + final var rightScore = result.versusScores().getLast(); + if (result.customBo() != null) { + return Math.max((result.customBo() + 1) / 2 - leftScore.wins(), 0); + } else { + return Math.max(rightScore.wins() - leftScore.wins() + 1, 1); + } + } + + public static int getRightLoseIndicatorCount(MultiplayerResultData result) { + final var leftScore = result.versusScores().getFirst(); + final var rightScore = result.versusScores().getLast(); + if (result.customBo() != null) { + return Math.max((result.customBo() + 1) / 2 - rightScore.wins(), 0); + } else { + return Math.max(leftScore.wins() - rightScore.wins() + 1, 1); + } + } +} diff --git a/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java b/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java index c229792..c2aa3b9 100644 --- a/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java +++ b/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java @@ -1,9 +1,9 @@ package xyz.zcraft.ostella.util.format; +import xyz.zcraft.ostella.data.ScoreId; import xyz.zcraft.ostella.service.CacheService; import xyz.zcraft.ostella.util.Colors; import xyz.zcraft.ostella.util.MiscUtil; -import xyz.zcraft.ostella.data.ScoreId; import xyz.zcraft.osu.model.Mod; import xyz.zcraft.osu.model.Score; diff --git a/src/main/java/xyz/zcraft/ostella/util/format/UserFormatUtil.java b/src/main/java/xyz/zcraft/ostella/util/format/UserFormatUtil.java index 9d6a0d6..1ebe392 100644 --- a/src/main/java/xyz/zcraft/ostella/util/format/UserFormatUtil.java +++ b/src/main/java/xyz/zcraft/ostella/util/format/UserFormatUtil.java @@ -5,8 +5,8 @@ import xyz.zcraft.osu.model.User; import xyz.zcraft.osu.model.UserExtended; -import java.time.OffsetDateTime; import java.time.LocalDate; +import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.Comparator; diff --git a/src/main/resources/ostella-example-config.yml b/src/main/resources/ostella-example-config.yml index a081be0..9f41ad2 100644 --- a/src/main/resources/ostella-example-config.yml +++ b/src/main/resources/ostella-example-config.yml @@ -1,5 +1,7 @@ # === Config for oStella === ostella: + # Bearer token for authenticating when calling oStella's API + token: # Maximum number of requests per second to send to the osu! API requestPerSecond: 10 # Minimum delay between replay download requests in milliseconds diff --git a/src/main/resources/template/multiplayer-room-result.html b/src/main/resources/template/multiplayer-room-result.html index caf2b4a..ac5028c 100644 --- a/src/main/resources/template/multiplayer-room-result.html +++ b/src/main/resources/template/multiplayer-room-result.html @@ -983,7 +983,7 @@ } .versus-total-separator { - padding: 0 24px; + padding: 4px 8px 0 8px; color: var(--faint); font-size: 18px; font-weight: 900; @@ -1434,6 +1434,41 @@ background: var(--surface-deep); font-size: 13px; } + + .win-indicator, .lose-indicator { + display: block; + width: 32px; + height: 32px; + } + + .win-indicator { + background-color: white; + } + + .lose-indicator { + border: 4px solid; + } + + .win-indicator.blue { + background-color: var(--blue-team); + } + + .win-indicator.red { + background-color: var(--red-team); + } + + .lose-indicator.blue { + border-color: var(--blue-team); + } + + .lose-indicator.red { + border-color: var(--red-team); + } + + .win-count { + font-weight: bolder; + font-size: 3em; + } @@ -1898,12 +1933,33 @@ th:with="leftScore=${result.versusScores[0]}, rightScore=${result.versusScores[1]}">
Left side - 3 +
+
3
+
+
+
-
WINS · VS
+
BO11
+
Right side - 2 +
+
+
+
2
+
diff --git a/src/main/resources/template/score-analysis.html b/src/main/resources/template/score-analysis.html index 453085a..b8f2d06 100644 --- a/src/main/resources/template/score-analysis.html +++ b/src/main/resources/template/score-analysis.html @@ -471,7 +471,8 @@ mod + height="48" src="https://osu.ppy.sh/assets/images/mod-double-time.895bdda1.svg" + th:src="${cache.getImageSrc(Mods.getModIcon(amod))}" width="48">
@@ -587,7 +588,9 @@
⚠
-
Replay sim result mismatch (123 / 456 / 789 / 123). Analysis may be inaccurate.
+
Replay + sim result mismatch (123 / 456 / 789 / 123). Analysis may be inaccurate. +
⚠
@@ -705,6 +708,10 @@ +
+ Aim UR: 123.45 +
diff --git a/src/main/resources/template/score-list.html b/src/main/resources/template/score-list.html index da42fb4..55bf4a2 100644 --- a/src/main/resources/template/score-list.html +++ b/src/main/resources/template/score-list.html @@ -435,6 +435,10 @@ font-size: 1.8em; font-weight: bold; } + + .hidden { + display: none !important; + }