Add MCP server over Streamable HTTP
Seven read-only tools over the catalog database: list_titles, search_scripts, get_script, get_edition, list_files, find_file and collection_stats. Writing stays in the CLI, where the effect of a command is visible. Transport is Streamable HTTP on the JDK's com.sun.net.httpserver, so Gson is the only new dependency. No sessions are kept: every tool is stateless, so Mcp-Session-Id is omitted and GET returns 405 rather than opening an SSE stream we would never write to. Requests carrying a non-loopback Origin are rejected, since a page in a browser can POST to localhost. Tool results are formatted text rather than JSON. The consumer is a model reading the answer, and prose costs less context than the same data wrapped in objects. Required arguments are validated against each tool's inputSchema before dispatch, so a missing parameter is an explicit tool error instead of a result computed from a default. find_file groups by the canonical lowercase path, otherwise an extracted directory and its ISO look like two files. Database access is serialized on one lock because sqlite-jdbc shares a single connection; the workload is read-only and single-client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2a051c561b
commit
393bcde6a8
@@ -29,6 +29,10 @@ dependencies {
|
||||
runtimeOnly "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop"
|
||||
|
||||
implementation 'org.xerial:sqlite-jdbc:3.46.1.3'
|
||||
|
||||
// JSON-RPC serwera MCP. Transport stoi na com.sun.net.httpserver z JDK,
|
||||
// więc poza parserem nie dokładamy nic.
|
||||
implementation 'com.google.code.gson:gson:2.11.0'
|
||||
}
|
||||
|
||||
application {
|
||||
|
||||
@@ -5,6 +5,7 @@ import pl.genschu.rexcatalog.catalog.Promoter;
|
||||
import pl.genschu.rexcatalog.db.Database;
|
||||
import pl.genschu.rexcatalog.ingest.Ingestor;
|
||||
import pl.genschu.rexcatalog.ingest.MetadataDetector;
|
||||
import pl.genschu.rexcatalog.mcp.McpServer;
|
||||
import pl.genschu.rexcatalog.script.ScriptDecoder;
|
||||
import pl.genschu.rexcatalog.script.ScriptSearch;
|
||||
|
||||
@@ -44,6 +45,7 @@ public final class Main {
|
||||
case "titles" -> titles(dataDir);
|
||||
case "set" -> set(dataDir, args);
|
||||
case "lang" -> lang(dataDir, args);
|
||||
case "mcp" -> mcp(dataDir, args);
|
||||
case "find" -> find(dataDir, args);
|
||||
case "cat" -> cat(dataDir, args);
|
||||
default -> usage();
|
||||
@@ -294,6 +296,33 @@ public final class Main {
|
||||
}
|
||||
}
|
||||
|
||||
private static void mcp(Path dataDir, String[] args) throws Exception {
|
||||
String host = option(args, "--host", "127.0.0.1");
|
||||
int port = Integer.parseInt(option(args, "--port", "8765"));
|
||||
|
||||
try (Database db = Database.open(dataDir)) {
|
||||
McpServer server = new McpServer(db, dataDir);
|
||||
server.start(host, port);
|
||||
|
||||
System.out.printf("Serwer MCP nasłuchuje na http://%s:%d/mcp%n", host, port);
|
||||
System.out.println("Transport: Streamable HTTP, bez sesji, tylko do odczytu.");
|
||||
System.out.println("Zatrzymanie: Ctrl+C");
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
// HttpServer pracuje na własnych wątkach, więc główny musi zaczekać
|
||||
Thread.currentThread().join();
|
||||
}
|
||||
}
|
||||
|
||||
private static String option(String[] args, String name, String fallback) {
|
||||
for (int i = 1; i < args.length - 1; i++) {
|
||||
if (args[i].equals(name)) {
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static void find(Path dataDir, String[] args) throws Exception {
|
||||
if (args.length < 2) {
|
||||
System.err.println("find wymaga zapytania, np. find SHOWCURSOR");
|
||||
@@ -453,6 +482,8 @@ public final class Main {
|
||||
find <zapytanie> przeszukaj skrypty (składnia FTS5)
|
||||
cat <ścieżka|sha1> wypisz odszyfrowany skrypt
|
||||
|
||||
mcp [--host H] [--port P] serwer MCP (Streamable HTTP, domyślnie 127.0.0.1:8765)
|
||||
|
||||
Baza: -Dcatalog.data=<katalog> (domyślnie ./data)
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package pl.genschu.rexcatalog.mcp;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import pl.genschu.rexcatalog.db.Database;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Serwer MCP w transporcie Streamable HTTP, na {@code com.sun.net.httpserver} z JDK.
|
||||
*
|
||||
* <p>Sesji nie prowadzimy: wszystkie narzędzia są bezstanowe i tylko do odczytu,
|
||||
* a specyfikacja pozwala pominąć {@code Mcp-Session-Id}. Z tego samego powodu
|
||||
* {@code GET} na endpoint zwraca 405 — nie mamy komunikatów inicjowanych przez serwer,
|
||||
* więc nie ma po co otwierać strumienia SSE.
|
||||
*/
|
||||
public final class McpServer {
|
||||
|
||||
/** Wersje protokołu, które umiemy obsłużyć; pierwsza jest domyślna. */
|
||||
private static final List<String> SUPPORTED_PROTOCOLS =
|
||||
List.of("2025-06-18", "2025-03-26", "2024-11-05");
|
||||
|
||||
private static final String SERVER_NAME = "rex-catalog";
|
||||
private static final String SERVER_VERSION = "0.1.0";
|
||||
|
||||
private final Tools tools;
|
||||
private final Map<String, Tools.Tool> byName = new LinkedHashMap<>();
|
||||
private final Object lock = new Object();
|
||||
private HttpServer http;
|
||||
|
||||
public McpServer(Database db, Path dataDir) {
|
||||
this.tools = new Tools(db, dataDir);
|
||||
for (Tools.Tool tool : tools.tools()) {
|
||||
byName.put(tool.name(), tool);
|
||||
}
|
||||
}
|
||||
|
||||
public void start(String host, int port) throws IOException {
|
||||
http = HttpServer.create(new InetSocketAddress(host, port), 0);
|
||||
http.createContext("/mcp", this::handle);
|
||||
// pula wątków, ale dostęp do bazy i tak serializujemy — patrz dispatch()
|
||||
http.setExecutor(Executors.newFixedThreadPool(4));
|
||||
http.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (http != null) {
|
||||
http.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void handle(HttpExchange exchange) throws IOException {
|
||||
try {
|
||||
if (!isOriginAllowed(exchange)) {
|
||||
// ochrona przed DNS rebinding — wymagana przez specyfikację transportu
|
||||
respond(exchange, 403, "text/plain", "Niedozwolony nagłówek Origin");
|
||||
return;
|
||||
}
|
||||
switch (exchange.getRequestMethod()) {
|
||||
case "POST" -> handlePost(exchange);
|
||||
case "GET" -> respond(exchange, 405, "text/plain",
|
||||
"Ten serwer nie wysyła komunikatów z własnej inicjatywy");
|
||||
case "DELETE" -> respond(exchange, 405, "text/plain", "Brak sesji do zamknięcia");
|
||||
default -> respond(exchange, 405, "text/plain", "Nieobsługiwana metoda");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
respond(exchange, 500, "text/plain", "Błąd serwera: " + e);
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePost(HttpExchange exchange) throws IOException {
|
||||
String body;
|
||||
try (InputStream in = exchange.getRequestBody()) {
|
||||
body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
JsonElement parsed;
|
||||
try {
|
||||
parsed = JsonParser.parseString(body);
|
||||
} catch (Exception e) {
|
||||
respond(exchange, 400, "application/json",
|
||||
error(null, -32700, "Nieparsowalny JSON").toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// wsadu nie rozdzielamy na osobne odpowiedzi w strumieniu — zwracamy tablicę
|
||||
if (parsed.isJsonArray()) {
|
||||
JsonArray responses = new JsonArray();
|
||||
for (JsonElement element : parsed.getAsJsonArray()) {
|
||||
JsonObject response = dispatch(element.getAsJsonObject());
|
||||
if (response != null) {
|
||||
responses.add(response);
|
||||
}
|
||||
}
|
||||
if (responses.isEmpty()) {
|
||||
respond(exchange, 202, "text/plain", "");
|
||||
} else {
|
||||
respond(exchange, 200, "application/json", responses.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObject response = dispatch(parsed.getAsJsonObject());
|
||||
if (response == null) {
|
||||
// powiadomienie: brak treści odpowiedzi
|
||||
respond(exchange, 202, "text/plain", "");
|
||||
} else {
|
||||
respond(exchange, 200, "application/json", response.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/** Zwraca odpowiedź albo {@code null}, gdy przyszło powiadomienie. */
|
||||
private JsonObject dispatch(JsonObject request) {
|
||||
JsonElement id = request.get("id");
|
||||
String method = request.has("method") ? request.get("method").getAsString() : "";
|
||||
boolean notification = id == null || id.isJsonNull();
|
||||
|
||||
try {
|
||||
JsonObject result = switch (method) {
|
||||
case "initialize" -> initialize(request);
|
||||
case "tools/list" -> toolsList();
|
||||
case "tools/call" -> toolsCall(request);
|
||||
case "ping" -> new JsonObject();
|
||||
default -> null;
|
||||
};
|
||||
|
||||
if (notification) {
|
||||
return null;
|
||||
}
|
||||
if (result == null) {
|
||||
return error(id, -32601, "Nieznana metoda: " + method);
|
||||
}
|
||||
JsonObject response = new JsonObject();
|
||||
response.addProperty("jsonrpc", "2.0");
|
||||
response.add("id", id);
|
||||
response.add("result", result);
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
if (notification) {
|
||||
return null;
|
||||
}
|
||||
String message = e.getMessage() == null ? e.toString() : e.getMessage();
|
||||
return error(id, -32603, message);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject initialize(JsonObject request) {
|
||||
String requested = null;
|
||||
if (request.has("params") && request.getAsJsonObject("params").has("protocolVersion")) {
|
||||
requested = request.getAsJsonObject("params").get("protocolVersion").getAsString();
|
||||
}
|
||||
// odbijamy wersję klienta, jeśli ją znamy — inaczej proponujemy własną najnowszą
|
||||
String agreed = SUPPORTED_PROTOCOLS.contains(requested)
|
||||
? requested : SUPPORTED_PROTOCOLS.get(0);
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("protocolVersion", agreed);
|
||||
|
||||
JsonObject capabilities = new JsonObject();
|
||||
capabilities.add("tools", new JsonObject());
|
||||
result.add("capabilities", capabilities);
|
||||
|
||||
JsonObject info = new JsonObject();
|
||||
info.addProperty("name", SERVER_NAME);
|
||||
info.addProperty("version", SERVER_VERSION);
|
||||
result.add("serverInfo", info);
|
||||
|
||||
result.addProperty("instructions",
|
||||
"Katalog kolekcji gier Aidem Media (seria Reksio, Poznaj Mity): obrazy płyt, "
|
||||
+ "sumy kontrolne, metadane wydań i odszyfrowane skrypty. "
|
||||
+ "Zacznij od list_titles, żeby poznać zawartość; "
|
||||
+ "search_scripts przeszukuje treść skryptów gier.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonObject toolsList() {
|
||||
JsonArray array = new JsonArray();
|
||||
for (Tools.Tool tool : byName.values()) {
|
||||
JsonObject entry = new JsonObject();
|
||||
entry.addProperty("name", tool.name());
|
||||
entry.addProperty("description", tool.description());
|
||||
entry.add("inputSchema", tool.inputSchema());
|
||||
array.add(entry);
|
||||
}
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("tools", array);
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonObject toolsCall(JsonObject request) {
|
||||
JsonObject params = request.has("params")
|
||||
? request.getAsJsonObject("params") : new JsonObject();
|
||||
String name = params.has("name") ? params.get("name").getAsString() : "";
|
||||
JsonObject arguments = params.has("arguments") && params.get("arguments").isJsonObject()
|
||||
? params.getAsJsonObject("arguments") : new JsonObject();
|
||||
|
||||
Tools.Tool tool = byName.get(name);
|
||||
if (tool == null) {
|
||||
return toolResult("Nie ma narzędzia o nazwie " + name
|
||||
+ ". Dostępne: " + String.join(", ", byName.keySet()), true);
|
||||
}
|
||||
|
||||
// wymagane argumenty sprawdzamy ze schematu, żeby brak parametru był błędem
|
||||
// narzędzia, a nie cichym wynikiem policzonym z wartości domyślnej
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (JsonElement required : tool.inputSchema().getAsJsonArray("required")) {
|
||||
String key = required.getAsString();
|
||||
if (!arguments.has(key) || arguments.get(key).isJsonNull()) {
|
||||
missing.add(key);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
return toolResult("Brakuje wymaganych argumentów narzędzia " + name + ": "
|
||||
+ String.join(", ", missing), true);
|
||||
}
|
||||
|
||||
try {
|
||||
String text;
|
||||
// sqlite-jdbc dzieli jedno połączenie, więc wywołania serializujemy;
|
||||
// obciążenie jest czysto odczytowe i pojedynczy klient go nie odczuje
|
||||
synchronized (lock) {
|
||||
text = tool.handler().apply(arguments);
|
||||
}
|
||||
return toolResult(text, false);
|
||||
} catch (Exception e) {
|
||||
String message = e.getMessage() == null ? e.toString() : e.getMessage();
|
||||
return toolResult("Narzędzie " + name + " zawiodło: " + message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject toolResult(String text, boolean isError) {
|
||||
JsonObject content = new JsonObject();
|
||||
content.addProperty("type", "text");
|
||||
content.addProperty("text", text);
|
||||
JsonArray array = new JsonArray();
|
||||
array.add(content);
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("content", array);
|
||||
result.addProperty("isError", isError);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonObject error(JsonElement id, int code, String message) {
|
||||
JsonObject error = new JsonObject();
|
||||
error.addProperty("code", code);
|
||||
error.addProperty("message", message);
|
||||
|
||||
JsonObject response = new JsonObject();
|
||||
response.addProperty("jsonrpc", "2.0");
|
||||
if (id == null) {
|
||||
response.add("id", com.google.gson.JsonNull.INSTANCE);
|
||||
} else {
|
||||
response.add("id", id);
|
||||
}
|
||||
response.add("error", error);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Przeglądarka na dowolnej stronie może wysłać POST na localhost, więc żądanie
|
||||
* z nagłówkiem Origin spoza pętli zwrotnej odrzucamy. Klienci MCP (procesy,
|
||||
* nie strony) Origin zwykle nie wysyłają i takie żądania przechodzą.
|
||||
*/
|
||||
private static boolean isOriginAllowed(HttpExchange exchange) {
|
||||
String origin = exchange.getRequestHeaders().getFirst("Origin");
|
||||
if (origin == null || origin.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
String host = URI.create(origin).getHost();
|
||||
return host != null && Set.of("localhost", "127.0.0.1", "::1", "[::1]").contains(host);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, int status, String contentType, String body)
|
||||
throws IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
if (!body.isEmpty()) {
|
||||
exchange.getResponseHeaders().set("Content-Type", contentType + "; charset=utf-8");
|
||||
}
|
||||
exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length);
|
||||
if (bytes.length > 0) {
|
||||
exchange.getResponseBody().write(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package pl.genschu.rexcatalog.mcp;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import pl.genschu.rexcatalog.db.Database;
|
||||
import pl.genschu.rexcatalog.script.ScriptSearch;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Narzędzia MCP nad bazą katalogu. Wszystkie są tylko do odczytu — serwer ma
|
||||
* odpowiadać na pytania o kolekcję, a nie ją modyfikować; indeksowanie i edycja
|
||||
* zostają w CLI, gdzie widać, co się dzieje.
|
||||
*
|
||||
* <p>Odpowiedzi są tekstem sformatowanym dla modelu, nie JSON-em: agent ma je
|
||||
* czytać, a nie parsować, a tekst zużywa mniej kontekstu niż to samo w obiektach.
|
||||
*/
|
||||
public final class Tools {
|
||||
|
||||
private final Database db;
|
||||
private final ScriptSearch search;
|
||||
|
||||
public Tools(Database db, Path dataDir) {
|
||||
this.db = db;
|
||||
this.search = new ScriptSearch(db, dataDir);
|
||||
}
|
||||
|
||||
public interface Handler {
|
||||
String apply(JsonObject arguments) throws Exception;
|
||||
}
|
||||
|
||||
public record Tool(String name, String description, JsonObject inputSchema, Handler handler) {
|
||||
}
|
||||
|
||||
public List<Tool> tools() {
|
||||
List<Tool> tools = new ArrayList<>();
|
||||
|
||||
tools.add(new Tool("list_titles",
|
||||
"Lista tytułów w kolekcji wraz z wydaniami: silnik, data builda, "
|
||||
+ "wersje językowe i liczba posiadanych kopii. Zacznij od tego, "
|
||||
+ "żeby poznać zawartość kolekcji.",
|
||||
schema(Map.of(), List.of()),
|
||||
args -> listTitles()));
|
||||
|
||||
tools.add(new Tool("search_scripts",
|
||||
"Przeszukuje pełnotekstowo odszyfrowane skrypty gier (CNV, DEF, CLASS). "
|
||||
+ "Składnia FTS5; identyfikatory z podkreśleniem są jednym tokenem. "
|
||||
+ "Zwraca ścieżkę, gry, w których plik występuje, fragment i SHA-1.",
|
||||
schema(Map.of(
|
||||
"query", property("string", "Zapytanie, np. CANVAS_OBSERVER albo TYPE=EPISODE"),
|
||||
"limit", property("integer", "Maksymalna liczba trafień (domyślnie 20)")
|
||||
), List.of("query")),
|
||||
args -> searchScripts(string(args, "query"), integer(args, "limit", 20))));
|
||||
|
||||
tools.add(new Tool("get_script",
|
||||
"Zwraca pełną odszyfrowaną treść skryptu. Wskaż go pełnym SHA-1 "
|
||||
+ "albo fragmentem ścieżki; przy niejednoznaczności dostaniesz listę kandydatów.",
|
||||
schema(Map.of("ref", property("string", "SHA-1 bloba albo fragment ścieżki")),
|
||||
List.of("ref")),
|
||||
args -> getScript(string(args, "ref"))));
|
||||
|
||||
tools.add(new Tool("get_edition",
|
||||
"Pełne metadane wydania: tytuł, silnik, daty, języki, kopie na dysku "
|
||||
+ "oraz wszystkie wykryte fakty wraz z dowodem i pewnością.",
|
||||
schema(Map.of("edition_id", property("integer", "Identyfikator wydania z list_titles")),
|
||||
List.of("edition_id")),
|
||||
args -> getEdition(integer(args, "edition_id", -1))));
|
||||
|
||||
tools.add(new Tool("list_files",
|
||||
"Wypisuje pliki należące do wydania. Można zawęzić po formacie "
|
||||
+ "(CNV, IMG, ANN, WAV...) i po fragmencie ścieżki.",
|
||||
schema(Map.of(
|
||||
"edition_id", property("integer", "Identyfikator wydania"),
|
||||
"format", property("string", "Filtr formatu, np. CNV"),
|
||||
"path_contains", property("string", "Fragment ścieżki"),
|
||||
"limit", property("integer", "Maksymalna liczba plików (domyślnie 100)")
|
||||
), List.of("edition_id")),
|
||||
args -> listFiles(integer(args, "edition_id", -1), string(args, "format"),
|
||||
string(args, "path_contains"), integer(args, "limit", 100))));
|
||||
|
||||
tools.add(new Tool("find_file",
|
||||
"Szuka pliku po fragmencie ścieżki w całej kolekcji i pokazuje, w których "
|
||||
+ "grach występuje oraz czy jest bit w bit ten sam. Przydatne do "
|
||||
+ "porównywania wydań.",
|
||||
schema(Map.of(
|
||||
"path_contains", property("string", "Fragment ścieżki, np. arcade.cnv"),
|
||||
"limit", property("integer", "Maksymalna liczba plików (domyślnie 50)")
|
||||
), List.of("path_contains")),
|
||||
args -> findFile(string(args, "path_contains"), integer(args, "limit", 50))));
|
||||
|
||||
tools.add(new Tool("collection_stats",
|
||||
"Statystyki kolekcji: liczba kopii, plików, unikalnych blobów, "
|
||||
+ "rozkład formatów i stopień współdzielenia zasobów między grami.",
|
||||
schema(Map.of(), List.of()),
|
||||
args -> stats()));
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
// ---------- implementacje ----------
|
||||
|
||||
private String listTitles() throws Exception {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||
SELECT e.id AS edition_id, t.name AS title, t.series, e.label,
|
||||
COALESCE(e.release_date_override, (
|
||||
SELECT d.value FROM game_root g
|
||||
JOIN detection d ON d.copy_id = g.copy_id AND d.root_path = g.root_path
|
||||
WHERE g.edition_id = e.id AND d.field = 'release_date' LIMIT 1)) AS release_date,
|
||||
COALESCE(e.engine_override, (
|
||||
SELECT g.engine_detected FROM game_root g
|
||||
WHERE g.edition_id = e.id LIMIT 1)) AS engine,
|
||||
(SELECT GROUP_CONCAT(lang || '/' || role, ', ') FROM edition_language
|
||||
WHERE edition_id = e.id) AS langs,
|
||||
(SELECT COUNT(*) FROM game_root WHERE edition_id = e.id) AS roots
|
||||
FROM edition e LEFT JOIN title t ON t.id = e.title_id
|
||||
ORDER BY t.series IS NULL, t.series, t.name, release_date
|
||||
""");
|
||||
ResultSet rs = st.executeQuery()) {
|
||||
|
||||
while (rs.next()) {
|
||||
sb.append("#").append(rs.getInt("edition_id")).append(" ")
|
||||
.append(rs.getString("title"));
|
||||
if (rs.getString("label") != null) {
|
||||
sb.append(" [").append(rs.getString("label")).append("]");
|
||||
}
|
||||
sb.append("\n silnik: ").append(nvl(rs.getString("engine")))
|
||||
.append(" data: ").append(nvl(rs.getString("release_date")))
|
||||
.append(" kopie: ").append(rs.getInt("roots"))
|
||||
.append(" języki: ").append(nvl(rs.getString("langs")))
|
||||
.append("\n");
|
||||
}
|
||||
}
|
||||
if (sb.isEmpty()) {
|
||||
return "Katalog jest pusty — uruchom ingest, decode, analyze, promote.";
|
||||
}
|
||||
return sb + "\nDaty pochodzą z LASTMODIFYTIME w application.def i przybliżają "
|
||||
+ "datę builda, nie datę wydania.";
|
||||
}
|
||||
|
||||
private String searchScripts(String query, int limit) throws Exception {
|
||||
ScriptSearch.Result result = search.find(query, Math.min(limit, 100));
|
||||
if (result.hits().isEmpty()) {
|
||||
return "Brak trafień dla: " + result.effectiveQuery();
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("Trafienia dla ")
|
||||
.append(result.effectiveQuery())
|
||||
.append(result.quoted() ? " (potraktowane jako fraza)" : "")
|
||||
.append(": ").append(result.hits().size()).append("\n\n");
|
||||
|
||||
for (ScriptSearch.Hit hit : result.hits()) {
|
||||
sb.append(String.join("\n", hit.paths())).append("\n")
|
||||
.append(" gry: ").append(String.join(", ", hit.games())).append("\n")
|
||||
.append(" ").append(hit.snippet().replaceAll("\\s+", " ").trim()).append("\n")
|
||||
.append(" sha1: ").append(hit.sha1()).append("\n\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String getScript(String ref) throws Exception {
|
||||
List<String> candidates = search.resolve(ref);
|
||||
if (candidates.isEmpty()) {
|
||||
return "Nic nie pasuje do: " + ref;
|
||||
}
|
||||
if (candidates.size() > 1) {
|
||||
StringBuilder sb = new StringBuilder("Niejednoznaczne — "
|
||||
+ candidates.size() + " różnych wersji tego pliku:\n");
|
||||
for (String sha1 : candidates) {
|
||||
sb.append(" ").append(sha1).append(" ")
|
||||
.append(String.join(", ", search.gamesFor(sha1))).append("\n");
|
||||
}
|
||||
return sb + "\nPowtórz z wybranym SHA-1.";
|
||||
}
|
||||
String sha1 = candidates.get(0);
|
||||
String body = search.body(sha1);
|
||||
if (body == null) {
|
||||
return "Ten plik nie jest odszyfrowany (nie jest skryptem albo brak go w cache'u).";
|
||||
}
|
||||
return "# " + String.join(", ", search.pathsFor(sha1))
|
||||
+ "\n# gry: " + String.join(", ", search.gamesFor(sha1))
|
||||
+ "\n# sha1: " + sha1 + "\n\n" + body;
|
||||
}
|
||||
|
||||
private String getEdition(int editionId) throws Exception {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||
SELECT e.id, t.name AS title, t.series, t.publisher, e.label, e.fingerprint,
|
||||
e.dll_sha1, e.distributor, e.notes,
|
||||
e.engine_override, e.engine_version_override,
|
||||
e.compiler_override, e.release_date_override
|
||||
FROM edition e LEFT JOIN title t ON t.id = e.title_id WHERE e.id = ?
|
||||
""")) {
|
||||
st.setInt(1, editionId);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
if (!rs.next()) {
|
||||
return "Nie ma wydania o id " + editionId + ".";
|
||||
}
|
||||
sb.append("Tytuł: ").append(rs.getString("title")).append("\n");
|
||||
append(sb, "Seria", rs.getString("series"));
|
||||
append(sb, "Wydawca", rs.getString("publisher"));
|
||||
append(sb, "Etykieta wydania", rs.getString("label"));
|
||||
append(sb, "Dystrybutor", rs.getString("distributor"));
|
||||
append(sb, "Notatki", rs.getString("notes"));
|
||||
append(sb, "Hash biblioteki silnika", rs.getString("dll_sha1"));
|
||||
append(sb, "Override silnika", rs.getString("engine_override"));
|
||||
append(sb, "Override wersji silnika", rs.getString("engine_version_override"));
|
||||
append(sb, "Override kompilera", rs.getString("compiler_override"));
|
||||
append(sb, "Override daty", rs.getString("release_date_override"));
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\nWersje językowe:\n");
|
||||
boolean any = false;
|
||||
try (PreparedStatement st = db.connection().prepareStatement(
|
||||
"SELECT lang, role FROM edition_language WHERE edition_id = ? ORDER BY lang, role")) {
|
||||
st.setInt(1, editionId);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
any = true;
|
||||
sb.append(" ").append(rs.getString("lang"))
|
||||
.append(" (").append(rs.getString("role")).append(")\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!any) {
|
||||
sb.append(" (nie uzupełniono)\n");
|
||||
}
|
||||
|
||||
sb.append("\nKopie na dysku:\n");
|
||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||
SELECT c.display_name, c.path, c.container, c.fs_type, c.status, c.file_count,
|
||||
c.size, c.sha256, c.source_kind, c.source_url, g.root_path
|
||||
FROM game_root g JOIN copy c ON c.id = g.copy_id
|
||||
WHERE g.edition_id = ? ORDER BY c.display_name
|
||||
""")) {
|
||||
st.setInt(1, editionId);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
sb.append(" ").append(rs.getString("display_name"));
|
||||
String root = rs.getString("root_path");
|
||||
if (root != null && !root.isEmpty()) {
|
||||
sb.append(" (korzeń ").append(root).append("/)");
|
||||
}
|
||||
sb.append("\n ").append(rs.getString("path"))
|
||||
.append("\n ").append(rs.getString("container"))
|
||||
.append(" / ").append(nvl(rs.getString("fs_type")))
|
||||
.append(", ").append(rs.getInt("file_count")).append(" plików, ")
|
||||
.append(rs.getLong("size") / 1024 / 1024).append(" MB")
|
||||
.append(", status ").append(rs.getString("status"))
|
||||
.append("\n źródło: ").append(nvl(rs.getString("source_kind")));
|
||||
if (rs.getString("source_url") != null) {
|
||||
sb.append(" — ").append(rs.getString("source_url"));
|
||||
}
|
||||
sb.append("\n sha256: ").append(nvl(rs.getString("sha256"))).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\nWykryte fakty (z dowodem):\n");
|
||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||
SELECT DISTINCT d.field, d.value, d.confidence, d.evidence, d.detector
|
||||
FROM game_root g
|
||||
JOIN detection d ON d.copy_id = g.copy_id AND d.root_path = g.root_path
|
||||
WHERE g.edition_id = ? ORDER BY d.detector, d.field
|
||||
""")) {
|
||||
st.setInt(1, editionId);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
sb.append(String.format(" %-16s %-30s pewność %.2f%n",
|
||||
rs.getString("field"), truncate(rs.getString("value"), 30),
|
||||
rs.getDouble("confidence")));
|
||||
sb.append(" dowód: ").append(truncate(rs.getString("evidence"), 100))
|
||||
.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String listFiles(int editionId, String format, String pathContains, int limit)
|
||||
throws Exception {
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT DISTINCT COALESCE(f.path_raw, f.path) AS path, b.format, b.size,
|
||||
b.encrypted, f.blob_sha1
|
||||
FROM game_root g
|
||||
JOIN file f ON f.copy_id = g.copy_id
|
||||
JOIN blob b ON b.sha1 = f.blob_sha1
|
||||
WHERE g.edition_id = ?
|
||||
""");
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(editionId);
|
||||
if (format != null && !format.isBlank()) {
|
||||
sql.append(" AND b.format = ?\n");
|
||||
params.add(format.toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
if (pathContains != null && !pathContains.isBlank()) {
|
||||
sql.append(" AND f.path LIKE ?\n");
|
||||
params.add("%" + pathContains.toLowerCase(java.util.Locale.ROOT) + "%");
|
||||
}
|
||||
sql.append("ORDER BY path LIMIT ?");
|
||||
params.add(Math.min(limit, 1000));
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int count = 0;
|
||||
try (PreparedStatement st = db.connection().prepareStatement(sql.toString())) {
|
||||
for (int i = 0; i < params.size(); i++) {
|
||||
st.setObject(i + 1, params.get(i));
|
||||
}
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
count++;
|
||||
sb.append(String.format("%-60s %-8s %8d B%s %s%n",
|
||||
truncate(rs.getString("path"), 60), nvl(rs.getString("format")),
|
||||
rs.getLong("size"), rs.getInt("encrypted") == 1 ? " [szyfr]" : "",
|
||||
rs.getString("blob_sha1")));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count == 0) {
|
||||
return "Brak plików spełniających kryteria.";
|
||||
}
|
||||
return "Plików: " + count + (count >= Math.min(limit, 1000)
|
||||
? " (obcięte do limitu — zawęź filtr)" : "") + "\n\n" + sb;
|
||||
}
|
||||
|
||||
private String findFile(String pathContains, int limit) throws Exception {
|
||||
Map<String, List<String>> byPath = new LinkedHashMap<>();
|
||||
Map<String, List<String>> hashes = new LinkedHashMap<>();
|
||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||
SELECT f.path AS path, f.blob_sha1, c.display_name
|
||||
FROM file f JOIN copy c ON c.id = f.copy_id
|
||||
WHERE f.path LIKE ? ORDER BY f.path, c.display_name LIMIT ?
|
||||
""")) {
|
||||
st.setString(1, "%" + pathContains.toLowerCase(java.util.Locale.ROOT) + "%");
|
||||
st.setInt(2, Math.min(limit, 500) * 8);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String key = rs.getString("path");
|
||||
byPath.computeIfAbsent(key, k -> new ArrayList<>())
|
||||
.add(rs.getString("display_name") + " ["
|
||||
+ rs.getString("blob_sha1").substring(0, 8) + "]");
|
||||
hashes.computeIfAbsent(key, k -> new ArrayList<>())
|
||||
.add(rs.getString("blob_sha1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (byPath.isEmpty()) {
|
||||
return "Nic nie pasuje do: " + pathContains;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int shown = 0;
|
||||
for (Map.Entry<String, List<String>> entry : byPath.entrySet()) {
|
||||
if (shown++ >= limit) {
|
||||
sb.append("\n(obcięte — zawęź fragment ścieżki)\n");
|
||||
break;
|
||||
}
|
||||
long distinct = hashes.get(entry.getKey()).stream().distinct().count();
|
||||
sb.append(entry.getKey()).append("\n ")
|
||||
.append(String.join("\n ", entry.getValue())).append("\n")
|
||||
.append(distinct == 1
|
||||
? " → identyczna treść we wszystkich\n"
|
||||
: " → " + distinct + " różne wersje treści\n")
|
||||
.append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String stats() throws Exception {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("kopie: ").append(scalar("SELECT COUNT(*) FROM copy")).append("\n")
|
||||
.append("tytuły: ").append(scalar("SELECT COUNT(*) FROM title")).append("\n")
|
||||
.append("wydania: ").append(scalar("SELECT COUNT(*) FROM edition")).append("\n")
|
||||
.append("pliki (wystąpienia): ").append(scalar("SELECT COUNT(*) FROM file")).append("\n")
|
||||
.append("bloby (unikalne): ").append(scalar("SELECT COUNT(*) FROM blob")).append("\n")
|
||||
.append("bloby w >1 kopii: ").append(scalar(
|
||||
"SELECT COUNT(*) FROM (SELECT blob_sha1 FROM file GROUP BY blob_sha1 "
|
||||
+ "HAVING COUNT(DISTINCT copy_id) > 1)")).append("\n")
|
||||
.append("skrypty w indeksie: ").append(scalar("SELECT COUNT(*) FROM script_fts"))
|
||||
.append("\n\nFormaty:\n");
|
||||
|
||||
try (PreparedStatement st = db.connection().prepareStatement(
|
||||
"SELECT format, COUNT(*) n, SUM(size) bytes FROM blob GROUP BY format "
|
||||
+ "ORDER BY n DESC LIMIT 20");
|
||||
ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
sb.append(String.format(" %-10s %6d %8.1f MB%n", nvl(rs.getString("format")),
|
||||
rs.getInt("n"), rs.getLong("bytes") / 1024.0 / 1024.0));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ---------- pomocnicze ----------
|
||||
|
||||
private long scalar(String sql) throws Exception {
|
||||
try (PreparedStatement st = db.connection().prepareStatement(sql);
|
||||
ResultSet rs = st.executeQuery()) {
|
||||
return rs.next() ? rs.getLong(1) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static void append(StringBuilder sb, String label, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
sb.append(label).append(": ").append(value).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject property(String type, String description) {
|
||||
JsonObject property = new JsonObject();
|
||||
property.addProperty("type", type);
|
||||
property.addProperty("description", description);
|
||||
return property;
|
||||
}
|
||||
|
||||
private static JsonObject schema(Map<String, JsonObject> properties, List<String> required) {
|
||||
JsonObject schema = new JsonObject();
|
||||
schema.addProperty("type", "object");
|
||||
JsonObject props = new JsonObject();
|
||||
properties.forEach(props::add);
|
||||
schema.add("properties", props);
|
||||
JsonArray requiredArray = new JsonArray();
|
||||
required.forEach(requiredArray::add);
|
||||
schema.add("required", requiredArray);
|
||||
return schema;
|
||||
}
|
||||
|
||||
private static String string(JsonObject args, String name) {
|
||||
return args != null && args.has(name) && !args.get(name).isJsonNull()
|
||||
? args.get(name).getAsString() : null;
|
||||
}
|
||||
|
||||
private static int integer(JsonObject args, String name, int fallback) {
|
||||
return args != null && args.has(name) && !args.get(name).isJsonNull()
|
||||
? args.get(name).getAsInt() : fallback;
|
||||
}
|
||||
|
||||
private static String nvl(String value) {
|
||||
return value == null ? "—" : value;
|
||||
}
|
||||
|
||||
private static String truncate(String value, int max) {
|
||||
if (value == null) {
|
||||
return "—";
|
||||
}
|
||||
return value.length() <= max ? value : value.substring(0, max - 1) + "…";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user