diff --git a/README.md b/README.md index 5daee11..8a83905 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,13 @@ rex-catalog cat arcade.cnv # odszyfrowany skrypt na wyjście rex-catalog titles # lista wydań z metadanymi ``` -Front ma cztery widoki: **Katalog** (tytuły, wydania, kopie, pliki), **Skrypty** -(wyszukiwanie pełnotekstowe i podgląd z listą użytych zasobów), **Dźwięk** (wyszukiwanie -nagrań, odtwarzacz, transkrypcja) i **Formaty** (rozkład zawartości kolekcji). +Front otwiera się na widoku **Zasoby** — paginowanej galerii obrazów, animacji i nagrań, +filtrowanej po grze, rodzaju i nazwie. Zaznaczenie otwiera inspektor z pełnym podglądem, +parametrami i miejscami użycia. Animacje można odtwarzać w tempie zapisanym w ANN — jako +wszystkie obrazy albo konkretne nazwane zdarzenie, np. `BEZRUCH` czy `GADA_1`, razem z jego +właściwą kolejnością klatek i zakresem pętli. Dalej są **Skrypty** (wyszukiwanie +pełnotekstowe i podgląd z listą użytych zasobów), **Dźwięk** (wyszukiwanie nagrań, +odtwarzacz, transkrypcja), **Kolekcja** (tytuły, wydania, kopie, pliki) i **Statystyki**. Pod `/mcp` stoi serwer MCP (Streamable HTTP, tylko do odczytu) z dziewięcioma narzędziami — od `list_titles` i `search_scripts` po `get_script_assets`, które pokazuje, co dany skrypt diff --git a/src/main/java/pl/genschu/rexcatalog/db/Database.java b/src/main/java/pl/genschu/rexcatalog/db/Database.java index f8686b0..9d1bde3 100644 --- a/src/main/java/pl/genschu/rexcatalog/db/Database.java +++ b/src/main/java/pl/genschu/rexcatalog/db/Database.java @@ -112,6 +112,11 @@ public final class Database implements AutoCloseable { addColumnIfMissing("media", "frames", "INTEGER"); addColumnIfMissing("media", "fps", "INTEGER"); addColumnIfMissing("media", "author", "TEXT"); + // Starszy indeks zdarzeń przechowywał tylko ich długość. Do wiernego + // podglądu potrzebna jest właściwa kolejność obrazów i reguły pętli. + addColumnIfMissing("anim_event", "frames", "TEXT"); + addColumnIfMissing("anim_event", "repeat_count", "INTEGER"); + addColumnIfMissing("anim_event", "flags", "INTEGER"); addBasenameIndex(); } @@ -412,6 +417,9 @@ public final class Database implements AutoCloseable { frames_count INTEGER, loop_start INTEGER, loop_end INTEGER, + frames TEXT, + repeat_count INTEGER, + flags INTEGER, PRIMARY KEY (ann_sha1, name) ) """ diff --git a/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java b/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java index 085f508..18089a7 100644 --- a/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java +++ b/src/main/java/pl/genschu/rexcatalog/media/GraphicsIndexer.java @@ -36,7 +36,8 @@ import java.util.Set; */ public final class GraphicsIndexer { - public static final String VERSION = "grafika-1"; + public static final String IMAGE_VERSION = "grafika-1"; + public static final String ANIMATION_VERSION = "grafika-2"; private static final String KIND = "preview"; @@ -93,11 +94,13 @@ public final class GraphicsIndexer { PreparedStatement eventDelete = conn.prepareStatement( "DELETE FROM anim_event WHERE ann_sha1 = ?"); PreparedStatement eventInsert = conn.prepareStatement(""" - INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end) - VALUES (?, ?, ?, ?, ?) + INSERT INTO anim_event (ann_sha1, name, frames_count, loop_start, loop_end, + frames, repeat_count, flags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(ann_sha1, name) DO UPDATE SET frames_count = excluded.frames_count, loop_start = excluded.loop_start, - loop_end = excluded.loop_end + loop_end = excluded.loop_end, frames = excluded.frames, + repeat_count = excluded.repeat_count, flags = excluded.flags """)) { for (Map.Entry> group : byContainer.entrySet()) { @@ -152,11 +155,11 @@ public final class GraphicsIndexer { private void indexImage(String sha1, byte[] raw, PreparedStatement mediaStmt, PreparedStatement artifactStmt) throws Exception { PikGraphics.Picture picture = PikGraphics.readImage(raw); - writePreview(sha1, picture.image(), artifactStmt); + writePreview(sha1, picture.image(), artifactStmt, IMAGE_VERSION); write(mediaStmt, sha1, "image", "pik" + picture.colorDepth(), picture.width(), picture.height(), 1, 0, null, - "kompresja " + picture.compression()); + "kompresja " + picture.compression(), IMAGE_VERSION); } private int indexAnimation(String sha1, byte[] raw, PreparedStatement mediaStmt, @@ -164,12 +167,14 @@ public final class GraphicsIndexer { PreparedStatement eventInsert) throws Exception { PikGraphics.Animation animation = PikGraphics.readAnimation(raw); if (!animation.frames().isEmpty()) { - writePreview(sha1, animation.frames().get(0).image(), artifactStmt); + writePreview(sha1, animation.frames().get(0).image(), artifactStmt, + ANIMATION_VERSION); } write(mediaStmt, sha1, "anim", "nvp" + animation.colorDepth(), animation.maxWidth(), animation.maxHeight(), animation.frames().size(), animation.fps(), - blankToNull(animation.author()), blankToNull(animation.description())); + blankToNull(animation.author()), blankToNull(animation.description()), + ANIMATION_VERSION); eventDelete.setString(1, sha1); eventDelete.executeUpdate(); @@ -179,13 +184,17 @@ public final class GraphicsIndexer { eventInsert.setInt(3, event.framesCount()); eventInsert.setInt(4, event.loopStart()); eventInsert.setInt(5, event.loopEnd()); + eventInsert.setString(6, event.frames().stream() + .map(String::valueOf).collect(java.util.stream.Collectors.joining(","))); + eventInsert.setInt(7, event.repeatCount()); + eventInsert.setInt(8, event.flags()); eventInsert.executeUpdate(); } return animation.frames().size(); } - private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt) - throws Exception { + private void writePreview(String sha1, BufferedImage image, PreparedStatement artifactStmt, + String version) throws Exception { String relative = cachePath(sha1); Path target = dataDir.resolve(relative); Files.createDirectories(target.getParent()); @@ -194,7 +203,7 @@ public final class GraphicsIndexer { artifactStmt.setString(1, sha1); artifactStmt.setString(2, KIND); artifactStmt.setString(3, relative); - artifactStmt.setString(4, VERSION); + artifactStmt.setString(4, version); artifactStmt.setString(5, Instant.now().toString()); artifactStmt.executeUpdate(); } @@ -223,12 +232,13 @@ public final class GraphicsIndexer { throws Exception { String reason = cause.getMessage() == null ? cause.toString() : cause.getMessage(); write(mediaStmt, pending.sha1(), "ANN".equals(pending.format()) ? "anim" : "image", - null, 0, 0, 0, 0, null, "nieczytelny: " + reason); + null, 0, 0, 0, 0, null, "nieczytelny: " + reason, + "ANN".equals(pending.format()) ? ANIMATION_VERSION : IMAGE_VERSION); } private void write(PreparedStatement stmt, String sha1, String kind, String codec, - int width, int height, int frames, int fps, String author, String note) - throws Exception { + int width, int height, int frames, int fps, String author, String note, + String version) throws Exception { stmt.setString(1, sha1); stmt.setString(2, kind); stmt.setString(3, codec); @@ -238,7 +248,7 @@ public final class GraphicsIndexer { stmt.setInt(7, fps); stmt.setString(8, author); stmt.setString(9, note); - stmt.setString(10, VERSION); + stmt.setString(10, version); stmt.setString(11, Instant.now().toString()); stmt.executeUpdate(); } @@ -262,13 +272,15 @@ public final class GraphicsIndexer { LEFT JOIN media m ON m.sha1 = b.sha1 WHERE c.status <> 'unreadable' AND b.format IN ('IMG', 'ANN') """ + (force ? "" : """ - AND (m.sha1 IS NULL OR m.tool_version <> ?) + AND (m.sha1 IS NULL OR m.tool_version <> + CASE b.format WHEN 'ANN' THEN ? ELSE ? END) """) + "ORDER BY c.id"; Map> byContainer = new LinkedHashMap<>(); try (PreparedStatement st = db.connection().prepareStatement(sql)) { if (!force) { - st.setString(1, VERSION); + st.setString(1, ANIMATION_VERSION); + st.setString(2, IMAGE_VERSION); } try (ResultSet rs = st.executeQuery()) { while (rs.next()) { diff --git a/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java b/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java index 433aeb7..71306e2 100644 --- a/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java +++ b/src/main/java/pl/genschu/rexcatalog/media/PikGraphics.java @@ -49,7 +49,7 @@ public final class PikGraphics { /** Zdarzenie animacji: nazwana sekwencja odsyłająca do numerów klatek. */ public record AnimEvent(String name, int framesCount, int loopStart, int loopEnd, - List frames) { + int repeatCount, int flags, List frames) { } public record Animation(int colorDepth, int fps, int opacity, int maxWidth, int maxHeight, @@ -144,9 +144,10 @@ public final class PikGraphics { reader.skip(4); int loopStart = reader.u16(); int loopEnd = reader.u16(); - reader.skip(4); // liczba powtórzeń i licznik + int repeatCount = reader.u16(); + reader.skip(2); // bieżący licznik powtórzeń reader.skip(4); - reader.skip(4); // flagi + int flags = reader.i32(); reader.u8(); // krycie reader.skip(12); @@ -157,7 +158,7 @@ public final class PikGraphics { for (int i = 0; i < framesCount; i++) { skipFrameData(reader); } - return new AnimEvent(name, framesCount, loopStart, loopEnd, frames); + return new AnimEvent(name, framesCount, loopStart, loopEnd, repeatCount, flags, frames); } /** Dane klatki opisują ruch i dźwięk — do podglądu nam niepotrzebne, ale trzeba je przejść. */ diff --git a/src/main/java/pl/genschu/rexcatalog/web/WebServer.java b/src/main/java/pl/genschu/rexcatalog/web/WebServer.java index faf15e1..b5e5a1f 100644 --- a/src/main/java/pl/genschu/rexcatalog/web/WebServer.java +++ b/src/main/java/pl/genschu/rexcatalog/web/WebServer.java @@ -37,6 +37,9 @@ public final class WebServer { private final MediaIndex media; private final Transcriber transcriber; private final Object lock; + private final Object animationCacheLock = new Object(); + private String cachedAnimationSha1; + private PikGraphics.Animation cachedAnimation; public WebServer(Database db, Path dataDir, Object lock) { this.db = db; @@ -139,6 +142,9 @@ public final class WebServer { if (path.equals("/titles")) { return titles(); } + if (path.equals("/assets")) { + return assets(query); + } if (path.equals("/search")) { return searchScripts(query.getOrDefault("q", ""), parseInt(query.get("limit"), 30)); @@ -250,19 +256,17 @@ public final class WebServer { private void serveRender(HttpExchange exchange, String sha1, int frame) throws IOException { byte[] png; try { - MediaIndex.Clip clip; String kind; synchronized (lock) { - clip = media.read(sha1); kind = mediaKind(sha1); } - if (clip == null) { - respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8)); - return; - } java.awt.image.BufferedImage image; if ("anim".equals(kind)) { - PikGraphics.Animation animation = PikGraphics.readAnimation(clip.bytes()); + PikGraphics.Animation animation = animation(sha1); + if (animation == null) { + respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8)); + return; + } if (animation.frames().isEmpty()) { respond(exchange, 404, "text/plain", "Animacja bez klatek".getBytes(UTF8)); return; @@ -270,6 +274,14 @@ public final class WebServer { int index = Math.max(0, Math.min(frame, animation.frames().size() - 1)); image = animation.frames().get(index).image(); } else { + MediaIndex.Clip clip; + synchronized (lock) { + clip = media.read(sha1); + } + if (clip == null) { + respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8)); + return; + } image = PikGraphics.readImage(clip.bytes()).image(); } java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); @@ -286,6 +298,31 @@ public final class WebServer { exchange.getResponseBody().write(png); } + /** + * Trzyma ostatnio oglądane ANN w pamięci. Odtwarzacz prosi o kilkanaście + * klatek na sekundę, a ponowne rozpakowywanie całego pliku dla każdej z nich + * byłoby znacznie droższe niż sam zapis PNG. Jedna pozycja ogranicza koszt + * pamięci także dla animacji z dużymi obrazami. + */ + private PikGraphics.Animation animation(String sha1) throws Exception { + synchronized (animationCacheLock) { + if (sha1.equals(cachedAnimationSha1)) { + return cachedAnimation; + } + MediaIndex.Clip clip; + synchronized (lock) { + clip = media.read(sha1); + } + if (clip == null) { + return null; + } + PikGraphics.Animation decoded = PikGraphics.readAnimation(clip.bytes()); + cachedAnimationSha1 = sha1; + cachedAnimation = decoded; + return decoded; + } + } + private String previewPath(String sha1) throws Exception { try (PreparedStatement st = db.connection().prepareStatement( "SELECT path FROM artifact WHERE blob_sha1 = ? AND kind = 'preview'")) { @@ -555,6 +592,146 @@ public final class WebServer { return out; } + /** + * Paginowana lista zasobów do głównej galerii. Jedna treść może występować + * na kilku płytach, dlatego karta jest kluczowana SHA-1, a liczba miejsc + * pozostaje jawna. Wpisy archiwów SND są włączone do tej samej listy co + * zwykłe pliki — z punktu widzenia przeglądającego oba są nagraniami. + */ + private JsonObject assets(Map query) throws Exception { + Integer edition = query.containsKey("edition") + ? parseInt(query.get("edition"), -1) : null; + if (edition != null && edition < 0) { + edition = null; + } + String kind = query.getOrDefault("kind", "visual").toLowerCase(Locale.ROOT); + if (!List.of("visual", "image", "anim", "audio", "all").contains(kind)) { + kind = "visual"; + } + String text = query.getOrDefault("q", "").trim().toLowerCase(Locale.ROOT); + int limit = Math.max(1, Math.min(parseInt(query.get("limit"), 80), 200)); + int offset = Math.max(0, parseInt(query.get("offset"), 0)); + String sort = query.getOrDefault("sort", "name"); + + String places = """ + WITH places AS ( + SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.width, m.height, + m.frames, m.fps, COALESCE(f.path_raw, f.path) AS path, + c.display_name AS copy, e.id AS edition_id, t.name AS title, + 0 AS in_archive + FROM media m + JOIN file f ON f.blob_sha1 = m.sha1 + JOIN copy c ON c.id = f.copy_id + LEFT JOIN game_root g ON g.copy_id = c.id + AND (g.root_path = '' OR f.path = g.root_path + OR f.path LIKE g.root_path || '/%') + LEFT JOIN edition e ON e.id = g.edition_id + LEFT JOIN title t ON t.id = e.title_id + UNION ALL + SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.width, m.height, + m.frames, m.fps, COALESCE(ae.name_raw, ae.name) AS path, + c.display_name AS copy, e.id AS edition_id, t.name AS title, + 1 AS in_archive + FROM media m + JOIN archive_entry ae ON ae.sha1 = m.sha1 + JOIN file f ON f.blob_sha1 = ae.container_sha1 + JOIN copy c ON c.id = f.copy_id + LEFT JOIN game_root g ON g.copy_id = c.id + AND (g.root_path = '' OR f.path = g.root_path + OR f.path LIKE g.root_path || '/%') + LEFT JOIN edition e ON e.id = g.edition_id + LEFT JOIN title t ON t.id = e.title_id + ) + """; + + StringBuilder where = new StringBuilder(" WHERE 1 = 1\n"); + List params = new ArrayList<>(); + switch (kind) { + case "image", "anim", "audio" -> { + where.append(" AND kind = ?\n"); + params.add(kind); + } + case "visual" -> where.append(" AND kind IN ('image', 'anim')\n"); + default -> { } + } + if (edition != null) { + where.append(" AND edition_id = ?\n"); + params.add(edition); + } + if (!text.isBlank()) { + where.append(" AND (lower(path) LIKE ? OR lower(COALESCE(title, '')) LIKE ? " + + "OR lower(COALESCE(copy, '')) LIKE ? OR lower(COALESCE(codec, '')) LIKE ?)\n"); + String like = "%" + text + "%"; + params.add(like); + params.add(like); + params.add(like); + params.add(like); + } + + long total; + try (PreparedStatement st = db.connection().prepareStatement( + places + "SELECT COUNT(DISTINCT sha1) FROM places" + where)) { + bind(st, params); + try (ResultSet rs = st.executeQuery()) { + total = rs.next() ? rs.getLong(1) : 0; + } + } + + String order = switch (sort) { + case "game" -> "lower(COALESCE(MIN(title), '')), lower(MIN(path)), sha1"; + case "largest" -> "COALESCE(MAX(width), 0) * COALESCE(MAX(height), 0) " + + "DESC, lower(MIN(path)), sha1"; + default -> "lower(MIN(path)), sha1"; + }; + String sql = places + """ + SELECT sha1, MIN(kind) AS kind, MIN(codec) AS codec, + MAX(duration_ms) AS duration_ms, MAX(width) AS width, + MAX(height) AS height, MAX(frames) AS frames, MAX(fps) AS fps, + MIN(path) AS path, MIN(copy) AS copy, MIN(edition_id) AS edition_id, + MIN(title) AS title, MAX(in_archive) AS in_archive, + COUNT(*) AS places_count, COUNT(DISTINCT edition_id) AS games_count + FROM places + """ + where + " GROUP BY sha1 ORDER BY " + order + " LIMIT ? OFFSET ?"; + + JsonArray items = new JsonArray(); + try (PreparedStatement st = db.connection().prepareStatement(sql)) { + List pageParams = new ArrayList<>(params); + pageParams.add(limit); + pageParams.add(offset); + bind(st, pageParams); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + JsonObject row = new JsonObject(); + row.addProperty("sha1", rs.getString("sha1")); + row.addProperty("kind", rs.getString("kind")); + row.addProperty("codec", rs.getString("codec")); + row.addProperty("duration_ms", rs.getInt("duration_ms")); + row.addProperty("width", rs.getInt("width")); + row.addProperty("height", rs.getInt("height")); + row.addProperty("frames", rs.getInt("frames")); + row.addProperty("fps", rs.getInt("fps")); + row.addProperty("path", rs.getString("path")); + row.addProperty("name", baseName(rs.getString("path"))); + row.addProperty("copy", rs.getString("copy")); + row.addProperty("edition_id", rs.getInt("edition_id")); + row.addProperty("title", rs.getString("title")); + row.addProperty("in_archive", rs.getInt("in_archive") == 1); + row.addProperty("places_count", rs.getInt("places_count")); + row.addProperty("games_count", rs.getInt("games_count")); + items.add(row); + } + } + } + + JsonObject out = new JsonObject(); + out.addProperty("total", total); + out.addProperty("offset", offset); + out.addProperty("limit", limit); + out.addProperty("has_more", offset + items.size() < total); + out.add("items", items); + return out; + } + private JsonObject searchScripts(String query, int limit) throws Exception { JsonObject out = new JsonObject(); if (query.isBlank()) { @@ -871,7 +1048,7 @@ public final class WebServer { JsonArray events = new JsonArray(); try (PreparedStatement st = db.connection().prepareStatement(""" - SELECT name, frames_count, loop_start, loop_end + SELECT name, frames_count, loop_start, loop_end, frames, repeat_count, flags FROM anim_event WHERE ann_sha1 = ? ORDER BY rowid """)) { st.setString(1, sha1); @@ -882,6 +1059,16 @@ public final class WebServer { row.addProperty("frames_count", rs.getInt("frames_count")); row.addProperty("loop_start", rs.getInt("loop_start")); row.addProperty("loop_end", rs.getInt("loop_end")); + row.addProperty("repeat_count", rs.getInt("repeat_count")); + row.addProperty("flags", rs.getInt("flags")); + JsonArray frames = new JsonArray(); + String encoded = rs.getString("frames"); + if (encoded != null && !encoded.isBlank()) { + for (String value : encoded.split(",")) { + frames.add(parseInt(value, 0)); + } + } + row.add("frames", frames); events.add(row); } } @@ -900,6 +1087,21 @@ public final class WebServer { return array; } + private static void bind(PreparedStatement statement, List values) + throws Exception { + for (int i = 0; i < values.size(); i++) { + statement.setObject(i + 1, values.get(i)); + } + } + + private static String baseName(String path) { + if (path == null) { + return null; + } + int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return slash >= 0 ? path.substring(slash + 1) : path; + } + private long scalar(String sql) throws Exception { try (PreparedStatement st = db.connection().prepareStatement(sql); ResultSet rs = st.executeQuery()) { diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index ec7845f..f30b146 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -6,13 +6,16 @@ rex-catalog @@ -126,15 +256,66 @@ button.akcja:disabled { opacity: .45; cursor: default; }

rex·catalog

wczytuję…
-
+
+ + +
+
+
+

Wszystkie zasoby

+ wczytuję… +
+
+
+ +
+ +
+
+
+
+
+
+
+ + +
+ +