diff --git a/Dockerfile b/Dockerfile index da639e3..4a967a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,10 @@ WORKDIR /app # obrazy płyt montowane z zewnątrz, tylko do odczytu; katalog danych to wolumen VOLUME ["/data"] +# Transkrypcji mowy w obrazie nie ma i nie będzie: whisper.cpp plus model to +# kilka gigabajtów, a katalog jest w pełni użyteczny bez nich — mówcę i kontekst +# kwestii bierzemy z dialogi.dta. Front powie wprost, że narzędzia brakuje. +# Kto chce transkrybować, robi to na hoście: rex-catalog transcribe. # file.encoding ustawia entrypoint przez JAVA_OPTS; JAVA_TOOL_OPTIONS robiłoby # to samo, ale JVM wypisuje wtedy "Picked up..." przy każdym uruchomieniu ENV CATALOG_DATA=/data diff --git a/src/main/java/pl/genschu/rexcatalog/Main.java b/src/main/java/pl/genschu/rexcatalog/Main.java index 165e9d4..a414433 100644 --- a/src/main/java/pl/genschu/rexcatalog/Main.java +++ b/src/main/java/pl/genschu/rexcatalog/Main.java @@ -4,7 +4,12 @@ import pl.genschu.rexcatalog.catalog.Editor; import pl.genschu.rexcatalog.catalog.Promoter; import pl.genschu.rexcatalog.db.Database; import pl.genschu.rexcatalog.ingest.Ingestor; +import pl.genschu.rexcatalog.ingest.LanguageDetector; import pl.genschu.rexcatalog.ingest.MetadataDetector; +import pl.genschu.rexcatalog.media.AudioIndexer; +import pl.genschu.rexcatalog.media.DialogueIndexer; +import pl.genschu.rexcatalog.script.RefIndexer; +import pl.genschu.rexcatalog.transcribe.Transcriber; import pl.genschu.rexcatalog.mcp.McpServer; import pl.genschu.rexcatalog.web.WebServer; import pl.genschu.rexcatalog.script.ScriptDecoder; @@ -45,6 +50,8 @@ public final class Main { case "list" -> list(dataDir); case "stats" -> stats(dataDir); case "decode" -> decode(dataDir, args); + case "probe" -> probe(dataDir, args); + case "transcribe" -> transcribe(dataDir, args); case "analyze" -> analyze(dataDir); case "promote" -> promote(dataDir); case "titles" -> titles(dataDir); @@ -145,7 +152,102 @@ public final class Main { } } + /** + * Czyta nagłówki zasobów i buduje powiązania między nimi: fakty o dźwięku, + * zawartość archiwów, wiersze dialogów i odwołania ze skryptów. + */ + private static void probe(Path dataDir, String[] args) throws Exception { + boolean force = args.length > 1 && args[1].equals("--force"); + Headless.boot(); + + try (Database db = Database.open(dataDir)) { + AudioIndexer audio = new AudioIndexer(db); + DialogueIndexer dialogue = new DialogueIndexer(db); + RefIndexer refs = new RefIndexer(db, dataDir); + + if (!force && audio.pendingCount() == 0 && dialogue.pendingCount() == 0 + && refs.pendingCount() == 0) { + System.out.println("Nic do przerobienia — wszystko już zbadane."); + return; + } + + long started = System.currentTimeMillis(); + System.out.println("Czytam nagłówki dźwięku i archiwa..."); + AudioIndexer.Stats audioStats = audio.run(force); + System.out.printf(" %d plików dźwiękowych, %d archiwów (%d wpisów), błędów: %d%n", + audioStats.audio(), audioStats.archives(), audioStats.entries(), + audioStats.failed()); + + System.out.println("Czytam tabele dialogów..."); + DialogueIndexer.Stats dialogueStats = dialogue.run(force); + System.out.printf(" %d plików, %d wierszy, błędów: %d%n", + dialogueStats.files(), dialogueStats.lines(), dialogueStats.failed()); + + System.out.println("Zbieram odwołania ze skryptów..."); + RefIndexer.Stats refStats = refs.run(force); + System.out.printf(" %d skryptów, %d odwołań, błędów: %d%n", + refStats.scripts(), refStats.refs(), refStats.failed()); + + System.out.printf("%nGotowe (%ds).%n", (System.currentTimeMillis() - started) / 1000); + } + } + + /** + * Transkrypcja mowy. Osobna komenda, nie część {@code probe} — trwa godzinami + * i wymaga narzędzi spoza tego repozytorium, więc uruchamia się ją świadomie. + */ + private static void transcribe(Path dataDir, String[] args) throws Exception { + Transcriber.Tool tool = Transcriber.detect(); + if (!tool.ready()) { + System.err.println(tool.note()); + System.err.println(""" + + Ścieżki można wskazać wprost: + -Dcatalog.whisper.bin=/opt/homebrew/bin/whisper-cli + -Dcatalog.whisper.model=~/modele/ggml-large-v3.bin"""); + return; + } + Headless.boot(); + + Integer edition = null; + String editionArg = option(args, "--edition", null); + if (editionArg != null) { + edition = Integer.parseInt(editionArg); + } + int limit = Integer.parseInt(option(args, "--limit", "0")); + boolean redo = Arrays.asList(args).contains("--redo"); + + try (Database db = Database.open(dataDir)) { + Object lock = new Object(); + Transcriber transcriber = new Transcriber(db, lock); + System.out.printf("Model: %s%n", Path.of(tool.model()).getFileName()); + transcriber.start(new Transcriber.Scope(edition, limit, redo)); + + Runtime.getRuntime().addShutdownHook(new Thread(transcriber::stop)); + Transcriber.Progress last = null; + while (transcriber.running()) { + Transcriber.Progress now = transcriber.progress(); + if (last == null || now.done() != last.done() || now.failed() != last.failed()) { + System.out.printf("\r %d/%d błędów: %d %-40s", now.done() + now.failed(), + now.total(), now.failed(), + now.current() == null ? "" : truncate(now.current(), 40)); + System.out.flush(); + last = now; + } + Thread.sleep(500); + } + Transcriber.Progress end = transcriber.progress(); + System.out.printf("%n%s: %d przetranskrybowanych, %d nieudanych%n", + end.state(), end.done(), end.failed()); + if (end.note() != null && end.total() == 0) { + System.out.println(end.note()); + } + } + } + private static void analyze(Path dataDir) throws Exception { + // LanguageDetector czyta install.ini wprost z obrazu, więc potrzebuje :core + Headless.boot(); try (Database db = Database.open(dataDir)) { List results = new MetadataDetector(db, dataDir, CORE_VERSION).run(); @@ -173,9 +275,37 @@ public final class Main { } System.out.println(); } + + languages(db); } } + /** Wersje językowe z install.ini — osobny detektor, bo czyta obraz, nie cache. */ + private static void languages(Database db) throws Exception { + List drift = LanguageDetector.verifyAgainstCore(); + if (!drift.isEmpty()) { + System.err.println("! tablica języków rozjechała się z :core: " + + String.join("; ", drift)); + } + + List results = + new LanguageDetector(db, CORE_VERSION).run(); + if (results.isEmpty()) { + return; + } + System.out.println("Wersje językowe z install.ini\n"); + for (LanguageDetector.RootResult result : results) { + String where = result.rootPath().isEmpty() + ? result.copyName() + : result.copyName() + " / " + result.rootPath(); + System.out.printf(" %-46s %-18s %s%n", truncate(where, 46), + String.join(" ", result.languages()), + result.note() != null ? "! " + result.note() + : "dubbing: " + String.join(" ", result.audioLanguages())); + } + System.out.println(); + } + private static void promote(Path dataDir) throws Exception { try (Database db = Database.open(dataDir)) { List assignments = new Promoter(db).run(); @@ -502,6 +632,9 @@ public final class Main { stats statystyki bazy decode [--force] odszyfruj skrypty do cache'u i indeksu + probe [--force] zbadaj dźwięk, archiwa, dialogi i odwołania + transcribe [--edition N] [--limit N] [--redo] + rozpoznaj mowę przez whisper.cpp (wynik maszynowy) analyze wyciągnij metadane z application.def promote utwórz tytuły i wydania z wykrytych faktów titles lista tytułów z metadanymi diff --git a/src/main/java/pl/genschu/rexcatalog/catalog/Promoter.java b/src/main/java/pl/genschu/rexcatalog/catalog/Promoter.java index 974483e..893840e 100644 --- a/src/main/java/pl/genschu/rexcatalog/catalog/Promoter.java +++ b/src/main/java/pl/genschu/rexcatalog/catalog/Promoter.java @@ -52,6 +52,7 @@ public final class Promoter { long titleId = upsertTitle(conn, naming); long editionId = upsertEdition(conn, fingerprint, titleId, naming, root); linkRoot(conn, root.copyId(), root.rootPath(), editionId); + applyLanguages(conn, root.copyId(), root.rootPath(), editionId); assignments.add(new Assignment(root.copyName(), root.rootPath(), naming.title(), naming.label(), fingerprint, naming.needsReview())); @@ -147,6 +148,50 @@ public final class Promoter { return colon > 0 ? title.substring(0, colon).trim() : null; } + /** + * Przenosi wykryte wersje językowe do wydania. + * + *

Tylko dopisuje: {@code DO NOTHING} przy konflikcie i żadnego kasowania, + * bo lista języków jest kuratorska. Detektor dokłada to, co widać na płycie + * ({@code install.ini}, podkatalogi {@code wavs/}), a ręczne wpisy zostają + * nietknięte. + */ + private void applyLanguages(Connection conn, long copyId, String rootPath, long editionId) + throws Exception { + try (PreparedStatement read = conn.prepareStatement(""" + SELECT field, value FROM detection + WHERE copy_id = ? AND root_path = ? AND detector = 'LanguageDetector' + AND field IN ('languages', 'audio_languages') + """); + PreparedStatement write = conn.prepareStatement(""" + INSERT INTO edition_language (edition_id, lang, role) + VALUES (?, ?, ?) + ON CONFLICT(edition_id, lang, role) DO NOTHING + """)) { + + read.setLong(1, copyId); + read.setString(2, rootPath); + try (ResultSet rs = read.executeQuery()) { + while (rs.next()) { + String role = rs.getString("field").equals("audio_languages") ? "audio" : "ui"; + String value = rs.getString("value"); + if (value == null || value.isBlank()) { + continue; + } + for (String lang : value.split(",")) { + if (lang.isBlank()) { + continue; + } + write.setLong(1, editionId); + write.setString(2, lang.trim()); + write.setString(3, role); + write.executeUpdate(); + } + } + } + } + } + private long upsertTitle(Connection conn, Naming naming) throws Exception { String slug = slug(naming.title()); // nazwa nie jest nadpisywana: klucz to slug, więc ręczna zmiana nazwy przetrwa diff --git a/src/main/java/pl/genschu/rexcatalog/db/Database.java b/src/main/java/pl/genschu/rexcatalog/db/Database.java index d0dd87d..4f6abe7 100644 --- a/src/main/java/pl/genschu/rexcatalog/db/Database.java +++ b/src/main/java/pl/genschu/rexcatalog/db/Database.java @@ -281,6 +281,96 @@ public final class Database implements AutoCloseable { blob_sha1 UNINDEXED, tokenize = "unicode61 remove_diacritics 2 tokenchars '_'" ) + """, + // Zawartość archiwów, które trzymają wiele zasobów w jednym pliku — + // wavs/wav.snd z Kapitana Nemo to 3274 kwestie w Ogg Vorbisie. Bez tego + // cała warstwa głosowa jednej gry jest w katalogu niewidoczna. + // + // Payloadów nie wypakowujemy: offset i length wystarczą, żeby je odczytać + // z obrazu płyty na żądanie. sha1 liczymy przy indeksowaniu, bo daje + // wspólny identyfikator dla faktów i transkryptów. + """ + CREATE TABLE IF NOT EXISTS archive_entry ( + container_sha1 TEXT NOT NULL REFERENCES blob(sha1), + name TEXT NOT NULL, + name_raw TEXT, + offset INTEGER NOT NULL, + size INTEGER NOT NULL, + sha1 TEXT, + format TEXT, + tool_version TEXT NOT NULL, + PRIMARY KEY (container_sha1, name) + ) + """, + "CREATE INDEX IF NOT EXISTS idx_archive_sha1 ON archive_entry(sha1)", + // Fakty odczytane z nagłówka zasobu. Bez klucza obcego do blob, bo sha1 + // bywa też odciskiem wpisu w archiwum, którego w blob nie ma — obie + // przestrzenie są adresowane treścią, więc się nie pomieszają. + """ + CREATE TABLE IF NOT EXISTS media ( + sha1 TEXT PRIMARY KEY, + kind TEXT NOT NULL, + codec TEXT, + duration_ms INTEGER, + sample_rate INTEGER, + channels INTEGER, + bits INTEGER, + note TEXT, + tool_version TEXT NOT NULL, + probed_at TEXT + ) + """, + // Odwołania skryptu do zasobów: OBIEKT:FILENAME=PLIK.WAV. Zapisujemy je + // niezależnie od tego, czy plik da się znaleźć — nierozwiązane odwołanie + // jest samo w sobie informacją o wydaniu. + """ + CREATE TABLE IF NOT EXISTS script_ref ( + script_sha1 TEXT NOT NULL REFERENCES blob(sha1), + object TEXT NOT NULL, + field TEXT NOT NULL, + value TEXT NOT NULL, + name TEXT NOT NULL, + format TEXT, + line INTEGER, + PRIMARY KEY (script_sha1, object, field, value) + ) + """, + "CREATE INDEX IF NOT EXISTS idx_script_ref_name ON script_ref(name)", + // dialogi.dta: trigger | identyfikator kwestii | rozmówca | flagi. + // Daje każdej kwestii mówcę i kontekst zdarzenia bez transkrypcji. + """ + CREATE TABLE IF NOT EXISTS dialogue_line ( + dta_sha1 TEXT NOT NULL REFERENCES blob(sha1), + line INTEGER NOT NULL, + section TEXT, + trigger TEXT, + audio_name TEXT, + speaker TEXT, + extra TEXT, + PRIMARY KEY (dta_sha1, line) + ) + """, + "CREATE INDEX IF NOT EXISTS idx_dialogue_audio ON dialogue_line(audio_name)", + // Transkrypty są WYGENEROWANE, nie odczytane z płyty — dlatego osobna + // tabela z nazwą modelu i narzędzia. Nigdy nie mieszają się z metadanymi + // wydania ani z faktami detektorów. + """ + CREATE TABLE IF NOT EXISTS transcript ( + sha1 TEXT PRIMARY KEY, + text TEXT NOT NULL, + lang TEXT, + model TEXT NOT NULL, + tool TEXT NOT NULL, + duration_ms INTEGER, + created_at TEXT + ) + """, + """ + CREATE VIRTUAL TABLE IF NOT EXISTS transcript_fts USING fts5( + body, + sha1 UNINDEXED, + tokenize = "unicode61 remove_diacritics 2 tokenchars '_'" + ) """ }; } diff --git a/src/main/java/pl/genschu/rexcatalog/ingest/LanguageDetector.java b/src/main/java/pl/genschu/rexcatalog/ingest/LanguageDetector.java new file mode 100644 index 0000000..bb32e40 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/ingest/LanguageDetector.java @@ -0,0 +1,392 @@ +package pl.genschu.rexcatalog.ingest; + +import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher; +import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem; +import pl.genschu.bloomooemulator.utils.LangCodeConverter; +import pl.genschu.rexcatalog.db.Database; + +import java.io.File; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Ustala wersje językowe wydania na podstawie {@code install.ini} i układu katalogu + * {@code wavs/}. + * + *

Sekcja {@code [Language]} instalatora wymienia identyfikatory LCID Windows: + *

+ *   0415 = Polish
+ *   040e = Hungarian
+ *   0405 = Czech
+ *   041b = Slovak
+ * 
+ * Tłumaczenie LCID na kod silnika bierzemy z {@link LangCodeConverter} w {@code :core}, + * żeby katalog i emulator nie rozjechały się we własnych tabelach. To ta klasa + * rozstrzyga, że {@code slo} oznacza słowacki, a nie słoweński. + * + *

Dubbing widać dodatkowo w drzewie plików: Wojna Trojańska ma + * {@code wavs/cze/}, {@code wavs/hun/} i {@code wavs/slo/} po 543 pliki każdy, + * a polski leży wprost w {@code wavs/}. Język bez własnego podkatalogu, a wymieniony + * w instalatorze, jest więc językiem podstawowym płyty. + */ +public final class LanguageDetector { + + private static final String DETECTOR = "LanguageDetector"; + + private static final Charset CP1250 = Charset.forName("windows-1250"); + + /** Wiersze {@code [Language]}: klucz to czterocyfrowy LCID, reszta to numery porządkowe. */ + private static final Pattern LCID_LINE = + Pattern.compile("^\\s*([0-9A-Fa-f]{4})\\s*=\\s*(.+?)\\s*$"); + + private static final Pattern SECTION = Pattern.compile("^\\s*\\[(.+?)]\\s*$"); + + /** + * LCID → ISO 639-1, dla języków, które silnik faktycznie obsługuje. + * + *

Kody dwuliterowe, bo takie przyjmuje {@code lang add}. Kluczem jest LCID, + * a nie kod silnika, bo ten drugi bywa spolszczony ({@code niem} to niemiecki). + * Zgodność z {@code :core} pilnuje {@link #verifyAgainstCore()}. + */ + private static final Map LCID_TO_ISO = Map.of( + "0402", "bg", + "0405", "cs", + "0407", "de", + "040E", "hu", + "0415", "pl", + "0418", "ro", + "0419", "ru", + "041B", "sk"); + + private final Database db; + private final String coreVersion; + + public LanguageDetector(Database db, String coreVersion) { + this.db = db; + this.coreVersion = coreVersion; + } + + public record RootResult(String copyName, String rootPath, String note, + List languages, List audioLanguages) { + } + + private record Target(long copyId, String copyName, String container, String rootPath, + String filePath) { + } + + /** + * Sprawdza, czy nasza tabela LCID → ISO opisuje te same języki co {@code :core}. + * Rozjazd oznacza, że emulator zmienił mapowanie i katalog też musi. + */ + public static List verifyAgainstCore() { + List problems = new ArrayList<>(); + for (String lcid : LCID_TO_ISO.keySet()) { + String engine = LangCodeConverter.lcidToIsoCode(lcid); + if ("unknown".equals(engine)) { + problems.add(lcid + ": :core nie zna już tego LCID"); + } + } + return problems; + } + + public List run() throws Exception { + List targets = targets(); + List results = new ArrayList<>(); + + Connection conn = db.connection(); + boolean previousAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); + + try (PreparedStatement delete = conn.prepareStatement( + "DELETE FROM detection WHERE copy_id = ? AND root_path = ? AND detector = ?"); + PreparedStatement insert = conn.prepareStatement(""" + INSERT INTO detection + (copy_id, root_path, field, value, confidence, evidence, + detector, core_version, detected_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """)) { + + String now = Instant.now().toString(); + Map open = new LinkedHashMap<>(); + + for (Target target : targets) { + delete.setLong(1, target.copyId()); + delete.setString(2, target.rootPath()); + delete.setString(3, DETECTOR); + delete.executeUpdate(); + + IFileSystem fs; + try { + fs = open.computeIfAbsent(target.container(), path -> { + try { + return AssetSourceDispatcher.openAssets(new File(path)); + } catch (Exception e) { + throw new IllegalStateException(e.getMessage(), e); + } + }); + } catch (RuntimeException e) { + results.add(new RootResult(target.copyName(), target.rootPath(), + "nie otwieram obrazu: " + e.getMessage(), List.of(), List.of())); + continue; + } + + byte[] raw; + try (InputStream in = fs.open(target.filePath())) { + raw = in.readAllBytes(); + } catch (Exception e) { + results.add(new RootResult(target.copyName(), target.rootPath(), + "nie czytam install.ini: " + e.getMessage(), List.of(), List.of())); + continue; + } + + Map declared = parseLanguages(new String(raw, CP1250)); + if (declared.isEmpty()) { + results.add(new RootResult(target.copyName(), target.rootPath(), + "install.ini bez sekcji [Language]", List.of(), List.of())); + continue; + } + + Set audioDirs = audioDirectories(target.copyId(), target.rootPath()); + List languages = new ArrayList<>(); + List audio = new ArrayList<>(); + List withoutDirectory = new ArrayList<>(); + StringBuilder evidence = new StringBuilder("install.ini [Language]:"); + + for (Map.Entry entry : declared.entrySet()) { + String lcid = entry.getKey().toUpperCase(Locale.ROOT); + String iso = LCID_TO_ISO.get(lcid); + String engine = LangCodeConverter.lcidToIsoCode(lcid).toLowerCase(Locale.ROOT); + String code = iso != null ? iso : lcid; + + languages.add(code); + evidence.append(' ').append(entry.getValue()) + .append(" (").append(lcid).append(')'); + + if (audioDirs.contains(engine)) { + audio.add(code); + } else { + withoutDirectory.add(code); + } + } + + // język bez własnego podkatalogu ma dubbing wprost w wavs/ — ale tylko + // wtedy, gdy jest jeden; przy dwóch nie wiadomo, który to + if (withoutDirectory.size() == 1) { + audio.add(0, withoutDirectory.get(0)); + } + + write(insert, target, "languages", String.join(",", languages), 0.95, + evidence.toString(), now); + if (!audio.isEmpty()) { + write(insert, target, "audio_languages", String.join(",", audio), 0.9, + audioEvidence(audioDirs, withoutDirectory), now); + } + results.add(new RootResult(target.copyName(), target.rootPath(), null, + languages, audio)); + } + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } finally { + conn.setAutoCommit(previousAutoCommit); + } + + return results; + } + + private static String audioEvidence(Set dirs, List withoutDirectory) { + StringBuilder out = new StringBuilder(); + if (!dirs.isEmpty()) { + out.append("podkatalogi wavs/: ").append(String.join(", ", dirs)); + } + if (withoutDirectory.size() == 1) { + if (!out.isEmpty()) { + out.append("; "); + } + out.append("bez podkatalogu, dźwięk w wavs/: ").append(withoutDirectory.get(0)); + } + return out.toString(); + } + + private void write(PreparedStatement insert, Target target, String field, String value, + double confidence, String evidence, String now) throws Exception { + insert.setLong(1, target.copyId()); + insert.setString(2, target.rootPath()); + insert.setString(3, field); + insert.setString(4, value); + insert.setDouble(5, confidence); + insert.setString(6, evidence); + insert.setString(7, DETECTOR); + insert.setString(8, coreVersion); + insert.setString(9, now); + insert.executeUpdate(); + } + + /** Czyta sekcję {@code [Language]}, pomijając numery porządkowe i pole {@code Nr}. */ + static Map parseLanguages(String text) { + Map out = new LinkedHashMap<>(); + boolean inside = false; + + for (String line : text.split("\r?\n")) { + Matcher section = SECTION.matcher(line); + if (section.matches()) { + inside = section.group(1).equalsIgnoreCase("Language"); + continue; + } + if (!inside) { + continue; + } + Matcher entry = LCID_LINE.matcher(line); + if (entry.matches()) { + out.put(entry.group(1), entry.group(2)); + } + } + return out; + } + + /** Podkatalogi {@code wavs/} w obrębie korzenia gry — nazwane kodem silnika. */ + private Set audioDirectories(long copyId, String rootPath) throws Exception { + String prefix = (rootPath.isEmpty() ? "" : rootPath + "/") + "wavs/"; + Set out = new LinkedHashSet<>(); + + try (PreparedStatement st = db.connection().prepareStatement( + "SELECT DISTINCT path FROM file WHERE copy_id = ? AND path LIKE ?")) { + st.setLong(1, copyId); + st.setString(2, prefix + "%/%"); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + String rest = rs.getString("path").substring(prefix.length()); + int slash = rest.indexOf('/'); + if (slash > 0) { + out.add(rest.substring(0, slash)); + } + } + } + } + // sfx to efekty dźwiękowe, nie wersja językowa + out.remove("sfx"); + return out; + } + + /** {@code install.ini} pasujący do korzenia gry — najpłytszy w obrębie prefiksu. */ + /** + * Dopasowuje {@code install.ini} do korzenia gry. + * + *

Zwykle plik leży w obrębie korzenia, ale na dwupacku instalator gry + * głównej stoi w korzeniu płyty, a druga gra ma własny w swoim podkatalogu + * ({@code odyseusz/install.ini}). Dlatego korzeń, który nie ma własnego pliku, + * sięga po ten z płyty — o ile nie należy on do żadnego innego korzenia. + */ + private List targets() throws Exception { + Map copies = copies(); + List candidates = candidates(copies.keySet()); + + List out = new ArrayList<>(); + for (Copy copy : copies.values()) { + for (String rootPath : copy.roots()) { + String prefix = rootPath.isEmpty() ? "" : rootPath + "/"; + Candidate best = shallowest(candidates, copy.id(), + canonical -> canonical.startsWith(prefix)); + + if (best == null) { + // instalator gry głównej dwupacka: w korzeniu płyty, nieprzypisany + best = shallowest(candidates, copy.id(), canonical -> copy.roots().stream() + .noneMatch(root -> !root.isEmpty() + && canonical.startsWith(root + "/"))); + } + if (best != null) { + out.add(new Target(copy.id(), copy.displayName(), copy.container(), + rootPath, best.filePath())); + } + } + } + return out; + } + + private record Copy(long id, String displayName, String container, List roots) { + } + + private record Candidate(long copyId, String canonical, String filePath) { + } + + private static Candidate shallowest(List candidates, long copyId, + java.util.function.Predicate accepts) { + Candidate best = null; + for (Candidate candidate : candidates) { + if (candidate.copyId() != copyId || !accepts.test(candidate.canonical())) { + continue; + } + if (best == null || depth(candidate.canonical()) < depth(best.canonical())) { + best = candidate; + } + } + return best; + } + + private Map copies() throws Exception { + Map out = new LinkedHashMap<>(); + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT DISTINCT g.copy_id, g.root_path, c.display_name, c.path AS container + FROM game_root g + JOIN copy c ON c.id = g.copy_id + WHERE c.status <> 'unreadable' + ORDER BY g.copy_id, g.root_path + """); + ResultSet rs = st.executeQuery()) { + while (rs.next()) { + long id = rs.getLong("copy_id"); + out.computeIfAbsent(id, key -> new Copy(key, rsString(rs, "display_name"), + rsString(rs, "container"), new ArrayList<>())) + .roots().add(rs.getString("root_path")); + } + } + return out; + } + + private List candidates(Set knownCopies) throws Exception { + List out = new ArrayList<>(); + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT f.copy_id, f.path AS canonical, COALESCE(f.path_raw, f.path) AS file_path + FROM file f + WHERE f.path LIKE '%install.ini' + ORDER BY f.copy_id, f.path + """); + ResultSet rs = st.executeQuery()) { + while (rs.next()) { + long copyId = rs.getLong("copy_id"); + if (knownCopies.contains(copyId)) { + out.add(new Candidate(copyId, rs.getString("canonical"), + rs.getString("file_path"))); + } + } + } + return out; + } + + /** {@code computeIfAbsent} nie przepuszcza wyjątku SQL, a kolumny są tu zawsze obecne. */ + private static String rsString(ResultSet rs, String column) { + try { + return rs.getString(column); + } catch (java.sql.SQLException e) { + throw new IllegalStateException(e); + } + } + + private static int depth(String path) { + return (int) path.chars().filter(c -> c == '/').count(); + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java b/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java index 79c14f6..966032c 100644 --- a/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java +++ b/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java @@ -94,6 +94,27 @@ public final class Tools { ), List.of("path_contains")), args -> findFile(string(args, "path_contains"), integer(args, "limit", 50)))); + tools.add(new Tool("get_script_assets", + "Zasoby, do których odwołuje się skrypt: pliki dźwiękowe, animacje, obrazy " + + "i tablice. Przy kwestiach mówionych podaje długość nagrania, " + + "postać, która je wypowiada, i zdarzenie, przy którym padają — " + + "a jeśli transkrypcja została uruchomiona, także rozpoznaną treść.", + schema(Map.of( + "ref", property("string", "SHA-1 skryptu albo fragment ścieżki"), + "format", property("string", "Zawęź do formatu, np. WAV") + ), List.of("ref")), + args -> scriptAssets(string(args, "ref"), string(args, "format")))); + + tools.add(new Tool("find_audio", + "Szuka nagrań po nazwie pliku, po postaci, która mówi, albo po treści " + + "transkryptu. Zwraca długość, format, grę i — o ile jest — " + + "rozpoznany tekst. Obejmuje też kwestie schowane w archiwum wav.snd.", + schema(Map.of( + "query", property("string", "Fragment nazwy, imię postaci albo słowa z kwestii"), + "limit", property("integer", "Maksymalna liczba trafień (domyślnie 30)") + ), List.of("query")), + args -> findAudio(string(args, "query"), integer(args, "limit", 30)))); + 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.", @@ -105,6 +126,183 @@ public final class Tools { // ---------- implementacje ---------- + /** + * Odwołania skryptu rozwiązane do konkretnych zasobów. Nierozwiązane też + * pokazujemy — nazwa, której na płycie nie ma, mówi coś o wydaniu. + */ + private String scriptAssets(String ref, String format) throws Exception { + List candidates = search.resolve(ref); + if (candidates.isEmpty()) { + return "Nic nie pasuje do: " + ref; + } + if (candidates.size() > 1) { + StringBuilder sb = new StringBuilder("Niejednoznaczne — podaj SHA-1:\n"); + for (String sha1 : candidates) { + sb.append(" ").append(sha1).append(" ") + .append(String.join(", ", search.gamesFor(sha1))).append('\n'); + } + return sb.toString(); + } + String sha1 = candidates.get(0); + + StringBuilder sb = new StringBuilder(); + sb.append(String.join(", ", search.pathsFor(sha1))).append('\n') + .append(String.join(", ", search.gamesFor(sha1))).append("\n\n"); + + String sql = """ + WITH kopia AS (SELECT f.copy_id FROM file f WHERE f.blob_sha1 = ? LIMIT 1) + SELECT r.object, r.field, r.value, r.format, r.line, + COALESCE( + (SELECT f2.blob_sha1 FROM file f2, kopia + WHERE f2.copy_id = kopia.copy_id + AND (f2.path = r.name OR f2.path LIKE '%/' || r.name) LIMIT 1), + (SELECT ae.sha1 FROM archive_entry ae + JOIN file f3 ON f3.blob_sha1 = ae.container_sha1, kopia + WHERE f3.copy_id = kopia.copy_id AND ae.name = r.name LIMIT 1) + ) AS target, + (SELECT d.speaker FROM dialogue_line d WHERE d.audio_name = r.name LIMIT 1) + AS speaker, + (SELECT d.trigger FROM dialogue_line d WHERE d.audio_name = r.name LIMIT 1) + AS trigger + FROM script_ref r WHERE r.script_sha1 = ? + """ + (format == null || format.isBlank() ? "" : " AND r.format = ?\n") + + "ORDER BY r.format, r.line"; + + int missing = 0; + int shown = 0; + try (PreparedStatement st = db.connection().prepareStatement(sql)) { + st.setString(1, sha1); + st.setString(2, sha1); + if (format != null && !format.isBlank()) { + st.setString(3, format.toUpperCase(java.util.Locale.ROOT)); + } + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + String target = rs.getString("target"); + if (target == null) { + missing++; + continue; + } + sb.append(String.format("%-26s %-9s %s%n", rs.getString("value"), + rs.getString("format"), + rs.getString("object") + ":" + rs.getString("field"))); + String detail = audioDetail(target, rs.getString("speaker"), + rs.getString("trigger")); + if (!detail.isEmpty()) { + sb.append(" ").append(detail).append('\n'); + } + shown++; + } + } + } + if (shown == 0 && missing == 0) { + return sb + "Ten skrypt nie odwołuje się do żadnych zasobów."; + } + if (missing > 0) { + sb.append(String.format("%n%d odwołań bez pliku na płycie.%n", missing)); + } + return sb.toString(); + } + + /** Jedna linijka faktów o nagraniu: ile trwa, kto mówi, co słychać. */ + private String audioDetail(String sha1, String speaker, String trigger) throws Exception { + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT m.duration_ms, m.codec, t.text, t.model + FROM media m LEFT JOIN transcript t ON t.sha1 = m.sha1 + WHERE m.sha1 = ? AND m.kind = 'audio' + """)) { + st.setString(1, sha1); + try (ResultSet rs = st.executeQuery()) { + if (!rs.next()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + sb.append(seconds(rs.getInt("duration_ms"))).append(' ') + .append(rs.getString("codec")); + if (speaker != null) { + sb.append(" · mówi ").append(speaker); + } + if (trigger != null) { + sb.append(" · przy ").append(trigger); + } + String text = rs.getString("text"); + if (text != null && !text.isBlank()) { + sb.append("\n „").append(text.replace('\n', ' ').trim()) + .append("” (rozpoznane maszynowo, ").append(rs.getString("model")) + .append(')'); + } + return sb.toString(); + } + } + } + + private String findAudio(String query, int limit) throws Exception { + StringBuilder sb = new StringBuilder(); + int found = 0; + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT klip.name, klip.copy, klip.sha1, m.duration_ms, m.codec, + (SELECT d.speaker FROM dialogue_line d + WHERE d.audio_name = klip.short LIMIT 1) AS speaker, + (SELECT d.trigger FROM dialogue_line d + WHERE d.audio_name = klip.short LIMIT 1) AS trigger, + t.text AS transcript + FROM ( + SELECT f.blob_sha1 AS sha1, COALESCE(f.path_raw, f.path) AS name, + CASE WHEN instr(f.path, '/') > 0 + THEN replace(f.path, rtrim(f.path, replace(f.path, '/', '')), '') + ELSE f.path END AS short, + c.display_name AS copy + FROM file f JOIN blob b ON b.sha1 = f.blob_sha1 + JOIN copy c ON c.id = f.copy_id WHERE b.format = 'WAV' + UNION + SELECT ae.sha1, ae.name_raw, ae.name, c.display_name + FROM archive_entry ae + JOIN file f ON f.blob_sha1 = ae.container_sha1 + JOIN copy c ON c.id = f.copy_id + ) AS klip + JOIN media m ON m.sha1 = klip.sha1 + LEFT JOIN transcript t ON t.sha1 = klip.sha1 + WHERE instr(lower(klip.name), lower(?)) > 0 + OR (t.text IS NOT NULL AND instr(lower(t.text), lower(?)) > 0) + OR EXISTS (SELECT 1 FROM dialogue_line d + WHERE d.audio_name = klip.short + AND lower(d.speaker) = lower(?)) + GROUP BY klip.sha1 ORDER BY klip.name LIMIT ? + """)) { + st.setString(1, query); + st.setString(2, query); + st.setString(3, query); + st.setInt(4, Math.min(limit, 200)); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + sb.append(String.format("%-26s %7s %s%n", rs.getString("name"), + seconds(rs.getInt("duration_ms")), rs.getString("copy"))); + String speaker = rs.getString("speaker"); + if (speaker != null) { + sb.append(" mówi ").append(speaker); + if (rs.getString("trigger") != null) { + sb.append(" · przy ").append(rs.getString("trigger")); + } + sb.append('\n'); + } + String text = rs.getString("transcript"); + if (text != null && !text.isBlank()) { + sb.append(" „").append(text.replace('\n', ' ').trim()).append("”\n"); + } + sb.append(" sha1: ").append(rs.getString("sha1")).append('\n'); + found++; + } + } + } + return found == 0 ? "Nic nie pasuje do: " + query + : found + " nagrań:\n\n" + sb; + } + + private static String seconds(int ms) { + return ms <= 0 ? "—" : String.format(java.util.Locale.ROOT, "%.1f s", ms / 1000.0); + } + + private String listTitles() throws Exception { StringBuilder sb = new StringBuilder(); try (PreparedStatement st = db.connection().prepareStatement(""" diff --git a/src/main/java/pl/genschu/rexcatalog/media/AudioIndexer.java b/src/main/java/pl/genschu/rexcatalog/media/AudioIndexer.java new file mode 100644 index 0000000..dbc487f --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/media/AudioIndexer.java @@ -0,0 +1,273 @@ +package pl.genschu.rexcatalog.media; + +import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher; +import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem; +import pl.genschu.rexcatalog.db.Database; + +import java.io.File; +import java.io.InputStream; +import java.security.MessageDigest; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Wypełnia tabele {@code media} i {@code archive_entry} faktami z nagłówków dźwięku. + * + *

Jednostką pracy jest blob, tak jak przy skryptach — ten sam plik w kilku + * obrazach czytamy raz. Dla luźnych WAV-ów wystarczy początek pliku, więc 3,3 GB + * próbek nie przechodzi przez procesor; archiwa {@code .snd} czytamy w całości, + * bo liczymy odcisk każdego wpisu. + */ +public final class AudioIndexer { + + /** + * Wersja własnych parserów, nie {@code :core} — te nagłówki czytamy sami, + * więc unieważnia je zmiana tego kodu, a nie bump emulatora. + */ + public static final String VERSION = "media-1"; + + private static final String KIND = "audio"; + + private final Database db; + + public AudioIndexer(Database db) { + this.db = db; + } + + public record Stats(int audio, int archives, int entries, int failed) { + } + + private record Pending(String pathInContainer, String sha1, String format) { + } + + public Stats run(boolean force) throws Exception { + Map> byContainer = pendingWork(force); + if (byContainer.isEmpty()) { + return new Stats(0, 0, 0, 0); + } + + Connection conn = db.connection(); + boolean previousAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); + + int audio = 0; + int archives = 0; + int entries = 0; + int failed = 0; + Set done = new HashSet<>(); + + try (PreparedStatement mediaStmt = conn.prepareStatement(""" + INSERT INTO media (sha1, kind, codec, duration_ms, sample_rate, channels, + bits, note, tool_version, probed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(sha1) DO UPDATE SET + kind = excluded.kind, codec = excluded.codec, + duration_ms = excluded.duration_ms, sample_rate = excluded.sample_rate, + channels = excluded.channels, bits = excluded.bits, note = excluded.note, + tool_version = excluded.tool_version, probed_at = excluded.probed_at + """); + PreparedStatement entryStmt = conn.prepareStatement(""" + INSERT INTO archive_entry (container_sha1, name, name_raw, offset, size, + sha1, format, tool_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(container_sha1, name) DO UPDATE SET + name_raw = excluded.name_raw, offset = excluded.offset, + size = excluded.size, sha1 = excluded.sha1, + format = excluded.format, tool_version = excluded.tool_version + """)) { + + for (Map.Entry> group : byContainer.entrySet()) { + File container = new File(group.getKey()); + IFileSystem fs; + try { + fs = AssetSourceDispatcher.openAssets(container); + } catch (Exception e) { + System.err.printf(" ! nie otwieram %s: %s%n", + container.getName(), e.getMessage()); + failed += group.getValue().size(); + continue; + } + + for (Pending pending : group.getValue()) { + if (!done.add(pending.sha1())) { + continue; + } + try { + if ("SND".equals(pending.format())) { + entries += indexArchive(fs, pending, entryStmt, mediaStmt); + archives++; + } else { + probeLooseFile(fs, pending, mediaStmt); + audio++; + } + } catch (Exception e) { + System.err.printf(" ! %s (%s): %s%n", pending.pathInContainer(), + container.getName(), e.getMessage()); + failed++; + } + } + conn.commit(); + } + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } finally { + conn.setAutoCommit(previousAutoCommit); + } + + return new Stats(audio, archives, entries, failed); + } + + /** Luźny plik dźwiękowy: czytamy tyle, ile trzeba na nagłówek, i nic więcej. */ + private void probeLooseFile(IFileSystem fs, Pending pending, PreparedStatement mediaStmt) + throws Exception { + byte[] head; + try (InputStream in = fs.open(pending.pathInContainer())) { + head = in.readNBytes(AudioProbe.WAV_HEAD); + } + writeMedia(mediaStmt, pending.sha1(), AudioProbe.probe(head)); + } + + /** Archiwum: każdy wpis dostaje własny odcisk, pozycję w kontenerze i fakty. */ + private int indexArchive(IFileSystem fs, Pending pending, PreparedStatement entryStmt, + PreparedStatement mediaStmt) throws Exception { + int[] count = {0}; + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + + try (InputStream in = fs.open(pending.pathInContainer())) { + SndArchive.walk(in, (entry, payload) -> { + try { + sha1.reset(); + String digest = hex(sha1.digest(payload)); + AudioProbe.Info info = AudioProbe.probe(payload); + + entryStmt.setString(1, pending.sha1()); + entryStmt.setString(2, entry.name()); + entryStmt.setString(3, entry.nameRaw()); + entryStmt.setLong(4, entry.offset()); + entryStmt.setInt(5, entry.size()); + entryStmt.setString(6, digest); + entryStmt.setString(7, formatOf(entry.name(), info)); + entryStmt.setString(8, VERSION); + entryStmt.executeUpdate(); + + writeMedia(mediaStmt, digest, info); + count[0]++; + } catch (Exception e) { + throw new java.io.IOException( + "wpis " + entry.nameRaw() + ": " + e.getMessage(), e); + } + }); + } + return count[0]; + } + + /** + * Rozszerzenie nazwy bywa mylące — wpisy w {@code wav.snd} nazywają się + * {@code *.wav}, a w środku mają Vorbisa. Wierzymy zawartości. + */ + private static String formatOf(String name, AudioProbe.Info info) { + if ("vorbis".equals(info.codec())) { + return "OGG"; + } + int dot = name.lastIndexOf('.'); + return dot < 0 ? null : name.substring(dot + 1).toUpperCase(Locale.ROOT); + } + + private void writeMedia(PreparedStatement stmt, String sha1, AudioProbe.Info info) + throws Exception { + stmt.setString(1, sha1); + stmt.setString(2, KIND); + stmt.setString(3, info.codec()); + if (info.durationMs() < 0) { + stmt.setNull(4, java.sql.Types.INTEGER); + } else { + stmt.setInt(4, info.durationMs()); + } + stmt.setInt(5, info.sampleRate()); + stmt.setInt(6, info.channels()); + stmt.setInt(7, info.bits()); + stmt.setString(8, info.note()); + stmt.setString(9, VERSION); + stmt.setString(10, Instant.now().toString()); + stmt.executeUpdate(); + } + + /** + * Zbiera pliki do przerobienia, pogrupowane po kontenerze — żeby każdy obraz + * otwierać raz. Luźne WAV-y sprawdzamy po tabeli {@code media}, archiwa po tym, + * czy mają już wpisy z aktualną wersją parsera. + */ + private Map> pendingWork(boolean force) throws Exception { + String condition = force ? "1 = 1" : """ + ( + (b.format = 'WAV' AND (m.sha1 IS NULL OR m.tool_version <> ?)) + OR + (b.format = 'SND' AND NOT EXISTS ( + SELECT 1 FROM archive_entry ae + WHERE ae.container_sha1 = b.sha1 AND ae.tool_version = ?)) + ) + """; + String sql = """ + SELECT c.path AS container, COALESCE(f.path_raw, f.path) AS in_container, + f.blob_sha1, b.format + FROM copy c + JOIN file f ON f.copy_id = c.id + JOIN blob b ON b.sha1 = f.blob_sha1 + LEFT JOIN media m ON m.sha1 = b.sha1 + WHERE c.status <> 'unreadable' + AND b.format IN ('WAV', 'SND') + AND %s + ORDER BY c.id + """.formatted(condition); + + Map> byContainer = new LinkedHashMap<>(); + try (PreparedStatement st = db.connection().prepareStatement(sql)) { + if (!force) { + st.setString(1, VERSION); + st.setString(2, VERSION); + } + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + byContainer.computeIfAbsent(rs.getString("container"), k -> new ArrayList<>()) + .add(new Pending(rs.getString("in_container"), + rs.getString("blob_sha1"), rs.getString("format"))); + } + } + } + return byContainer; + } + + /** Ile plików czeka na przerobienie — do komunikatu przed startem. */ + public int pendingCount() throws Exception { + int total = 0; + Set unique = new HashSet<>(); + for (List list : pendingWork(false).values()) { + for (Pending pending : list) { + if (unique.add(pending.sha1())) { + total++; + } + } + } + return total; + } + + private static String hex(byte[] bytes) { + StringBuilder out = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + out.append(Character.forDigit((b >> 4) & 0xF, 16)); + out.append(Character.forDigit(b & 0xF, 16)); + } + return out.toString(); + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/media/AudioProbe.java b/src/main/java/pl/genschu/rexcatalog/media/AudioProbe.java new file mode 100644 index 0000000..4acbe13 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/media/AudioProbe.java @@ -0,0 +1,195 @@ +package pl.genschu.rexcatalog.media; + +import java.nio.charset.StandardCharsets; + +/** + * Odczyt nagłówków dźwięku: RIFF/WAVE oraz Ogg Vorbis. + * + *

Bez {@code :core} i bez libGDX — {@code SoundLoader} z emulatora wymaga + * {@code FileHandle} i działającego {@code Gdx.audio}, a jego wkładem jest + * parsowanie standardowego nagłówka RIFF. Tutaj czytamy prosto ze strumienia + * z obrazu płyty, bez plików tymczasowych. + * + *

Dla WAV wystarczy początek pliku: długość dźwięku stoi w nagłówku porcji + * {@code data}, więc 3,3 GB próbek nie musi przejść przez procesor. + */ +public final class AudioProbe { + + /** Ile bajtów początku pliku wystarcza, by znaleźć porcje {@code fmt } i {@code data}. */ + public static final int WAV_HEAD = 4096; + + private AudioProbe() { + } + + /** + * @param codec nazwa kodeka albo jego numer, gdy nieznany + * @param durationMs długość w milisekundach, {@code -1} gdy nie do wyliczenia + * @param note co poszło nie tak, gdy nagłówek był nietypowy + */ + public record Info(String codec, int durationMs, int sampleRate, int channels, + int bits, String note) { + + public static Info unreadable(String note) { + return new Info(null, -1, 0, 0, 0, note); + } + } + + /** Rozpoznaje kontener po magicznych bajtach i wybiera właściwy parser. */ + public static Info probe(byte[] data) { + if (data.length >= 4 && matches(data, 0, "OggS")) { + return probeOgg(data); + } + if (data.length >= 12 && matches(data, 0, "RIFF") && matches(data, 8, "WAVE")) { + return probeWav(data); + } + return Info.unreadable("nierozpoznany kontener dźwięku"); + } + + // ---------- RIFF / WAVE ---------- + + private static Info probeWav(byte[] data) { + int position = 12; + int format = 0; + int channels = 0; + int sampleRate = 0; + int bits = 0; + long byteRate = 0; + long dataSize = -1; + + while (position + 8 <= data.length) { + String chunkId = new String(data, position, 4, StandardCharsets.US_ASCII); + long chunkSize = u32(data, position + 4); + int body = position + 8; + + if (chunkId.equals("fmt ") && body + 16 <= data.length) { + format = u16(data, body); + channels = u16(data, body + 2); + sampleRate = (int) u32(data, body + 4); + byteRate = u32(data, body + 8); + bits = u16(data, body + 14); + } else if (chunkId.equals("data")) { + dataSize = chunkSize; + break; + } + // porcje są wyrównane do parzystej granicy + position = body + (int) chunkSize + (int) (chunkSize & 1); + if (chunkSize <= 0) { + break; + } + } + + if (sampleRate <= 0 || channels <= 0) { + return Info.unreadable("brak czytelnej porcji fmt"); + } + + int durationMs = -1; + if (dataSize >= 0) { + // dla kompresji (ADPCM, MP3 w kontenerze WAV) liczba próbek nie wynika + // z liczby bajtów, ale byteRate jest w nagłówku deklarowany zawsze + long bytesPerSecond = byteRate > 0 + ? byteRate + : (long) sampleRate * channels * Math.max(bits, 8) / 8; + if (bytesPerSecond > 0) { + durationMs = (int) (dataSize * 1000 / bytesPerSecond); + } + } + + return new Info(wavCodec(format), durationMs, sampleRate, channels, bits, + dataSize < 0 ? "nie znaleziono porcji data w nagłówku" : null); + } + + private static String wavCodec(int format) { + return switch (format) { + case 0x0001 -> "pcm"; + case 0x0002 -> "ms-adpcm"; + case 0x0011 -> "ima-adpcm"; + case 0x0055 -> "mp3"; + case 0x0161 -> "wma"; + case 0xFFFE -> "pcm-extensible"; + default -> "wav-0x%04X".formatted(format); + }; + } + + // ---------- Ogg Vorbis ---------- + + /** + * Wymaga całego payloadu, bo długość dźwięku stoi w pozycji ziarna ostatniej + * strony. Wpisy w {@code wav.snd} mają średnio 24 kB, więc to nie jest problem. + */ + public static Info probeOgg(byte[] data) { + int channels = 0; + int sampleRate = 0; + long lastGranule = -1; + int position = 0; + boolean sawIdentification = false; + + while (position + 27 <= data.length && matches(data, position, "OggS")) { + int segmentCount = data[position + 26] & 0xFF; + int segmentTable = position + 27; + if (segmentTable + segmentCount > data.length) { + break; + } + int payloadSize = 0; + for (int i = 0; i < segmentCount; i++) { + payloadSize += data[segmentTable + i] & 0xFF; + } + int payload = segmentTable + segmentCount; + + long granule = u64(data, position + 6); + if (granule != -1L) { + lastGranule = granule; + } + + if (!sawIdentification && payloadSize >= 16 && payload + 16 <= data.length + && (data[payload] & 0xFF) == 0x01 && matches(data, payload + 1, "vorbis")) { + channels = data[payload + 11] & 0xFF; + sampleRate = (int) u32(data, payload + 12); + sawIdentification = true; + } + position = payload + payloadSize; + } + + if (!sawIdentification || sampleRate <= 0) { + return Info.unreadable("brak nagłówka identyfikacyjnego Vorbisa"); + } + // ziarno równe zeru to nie brak informacji, tylko pusty strumień — + // w wav.snd leży kilkanaście takich plików, nazwanych wprost _pusty0.wav + int durationMs = lastGranule >= 0 + ? (int) (lastGranule * 1000 / sampleRate) + : -1; + return new Info("vorbis", durationMs, sampleRate, channels, 0, null); + } + + // ---------- pomocnicze ---------- + + private static boolean matches(byte[] data, int offset, String ascii) { + if (offset + ascii.length() > data.length) { + return false; + } + for (int i = 0; i < ascii.length(); i++) { + if ((data[offset + i] & 0xFF) != ascii.charAt(i)) { + return false; + } + } + return true; + } + + private static int u16(byte[] data, int offset) { + return (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8); + } + + private static long u32(byte[] data, int offset) { + return (data[offset] & 0xFFL) + | ((data[offset + 1] & 0xFFL) << 8) + | ((data[offset + 2] & 0xFFL) << 16) + | ((data[offset + 3] & 0xFFL) << 24); + } + + private static long u64(byte[] data, int offset) { + long value = 0; + for (int i = 7; i >= 0; i--) { + value = (value << 8) | (data[offset + i] & 0xFFL); + } + return value; + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/media/DialogueIndexer.java b/src/main/java/pl/genschu/rexcatalog/media/DialogueIndexer.java new file mode 100644 index 0000000..7f7b771 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/media/DialogueIndexer.java @@ -0,0 +1,279 @@ +package pl.genschu.rexcatalog.media; + +import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher; +import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem; +import pl.genschu.rexcatalog.db.Database; + +import java.io.File; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Czyta tabele {@code .dta} i wyciąga z nich wiersze dialogów. + * + *

{@code dialogi.dta} to zwykły tekst CP1250 rozdzielony pionowymi kreskami: + *

+ *   click_zawor0|Rex_Aw07|rex
+ *   use_mlotek_on_tlok0|Kret_Aw14|rex|f__
+ * 
+ * Kolumny to zdarzenie, identyfikator kwestii, rozmówca i flagi. Identyfikator + * odpowiada nazwie pliku dźwiękowego, a jego przedrostek — postaci + * ({@code Kret} to Kretes, {@code Nar} narrator). + * + *

Dzięki temu każda kwestia ma mówcę i kontekst zdarzenia bez transkrypcji. + * Wiersze zaczynające się od myślników są komentarzami autorów i niosą nazwy + * planszy — trzymamy je jako sekcję kolejnych wierszy. + */ +public final class DialogueIndexer { + + public static final String VERSION = "dialogue-1"; + + private static final Charset CP1250 = Charset.forName("windows-1250"); + + /** Identyfikator kwestii: przedrostek postaci, podkreślenie, oznaczenie sceny. */ + private static final Pattern AUDIO_ID = Pattern.compile("[A-Za-z0-9]{2,}_[A-Za-z0-9]+"); + + private static final Pattern SECTION = Pattern.compile("^-{3,}\\s*(.*)$"); + + private final Database db; + + public DialogueIndexer(Database db) { + this.db = db; + } + + public record Stats(int files, int lines, int failed) { + } + + private record Pending(String pathInContainer, String sha1) { + } + + public Stats run(boolean force) throws Exception { + Map> byContainer = pendingWork(force); + if (byContainer.isEmpty()) { + return new Stats(0, 0, 0); + } + + Connection conn = db.connection(); + boolean previousAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); + + int files = 0; + int lines = 0; + int failed = 0; + Set done = new HashSet<>(); + + try (PreparedStatement delete = conn.prepareStatement( + "DELETE FROM dialogue_line WHERE dta_sha1 = ?"); + PreparedStatement insert = conn.prepareStatement(""" + INSERT INTO dialogue_line (dta_sha1, line, section, trigger, + audio_name, speaker, extra) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(dta_sha1, line) DO UPDATE SET + section = excluded.section, trigger = excluded.trigger, + audio_name = excluded.audio_name, speaker = excluded.speaker, + extra = excluded.extra + """); + // ślad, że plik już przeszedł przez parser — inaczej tabele .dta bez + // dialogów (zapisy stanu gry) czytalibyśmy przy każdym uruchomieniu + PreparedStatement mark = conn.prepareStatement(""" + INSERT INTO media (sha1, kind, note, tool_version, probed_at) + VALUES (?, 'dialogue', ?, ?, ?) + ON CONFLICT(sha1) DO UPDATE SET + kind = excluded.kind, note = excluded.note, + tool_version = excluded.tool_version, probed_at = excluded.probed_at + """)) { + + for (Map.Entry> group : byContainer.entrySet()) { + File container = new File(group.getKey()); + IFileSystem fs; + try { + fs = AssetSourceDispatcher.openAssets(container); + } catch (Exception e) { + System.err.printf(" ! nie otwieram %s: %s%n", + container.getName(), e.getMessage()); + failed += group.getValue().size(); + continue; + } + + for (Pending pending : group.getValue()) { + if (!done.add(pending.sha1())) { + continue; + } + try { + byte[] raw; + try (InputStream in = fs.open(pending.pathInContainer())) { + raw = in.readAllBytes(); + } + delete.setString(1, pending.sha1()); + delete.executeUpdate(); + + int written = parse(pending.sha1(), new String(raw, CP1250), insert); + if (written > 0) { + lines += written; + files++; + } + mark.setString(1, pending.sha1()); + mark.setString(2, written + " wierszy dialogu"); + mark.setString(3, VERSION); + mark.setString(4, java.time.Instant.now().toString()); + mark.executeUpdate(); + } catch (Exception e) { + System.err.printf(" ! %s (%s): %s%n", pending.pathInContainer(), + container.getName(), e.getMessage()); + failed++; + } + } + conn.commit(); + } + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } finally { + conn.setAutoCommit(previousAutoCommit); + } + + return new Stats(files, lines, failed); + } + + /** + * Zapisuje tylko wiersze, w których druga kolumna wygląda na identyfikator + * kwestii. To odsiewa inne tabele {@code .dta} — zapisy stanu gry mają ten sam + * format, ale w drugiej kolumnie zwykłe wartości. + */ + private int parse(String sha1, String text, PreparedStatement insert) throws Exception { + String[] rows = text.split("\r?\n", -1); + String section = null; + int written = 0; + + for (int number = 0; number < rows.length; number++) { + String row = rows[number].trim(); + if (row.isEmpty()) { + continue; + } + String[] columns = row.split("\\|", -1); + + String marker = sectionLabel(row, columns); + if (marker != null) { + section = marker.isEmpty() ? null : marker; + continue; + } + if (columns.length < 2) { + continue; + } + String id = columns[1].trim(); + if (!AUDIO_ID.matcher(id).matches() || !isTrigger(columns[0])) { + continue; + } + + insert.setString(1, sha1); + insert.setInt(2, number + 1); + insert.setString(3, section); + insert.setString(4, blankToNull(columns[0])); + insert.setString(5, audioName(id)); + insert.setString(6, speaker(id)); + insert.setString(7, columns.length > 2 + ? blankToNull(String.join("|", + java.util.Arrays.copyOfRange(columns, 2, columns.length))) + : null); + insert.executeUpdate(); + written++; + } + return written; + } + + /** + * Pierwsza kolumna wiersza dialogu to nazwa zdarzenia, nie plik. + * + *

Ten sam format {@code .dta} niosą tabele definicji planszy + * ({@code MIASTO\\DO_ALCHOMIKA.ANN|DO_ALCHOMIKA|...}), gdzie druga kolumna + * wygląda jak identyfikator kwestii, choć jest nazwą przejścia. Ścieżka + * w kolumnie zerowej rozstrzyga, że to nie dialog. + */ + private static boolean isTrigger(String column) { + String value = column.trim(); + return !value.isEmpty() + && value.indexOf('\\') < 0 + && value.indexOf('/') < 0 + && value.indexOf('.') < 0; + } + + /** Komentarz autorów: myślniki na początku wiersza albo w którejś kolumnie. */ + private static String sectionLabel(String row, String[] columns) { + var direct = SECTION.matcher(row); + if (direct.matches()) { + return direct.group(1).trim(); + } + for (String column : columns) { + var inside = SECTION.matcher(column.trim()); + if (inside.matches()) { + return inside.group(1).trim(); + } + } + return null; + } + + /** Identyfikatory są bez rozszerzenia, a pliki i wpisy archiwum kończą się na .wav. */ + static String audioName(String id) { + String name = id.toLowerCase(Locale.ROOT); + return name.contains(".") ? name : name + ".wav"; + } + + static String speaker(String id) { + int underscore = id.indexOf('_'); + return underscore <= 0 ? null : id.substring(0, underscore); + } + + private static String blankToNull(String value) { + String trimmed = value == null ? "" : value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private Map> pendingWork(boolean force) throws Exception { + String sql = """ + SELECT c.path AS container, COALESCE(f.path_raw, f.path) AS in_container, + f.blob_sha1 + FROM copy c + JOIN file f ON f.copy_id = c.id + JOIN blob b ON b.sha1 = f.blob_sha1 + LEFT JOIN media m ON m.sha1 = b.sha1 + WHERE c.status <> 'unreadable' AND b.format = 'DTA' + """ + (force ? "" : """ + AND (m.sha1 IS NULL OR m.tool_version <> ?) + """) + "ORDER BY c.id"; + + Map> byContainer = new LinkedHashMap<>(); + try (PreparedStatement st = db.connection().prepareStatement(sql)) { + if (!force) { + st.setString(1, VERSION); + } + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + byContainer.computeIfAbsent(rs.getString("container"), k -> new ArrayList<>()) + .add(new Pending(rs.getString("in_container"), + rs.getString("blob_sha1"))); + } + } + } + return byContainer; + } + + public int pendingCount() throws Exception { + Set unique = new HashSet<>(); + for (List list : pendingWork(false).values()) { + list.forEach(pending -> unique.add(pending.sha1())); + } + return unique.size(); + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/media/MediaIndex.java b/src/main/java/pl/genschu/rexcatalog/media/MediaIndex.java new file mode 100644 index 0000000..19c26d8 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/media/MediaIndex.java @@ -0,0 +1,182 @@ +package pl.genschu.rexcatalog.media; + +import pl.genschu.bloomooemulator.engine.filesystem.AssetSourceDispatcher; +import pl.genschu.bloomooemulator.engine.filesystem.IFileSystem; +import pl.genschu.rexcatalog.db.Database; + +import java.io.File; +import java.io.InputStream; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.Locale; + +/** + * Wyszukuje zasoby po nazwie i podaje ich bajty prosto z obrazu płyty. + * + *

Niczego nie wypakowujemy na dysk: kopia zostaje jednym plikiem, a katalog + * odczytuje z niej fragment na żądanie. Zasób bywa osobnym plikiem albo wpisem + * w archiwum {@code .snd} — dla odbiorcy to bez różnicy, bo jedno i drugie + * adresujemy odciskiem treści. + */ +public final class MediaIndex { + + private final Database db; + + public MediaIndex(Database db) { + this.db = db; + } + + /** + * @param name nazwa pliku do pokazania i pobrania + * @param contentType typ MIME wynikający z rzeczywistej zawartości + * @param inArchive czy zasób leży w archiwum, a nie osobno na płycie + */ + public record Clip(String sha1, String name, String contentType, byte[] bytes, + boolean inArchive) { + } + + /** Miejsce zasobu w kontenerze: {@code offset < 0} oznacza cały plik. */ + private record Location(String container, String pathInContainer, String name, + String format, long offset, int size) { + } + + /** Odczytuje zasób po odcisku treści; {@code null}, gdy nie ma go w katalogu. */ + public Clip read(String sha1) throws Exception { + Location location = locate(sha1); + if (location == null) { + return null; + } + IFileSystem fs = AssetSourceDispatcher.openAssets(new File(location.container())); + byte[] bytes; + + try (InputStream in = fs.open(location.pathInContainer())) { + if (location.offset() < 0) { + bytes = in.readAllBytes(); + } else { + skipFully(in, location.offset()); + bytes = in.readNBytes(location.size()); + } + } + return new Clip(sha1, location.name(), contentType(location.format(), bytes), bytes, + location.offset() >= 0); + } + + /** + * Znajduje odcisk zasobu o danej nazwie w obrębie kopii — najpierw wśród plików, + * potem w archiwach. Tak odwołanie ze skryptu ({@code FILENAME=BUREKTOR_M030.WAV}) + * staje się czymś, co da się odsłuchać. + */ + public String resolve(long copyId, String name) throws Exception { + String needle = name.toLowerCase(Locale.ROOT); + + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT blob_sha1 FROM file + WHERE copy_id = ? AND (path = ? OR path LIKE '%/' || ?) + LIMIT 1 + """)) { + st.setLong(1, copyId); + st.setString(2, needle); + st.setString(3, needle); + try (ResultSet rs = st.executeQuery()) { + if (rs.next()) { + return rs.getString(1); + } + } + } + + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT ae.sha1 FROM archive_entry ae + JOIN file f ON f.blob_sha1 = ae.container_sha1 + WHERE f.copy_id = ? AND ae.name = ? + LIMIT 1 + """)) { + st.setLong(1, copyId); + st.setString(2, needle); + try (ResultSet rs = st.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } + } + + private Location locate(String sha1) throws Exception { + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT c.path AS container, COALESCE(f.path_raw, f.path) AS in_container, + f.path AS canonical, b.format + FROM file f + JOIN copy c ON c.id = f.copy_id + JOIN blob b ON b.sha1 = f.blob_sha1 + WHERE f.blob_sha1 = ? AND c.status <> 'unreadable' + LIMIT 1 + """)) { + st.setString(1, sha1); + try (ResultSet rs = st.executeQuery()) { + if (rs.next()) { + String canonical = rs.getString("canonical"); + int slash = canonical.lastIndexOf('/'); + return new Location(rs.getString("container"), rs.getString("in_container"), + slash < 0 ? canonical : canonical.substring(slash + 1), + rs.getString("format"), -1, 0); + } + } + } + + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT c.path AS container, COALESCE(f.path_raw, f.path) AS in_container, + ae.name, ae.format, ae.offset, ae.size + FROM archive_entry ae + JOIN file f ON f.blob_sha1 = ae.container_sha1 + JOIN copy c ON c.id = f.copy_id + WHERE ae.sha1 = ? AND c.status <> 'unreadable' + LIMIT 1 + """)) { + st.setString(1, sha1); + try (ResultSet rs = st.executeQuery()) { + if (rs.next()) { + return new Location(rs.getString("container"), rs.getString("in_container"), + rs.getString("name"), rs.getString("format"), + rs.getLong("offset"), rs.getInt("size")); + } + } + } + return null; + } + + /** + * Rozszerzenie bywa mylące — wpisy w {@code wav.snd} nazywają się {@code *.wav}, + * a niosą Vorbisa. Rozstrzygają magiczne bajty. + */ + private static String contentType(String format, byte[] bytes) { + if (bytes.length >= 4) { + if (bytes[0] == 'O' && bytes[1] == 'g' && bytes[2] == 'g' && bytes[3] == 'S') { + return "audio/ogg"; + } + if (bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F') { + return "audio/wav"; + } + } + if (format == null) { + return "application/octet-stream"; + } + return switch (format.toUpperCase(Locale.ROOT)) { + case "WAV" -> "audio/wav"; + case "OGG" -> "audio/ogg"; + case "MPG" -> "video/mpeg"; + default -> "application/octet-stream"; + }; + } + + private static void skipFully(InputStream in, long count) throws java.io.IOException { + long remaining = count; + while (remaining > 0) { + long skipped = in.skip(remaining); + if (skipped <= 0) { + if (in.read() < 0) { + throw new java.io.EOFException("kontener krótszy niż pozycja wpisu"); + } + remaining--; + } else { + remaining -= skipped; + } + } + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/media/SndArchive.java b/src/main/java/pl/genschu/rexcatalog/media/SndArchive.java new file mode 100644 index 0000000..11ecab6 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/media/SndArchive.java @@ -0,0 +1,169 @@ +package pl.genschu.rexcatalog.media; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +/** + * Archiwum {@code .snd} — płaski kontener na kwestie mówione. + * + *

Używa go Reksio i Kapitan Nemo: zamiast katalogu z tysiącami plików + * leży jeden {@code wavs/wav.snd} na 79 MB, a w środku 3274 wpisy zakodowane + * w Ogg Vorbisie (na płycie leży {@code oggenc.exe}, więc zgadza się i narzędzie). + * Nazwy wpisów kończą się na {@code .wav} mimo vorbisowej zawartości. + * + *

Układ wpisu, potwierdzony przejściem całego pliku — parser dochodzi + * dokładnie do jego końca, bajt w bajt: + *

+ *   u32  znacznik (zawsze 0 w zbadanych płytach)
+ *   u32  długość payloadu
+ *   u32  długość nazwy razem z bajtem zerowym
+ *   u8[] nazwa w CP1250, zakończona zerem
+ *   u8[] payload
+ * 
+ * + *

{@code :core} tego formatu nie zna, więc parser jest tutaj, a nie w emulatorze. + */ +public final class SndArchive { + + private static final Charset CP1250 = Charset.forName("windows-1250"); + + /** Górny limit długości nazwy — chroni przed próbą alokacji na śmieciach. */ + private static final int MAX_NAME = 512; + + private SndArchive() { + } + + /** + * Jeden wpis archiwum. {@code offset} wskazuje na payload, nie na nagłówek, + * więc wystarcza do odczytania zasobu bez ponownego parsowania kontenera. + */ + public record Entry(String name, String nameRaw, long offset, int size) { + } + + /** Odbiorca wpisów; {@code payload} jest null, gdy przechodzimy tylko po nagłówkach. */ + public interface Visitor { + void entry(Entry entry, byte[] payload) throws IOException; + } + + /** + * Zwraca same wpisy, przeskakując payloady — do odnalezienia zasobu, + * gdy trzeba go tylko podać przeglądarce. + */ + public static List index(InputStream in) throws IOException { + List entries = new ArrayList<>(); + walk(in, false, (entry, payload) -> entries.add(entry)); + return entries; + } + + /** Przechodzi archiwum wczytując payloady — do liczenia odcisków i nagłówków. */ + public static void walk(InputStream in, Visitor visitor) throws IOException { + walk(in, true, visitor); + } + + private static void walk(InputStream in, boolean withPayload, Visitor visitor) + throws IOException { + long position = 0; + byte[] header = new byte[12]; + + while (true) { + int read = readFully(in, header, 0, 12); + if (read == 0) { + return; + } + if (read < 12) { + throw new IOException("obcięty nagłówek wpisu na pozycji " + position); + } + position += 12; + + long payloadSize = u32(header, 4); + long nameLength = u32(header, 8); + if (nameLength <= 0 || nameLength > MAX_NAME) { + throw new IOException("nieprawdopodobna długość nazwy (" + nameLength + + ") na pozycji " + (position - 12)); + } + + byte[] rawName = new byte[(int) nameLength]; + if (readFully(in, rawName, 0, rawName.length) < rawName.length) { + throw new IOException("obcięta nazwa wpisu na pozycji " + position); + } + position += nameLength; + + String nameRaw = new String(rawName, CP1250).replace("\0", ""); + Entry entry = new Entry(nameRaw.toLowerCase(java.util.Locale.ROOT), nameRaw, + position, (int) payloadSize); + + if (withPayload) { + byte[] payload = new byte[(int) payloadSize]; + if (readFully(in, payload, 0, payload.length) < payload.length) { + throw new IOException("obcięty payload wpisu " + nameRaw); + } + visitor.entry(entry, payload); + } else { + visitor.entry(entry, null); + skipFully(in, payloadSize); + } + position += payloadSize; + } + } + + /** Czy strumień w ogóle wygląda na archiwum — po pierwszym wpisie. */ + public static boolean looksLikeArchive(byte[] head) { + if (head.length < 17) { + return false; + } + long nameLength = u32(head, 8); + if (nameLength < 2 || nameLength > MAX_NAME || 12 + nameLength > head.length) { + return false; + } + // nazwa musi być zakończona zerem i mieścić się w drukowalnym ASCII + if (head[(int) (12 + nameLength - 1)] != 0) { + return false; + } + for (int i = 12; i < 12 + nameLength - 1; i++) { + if (head[i] < 0x20) { + return false; + } + } + return true; + } + + private static long u32(byte[] buffer, int offset) { + return (buffer[offset] & 0xFFL) + | ((buffer[offset + 1] & 0xFFL) << 8) + | ((buffer[offset + 2] & 0xFFL) << 16) + | ((buffer[offset + 3] & 0xFFL) << 24); + } + + private static int readFully(InputStream in, byte[] buffer, int offset, int length) + throws IOException { + int total = 0; + while (total < length) { + int read = in.read(buffer, offset + total, length - total); + if (read < 0) { + break; + } + total += read; + } + return total; + } + + private static void skipFully(InputStream in, long count) throws IOException { + long remaining = count; + while (remaining > 0) { + long skipped = in.skip(remaining); + if (skipped <= 0) { + // skip() na niektórych strumieniach nie rusza do przodu na końcu bufora + if (in.read() < 0) { + throw new EOFException("payload krótszy niż deklarowany o " + remaining + " B"); + } + remaining--; + } else { + remaining -= skipped; + } + } + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java b/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java new file mode 100644 index 0000000..7348b13 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/script/RefIndexer.java @@ -0,0 +1,183 @@ +package pl.genschu.rexcatalog.script; + +import pl.genschu.rexcatalog.db.Database; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Wyciąga ze skryptów odwołania do zasobów i zapisuje je w {@code script_ref}. + * + *

Skrypty Piklib są liniowe — {@code OBIEKT:POLE=WARTOŚĆ} — również dla + * zachowań, bo dekoder zwija całe {@code CODE={...}} do jednej linii. Dzięki temu + * ten sam przebieg łapie i deklaracje ({@code SNDQUESTION:FILENAME=BUREKTOR_M030.WAV}), + * i nazwy wplecione w kod ({@code DBDIALOG^LOAD("$COMMON\DIALOGS.DTA")}), a obiekt + * przypisuje się poprawnie w obu przypadkach. + * + *

Odwołania zapisujemy niezależnie od tego, czy plik da się znaleźć: nazwa, + * której w wydaniu nie ma, jest sama w sobie informacją. + */ +public final class RefIndexer { + + public static final String VERSION = "refs-1"; + + /** Rozszerzenia, które w tej kolekcji są zasobami, a nie przypadkowym tekstem. */ + private static final Set EXTENSIONS = Set.of( + "WAV", "OGG", "SND", "IMG", "ANN", "SEQ", "ARR", "DTA", "SEK", "FNT", + "CNV", "CLASS", "DEF", "AVI", "MPG", "TGA", "DDS", "BMP", "SCN", "LUA", + "KIMODEL", "KIANIM"); + + private static final Pattern ASSIGNMENT = + Pattern.compile("^([A-Za-z0-9_]+):([A-Za-z0-9_]+)=(.*)$"); + + /** + * Nazwa nie może być doklejona plusem: {@code "$COMMON\"+ZMIENNA+"_DEF.DTA"} + * buduje ścieżkę w czasie działania, więc {@code _DEF.DTA} nie jest nazwą pliku. + * Podkreślenie na początku samo w sobie niczego nie przesądza — {@code SNDX:FILENAME= + * _WZIECIE_JABLKA.WAV} to prawdziwy plik. + */ + private static final Pattern FILENAME = Pattern.compile( + "(? pending = pendingWork(force); + if (pending.isEmpty()) { + return new Stats(0, 0, 0); + } + + Connection conn = db.connection(); + boolean previousAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); + + int scripts = 0; + int refs = 0; + int failed = 0; + + try (PreparedStatement delete = conn.prepareStatement( + "DELETE FROM script_ref WHERE script_sha1 = ?"); + PreparedStatement insert = conn.prepareStatement(""" + INSERT INTO script_ref (script_sha1, object, field, value, name, format, line) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(script_sha1, object, field, value) DO NOTHING + """)) { + + for (Map.Entry entry : pending.entrySet()) { + String sha1 = entry.getKey(); + try { + String body = Files.readString(dataDir.resolve(entry.getValue()), + StandardCharsets.UTF_8); + delete.setString(1, sha1); + delete.executeUpdate(); + refs += extract(sha1, body, insert); + scripts++; + } catch (Exception e) { + System.err.printf(" ! %s: %s%n", sha1, e.getMessage()); + failed++; + } + } + conn.commit(); + } catch (Exception e) { + conn.rollback(); + throw e; + } finally { + conn.setAutoCommit(previousAutoCommit); + } + + return new Stats(scripts, refs, failed); + } + + private int extract(String sha1, String body, PreparedStatement insert) throws Exception { + String[] lines = body.split("\n", -1); + int written = 0; + + for (int number = 0; number < lines.length; number++) { + Matcher assignment = ASSIGNMENT.matcher(lines[number].trim()); + if (!assignment.matches()) { + continue; + } + String object = assignment.group(1).toUpperCase(Locale.ROOT); + String field = assignment.group(2).toUpperCase(Locale.ROOT); + String rest = assignment.group(3); + + Matcher filename = FILENAME.matcher(rest); + while (filename.find()) { + String value = filename.group(); + insert.setString(1, sha1); + insert.setString(2, object); + insert.setString(3, field); + insert.setString(4, value); + insert.setString(5, basename(value)); + insert.setString(6, filename.group(1).toUpperCase(Locale.ROOT)); + insert.setInt(7, number + 1); + written += insert.executeUpdate(); + } + } + return written; + } + + /** Ścieżki w skryptach bywają z makrem katalogu ({@code $COMMON\...}) — liczy się nazwa. */ + static String basename(String value) { + String path = value.replace('\\', '/'); + int slash = path.lastIndexOf('/'); + return (slash < 0 ? path : path.substring(slash + 1)).toLowerCase(Locale.ROOT); + } + + /** Skrypty z cache'u, dla których nie mamy jeszcze odwołań. */ + private Map pendingWork(boolean force) throws Exception { + String sql = """ + SELECT a.blob_sha1, a.path + FROM artifact a + WHERE a.kind = 'script' + """ + (force ? "" : """ + AND NOT EXISTS (SELECT 1 FROM script_ref r WHERE r.script_sha1 = a.blob_sha1) + """); + + Map out = new LinkedHashMap<>(); + try (PreparedStatement st = db.connection().prepareStatement(sql); + ResultSet rs = st.executeQuery()) { + while (rs.next()) { + out.put(rs.getString("blob_sha1"), rs.getString("path")); + } + } + return out; + } + + public int pendingCount() throws Exception { + return pendingWork(false).size(); + } + + /** Czyści odwołania skryptów, których nie ma już w cache'u. */ + public int prune() throws Exception { + try (Statement st = db.connection().createStatement()) { + return st.executeUpdate(""" + DELETE FROM script_ref + WHERE script_sha1 NOT IN (SELECT blob_sha1 FROM artifact WHERE kind = 'script') + """); + } + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java b/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java new file mode 100644 index 0000000..9380193 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/transcribe/Transcriber.java @@ -0,0 +1,405 @@ +package pl.genschu.rexcatalog.transcribe; + +import pl.genschu.rexcatalog.db.Database; +import pl.genschu.rexcatalog.media.MediaIndex; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Transkrypcja mowy przez whisper.cpp — uruchamiana ręcznie, nigdy sama z siebie. + * + *

Transkrypt jest wygenerowany, a nie odczytany z płyty, więc trafia do + * osobnej tabeli razem z nazwą modelu i narzędzia. Nie miesza się z metadanymi + * wydania ani z faktami detektorów: to hipoteza maszyny o tym, co słychać, a nie + * zapis z nośnika. + * + *

Praca idzie w tle, po jednym pliku, i da się ją przerwać w dowolnym momencie — + * to, co już policzone, zostaje w bazie. Dzięki temu można puścić kolekcję na noc + * i zatrzymać rano bez straty. + */ +public final class Transcriber { + + /** Whisper przyjmuje 16 kHz mono; nagrania z płyt mają 22 050 Hz, więc idą przez ffmpeg. */ + private static final int WHISPER_RATE = 16000; + + /** Nazwy, pod którymi whisper.cpp bywa instalowany. */ + private static final List BINARIES = + List.of("whisper-cli", "whisper-cpp", "whisper", "main"); + + private final Database db; + private final MediaIndex media; + private final Object lock; + + private final AtomicBoolean cancelled = new AtomicBoolean(); + private volatile Thread worker; + private volatile Progress progress = Progress.idle(); + + public Transcriber(Database db, Object lock) { + this.db = db; + this.media = new MediaIndex(db); + this.lock = lock; + } + + /** Gdzie leży whisper.cpp i model; {@code ready} mówi, czy da się w ogóle ruszyć. */ + public record Tool(String binary, String model, boolean ready, String note) { + } + + public record Progress(String state, int total, int done, int failed, String current, + long elapsedMs, String note) { + + static Progress idle() { + return new Progress("bezczynny", 0, 0, 0, null, 0, null); + } + } + + // ---------- wykrywanie narzędzi ---------- + + /** + * Szuka whisper.cpp i modelu. Ścieżki da się wskazać wprost właściwościami + * {@code catalog.whisper.bin} i {@code catalog.whisper.model} — bo model + * to kilkaset megabajtów i każdy trzyma go gdzie indziej. + */ + public static Tool detect() { + String binary = System.getProperty("catalog.whisper.bin", + System.getenv("WHISPER_BIN")); + if (binary == null) { + binary = BINARIES.stream().map(Transcriber::onPath) + .filter(java.util.Objects::nonNull).findFirst().orElse(null); + } + String model = System.getProperty("catalog.whisper.model", + System.getenv("WHISPER_MODEL")); + if (model == null) { + model = findModel(); + } + + if (binary == null) { + return new Tool(null, model, false, + "nie znalazłem whisper.cpp — zainstaluj (brew install whisper-cpp) " + + "albo wskaż przez -Dcatalog.whisper.bin="); + } + if (model == null) { + return new Tool(binary, null, false, + "nie znalazłem modelu — wskaż przez -Dcatalog.whisper.model=ścieżka.bin"); + } + if (!ffmpegAvailable()) { + return new Tool(binary, model, false, + "brakuje ffmpeg, a nagrania trzeba przepróbkować do 16 kHz mono"); + } + return new Tool(binary, model, true, null); + } + + private static String onPath(String name) { + String path = System.getenv("PATH"); + if (path == null) { + return null; + } + for (String directory : path.split(java.io.File.pathSeparator)) { + Path candidate = Path.of(directory, name); + if (Files.isExecutable(candidate)) { + return candidate.toString(); + } + } + return null; + } + + private static String findModel() { + List places = List.of( + Path.of(System.getProperty("user.home"), ".cache", "whisper.cpp"), + Path.of(System.getProperty("user.home"), "Library", "Application Support", + "whisper.cpp"), + Path.of("/opt/homebrew/share/whisper.cpp/models"), + Path.of("/usr/local/share/whisper.cpp/models"), + Path.of("models")); + + for (Path place : places) { + if (!Files.isDirectory(place)) { + continue; + } + try (var stream = Files.list(place)) { + // większy model bywa dokładniejszy, a przy polskim to widać + return stream.filter(p -> p.getFileName().toString().endsWith(".bin")) + .max(java.util.Comparator.comparingLong(Transcriber::sizeOf)) + .map(Path::toString).orElse(null); + } catch (IOException ignored) { + // katalog może zniknąć między sprawdzeniem a listowaniem + } + } + return null; + } + + private static long sizeOf(Path path) { + try { + return Files.size(path); + } catch (IOException e) { + return 0; + } + } + + private static boolean ffmpegAvailable() { + return onPath("ffmpeg") != null; + } + + // ---------- praca ---------- + + /** Co przetranskrybować: cała kolekcja albo jedno wydanie. */ + public record Scope(Integer editionId, int limit, boolean redo) { + } + + private record Job(String sha1, String name, String lang) { + } + + public Progress progress() { + return progress; + } + + public boolean running() { + Thread current = worker; + return current != null && current.isAlive(); + } + + public synchronized void start(Scope scope) throws Exception { + if (running()) { + throw new IllegalStateException("transkrypcja już trwa"); + } + Tool tool = detect(); + if (!tool.ready()) { + throw new IllegalStateException(tool.note()); + } + + List jobs; + synchronized (lock) { + jobs = pending(scope); + } + if (jobs.isEmpty()) { + progress = new Progress("bezczynny", 0, 0, 0, null, 0, + "nie ma czego transkrybować"); + return; + } + + cancelled.set(false); + long started = System.currentTimeMillis(); + progress = new Progress("pracuje", jobs.size(), 0, 0, null, 0, + "model: " + Path.of(tool.model()).getFileName()); + + worker = new Thread(() -> run(tool, jobs, started), "transkrypcja"); + worker.setDaemon(true); + worker.start(); + } + + public void stop() { + cancelled.set(true); + } + + private void run(Tool tool, List jobs, long started) { + int done = 0; + int failed = 0; + Path workDir = null; + + try { + workDir = Files.createTempDirectory("rex-transkrypcja"); + for (Job job : jobs) { + if (cancelled.get()) { + break; + } + progress = new Progress("pracuje", jobs.size(), done, failed, job.name(), + System.currentTimeMillis() - started, progress.note()); + try { + byte[] raw; + synchronized (lock) { + MediaIndex.Clip clip = media.read(job.sha1()); + raw = clip == null ? null : clip.bytes(); + } + if (raw == null) { + failed++; + continue; + } + String text = transcribe(tool, workDir, raw, job.lang()); + if (text == null || text.isBlank()) { + // cisza albo sam efekt dźwiękowy — zapisujemy pustkę, + // żeby nie liczyć tego pliku przy każdym kolejnym przebiegu + text = ""; + } + synchronized (lock) { + store(job, text, tool); + } + done++; + } catch (Exception e) { + System.err.printf(" ! %s: %s%n", job.name(), e.getMessage()); + failed++; + } + } + } catch (Exception e) { + progress = new Progress("błąd", jobs.size(), done, failed, null, + System.currentTimeMillis() - started, e.getMessage()); + return; + } finally { + deleteQuietly(workDir); + } + + progress = new Progress(cancelled.get() ? "przerwany" : "zakończony", jobs.size(), + done, failed, null, System.currentTimeMillis() - started, progress.note()); + } + + /** ffmpeg przepróbkowuje do 16 kHz mono, whisper.cpp wypisuje tekst na standardowe wyjście. */ + private String transcribe(Tool tool, Path workDir, byte[] raw, String lang) throws Exception { + Path wav = workDir.resolve("klip.wav"); + Files.deleteIfExists(wav); + + Process ffmpeg = new ProcessBuilder("ffmpeg", "-hide_banner", "-loglevel", "error", + "-i", "pipe:0", "-ar", String.valueOf(WHISPER_RATE), "-ac", "1", + "-c:a", "pcm_s16le", "-y", wav.toString()) + .redirectErrorStream(true) + .start(); + try (OutputStream in = ffmpeg.getOutputStream()) { + in.write(raw); + } catch (IOException ignored) { + // ffmpeg potrafi zamknąć wejście wcześniej, gdy nagłówek mu wystarczy + } + String ffmpegOutput = new String(ffmpeg.getInputStream().readAllBytes(), + StandardCharsets.UTF_8); + if (ffmpeg.waitFor() != 0) { + throw new IOException("ffmpeg: " + ffmpegOutput.trim()); + } + + Process whisper = new ProcessBuilder(tool.binary(), "-m", tool.model(), + "-l", lang, "-f", wav.toString(), "-nt", "-np") + .start(); + String text = new String(whisper.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String errors = new String(whisper.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + if (whisper.waitFor() != 0) { + throw new IOException("whisper: " + errors.trim()); + } + return text.trim(); + } + + private void store(Job job, String text, Tool tool) throws Exception { + try (PreparedStatement st = db.connection().prepareStatement(""" + INSERT INTO transcript (sha1, text, lang, model, tool, duration_ms, created_at) + VALUES (?, ?, ?, ?, 'whisper.cpp', + (SELECT duration_ms FROM media WHERE sha1 = ?), ?) + ON CONFLICT(sha1) DO UPDATE SET + text = excluded.text, lang = excluded.lang, model = excluded.model, + tool = excluded.tool, created_at = excluded.created_at + """)) { + st.setString(1, job.sha1()); + st.setString(2, text); + st.setString(3, job.lang()); + st.setString(4, Path.of(tool.model()).getFileName().toString()); + st.setString(5, job.sha1()); + st.setString(6, Instant.now().toString()); + st.executeUpdate(); + } + try (PreparedStatement delete = db.connection().prepareStatement( + "DELETE FROM transcript_fts WHERE sha1 = ?"); + PreparedStatement insert = db.connection().prepareStatement( + "INSERT INTO transcript_fts (body, sha1) VALUES (?, ?)")) { + delete.setString(1, job.sha1()); + delete.executeUpdate(); + if (!text.isBlank()) { + insert.setString(1, text); + insert.setString(2, job.sha1()); + insert.executeUpdate(); + } + } + } + + /** + * Wybiera mowę, pomijając efekty dźwiękowe — katalog {@code sfx/} i nazwy + * zaczynające się od {@code sfx_} to nie kwestie, a model i tak wypisałby + * dla nich przypadkowe słowa. + */ + private List pending(Scope scope) throws Exception { + List jobs = new ArrayList<>(); + StringBuilder sql = new StringBuilder(""" + SELECT klip.sha1 AS sha1, klip.name AS name FROM ( + SELECT f.blob_sha1 AS sha1, f.path AS name, g.edition_id AS edition_id + FROM file f + JOIN blob b ON b.sha1 = f.blob_sha1 + JOIN game_root g ON g.copy_id = f.copy_id + WHERE b.format = 'WAV' + UNION + SELECT ae.sha1, ae.name, g.edition_id + FROM archive_entry ae + JOIN file f ON f.blob_sha1 = ae.container_sha1 + JOIN game_root g ON g.copy_id = f.copy_id + ) AS klip + JOIN media m ON m.sha1 = klip.sha1 + LEFT JOIN transcript t ON t.sha1 = klip.sha1 + WHERE m.kind = 'audio' AND COALESCE(m.duration_ms, 0) >= 300 + AND klip.name NOT LIKE '%/sfx/%' + AND klip.name NOT LIKE 'sfx!_%' ESCAPE '!' + AND klip.name NOT LIKE '%/sfx!_%' ESCAPE '!' + """); + if (!scope.redo()) { + sql.append(" AND t.sha1 IS NULL\n"); + } + if (scope.editionId() != null) { + sql.append(" AND klip.edition_id = ?\n"); + } + sql.append("GROUP BY klip.sha1 ORDER BY klip.name LIMIT ?"); + + try (PreparedStatement st = db.connection().prepareStatement(sql.toString())) { + int index = 1; + if (scope.editionId() != null) { + st.setInt(index++, scope.editionId()); + } + st.setInt(index, scope.limit() > 0 ? scope.limit() : 1_000_000); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + String name = rs.getString("name"); + jobs.add(new Job(rs.getString("sha1"), name, languageOf(name))); + } + } + } + return jobs; + } + + /** + * Wersja językowa wynika ze ścieżki: {@code wavs/cze/} to czeski dubbing. + * Kody katalogów są te same, których używa silnik. + */ + static String languageOf(String path) { + String lower = path.toLowerCase(Locale.ROOT); + if (lower.contains("/cze/")) { + return "cs"; + } + if (lower.contains("/hun/")) { + return "hu"; + } + if (lower.contains("/slo/")) { + return "sk"; + } + if (lower.contains("/niem/")) { + return "de"; + } + return "pl"; + } + + private static void deleteQuietly(Path directory) { + if (directory == null) { + return; + } + try (var stream = Files.walk(directory)) { + stream.sorted(java.util.Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // katalog tymczasowy, system i tak go w końcu sprzątnie + } + }); + } catch (IOException ignored) { + // jw. + } + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/web/WebServer.java b/src/main/java/pl/genschu/rexcatalog/web/WebServer.java index dc2eeca..03a049b 100644 --- a/src/main/java/pl/genschu/rexcatalog/web/WebServer.java +++ b/src/main/java/pl/genschu/rexcatalog/web/WebServer.java @@ -5,7 +5,9 @@ import com.google.gson.JsonObject; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import pl.genschu.rexcatalog.db.Database; +import pl.genschu.rexcatalog.media.MediaIndex; import pl.genschu.rexcatalog.script.ScriptSearch; +import pl.genschu.rexcatalog.transcribe.Transcriber; import java.io.IOException; import java.io.InputStream; @@ -30,11 +32,15 @@ public final class WebServer { private final Database db; private final ScriptSearch search; + private final MediaIndex media; + private final Transcriber transcriber; private final Object lock; public WebServer(Database db, Path dataDir, Object lock) { this.db = db; this.search = new ScriptSearch(db, dataDir); + this.media = new MediaIndex(db); + this.transcriber = new Transcriber(db, lock); this.lock = lock; } @@ -88,9 +94,15 @@ public final class WebServer { String path = exchange.getRequestURI().getPath().substring("/api".length()); Map query = parseQuery(exchange.getRequestURI().getRawQuery()); + // dźwięk wychodzi bajtami, a nie JSON-em — obsługiwany przed resztą tras + if (path.startsWith("/audio/")) { + serveAudio(exchange, path.substring("/audio/".length())); + return; + } + Object payload; synchronized (lock) { - payload = route(path, query); + payload = route(path, query, exchange.getRequestMethod()); } if (payload == null) { respond(exchange, 404, "application/json", @@ -107,7 +119,8 @@ public final class WebServer { } } - private Object route(String path, Map query) throws Exception { + private Object route(String path, Map query, String method) + throws Exception { if (path.equals("/stats")) { return stats(); } @@ -130,9 +143,103 @@ public final class WebServer { if (path.startsWith("/scripts/")) { return script(path.substring("/scripts/".length())); } + if (path.equals("/audio-search")) { + return audioSearch(query.getOrDefault("q", ""), parseInt(query.get("limit"), 60)); + } + if (path.startsWith("/media/")) { + return mediaInfo(path.substring("/media/".length())); + } + if (path.equals("/transcribe/status")) { + return transcribeStatus(); + } + // start i stop zmieniają stan, więc tylko POST — GET bywa wywołany przez + // podgląd odsyłacza albo wczytanie z historii, a to godziny liczenia + if (path.equals("/transcribe/start")) { + return "POST".equals(method) ? transcribeStart(query) : methodNotAllowed(); + } + if (path.equals("/transcribe/stop")) { + if (!"POST".equals(method)) { + return methodNotAllowed(); + } + transcriber.stop(); + return transcribeStatus(); + } return null; } + private JsonObject methodNotAllowed() { + JsonObject out = new JsonObject(); + out.addProperty("error", "ten zasób wymaga metody POST"); + return out; + } + + // ---------- dźwięk ---------- + + /** + * Podaje nagranie prosto z obrazu płyty. Nic nie ląduje na dysku — kopia + * zostaje jednym plikiem, a przeglądarka dostaje wycięty z niej fragment. + */ + private void serveAudio(HttpExchange exchange, String sha1) throws IOException { + MediaIndex.Clip clip; + try { + synchronized (lock) { + clip = media.read(sha1); + } + } catch (Exception e) { + respond(exchange, 500, "text/plain", + ("Nie odczytałem nagrania: " + e.getMessage()).getBytes(UTF8)); + return; + } + if (clip == null) { + respond(exchange, 404, "text/plain", "Nie ma takiego zasobu".getBytes(UTF8)); + return; + } + exchange.getResponseHeaders().set("Content-Type", clip.contentType()); + exchange.getResponseHeaders().set("Cache-Control", "public, max-age=86400"); + exchange.sendResponseHeaders(200, clip.bytes().length); + exchange.getResponseBody().write(clip.bytes()); + } + + // ---------- transkrypcja ---------- + + private JsonObject transcribeStatus() { + Transcriber.Tool tool = Transcriber.detect(); + Transcriber.Progress progress = transcriber.progress(); + + JsonObject out = new JsonObject(); + out.addProperty("ready", tool.ready()); + out.addProperty("tool_note", tool.note()); + out.addProperty("binary", tool.binary()); + out.addProperty("model", tool.model()); + out.addProperty("running", transcriber.running()); + out.addProperty("state", progress.state()); + out.addProperty("total", progress.total()); + out.addProperty("done", progress.done()); + out.addProperty("failed", progress.failed()); + out.addProperty("current", progress.current()); + out.addProperty("elapsed_ms", progress.elapsedMs()); + out.addProperty("note", progress.note()); + return out; + } + + private JsonObject transcribeStart(Map query) throws Exception { + Integer edition = query.containsKey("edition") + ? parseInt(query.get("edition"), -1) : null; + if (edition != null && edition < 0) { + edition = null; + } + try { + transcriber.start(new Transcriber.Scope(edition, + parseInt(query.get("limit"), 0), + "1".equals(query.get("redo")))); + } catch (IllegalStateException e) { + JsonObject out = transcribeStatus(); + out.addProperty("error", e.getMessage()); + return out; + } + return transcribeStatus(); + } + private JsonObject stats() throws Exception { JsonObject out = new JsonObject(); out.addProperty("copies", scalar("SELECT COUNT(*) FROM copy")); @@ -142,6 +249,13 @@ public final class WebServer { out.addProperty("blobs", scalar("SELECT COUNT(*) FROM blob")); out.addProperty("scripts", scalar("SELECT COUNT(*) FROM script_fts")); out.addProperty("bytes", scalar("SELECT COALESCE(SUM(size), 0) FROM blob")); + out.addProperty("audio", scalar("SELECT COUNT(*) FROM media WHERE kind = 'audio'")); + out.addProperty("audio_ms", + scalar("SELECT COALESCE(SUM(duration_ms), 0) FROM media WHERE kind = 'audio'")); + out.addProperty("archive_entries", scalar("SELECT COUNT(*) FROM archive_entry")); + out.addProperty("dialogue", scalar("SELECT COUNT(*) FROM dialogue_line")); + out.addProperty("refs", scalar("SELECT COUNT(*) FROM script_ref")); + out.addProperty("transcripts", scalar("SELECT COUNT(*) FROM transcript")); JsonArray formats = new JsonArray(); try (PreparedStatement st = db.connection().prepareStatement( @@ -365,6 +479,283 @@ public final class WebServer { out.addProperty("body", body); out.add("paths", toArray(search.pathsFor(sha1))); out.add("games", toArray(search.gamesFor(sha1))); + out.add("refs", refs(sha1)); + return out; + } + + /** + * Zasoby, do których odwołuje się skrypt, rozwiązane do konkretnych plików. + * + *

To jest sedno przeglądania: przy {@code SNDQUESTION:FILENAME=BUREKTOR_M030.WAV} + * widać od razu, ile ta kwestia trwa, kto ją wypowiada i przy jakim zdarzeniu + * pada — bez otwierania gry. + */ + private JsonArray refs(String scriptSha1) throws Exception { + JsonArray out = new JsonArray(); + try (PreparedStatement st = db.connection().prepareStatement(""" + WITH kopia AS ( + SELECT f.copy_id FROM file f WHERE f.blob_sha1 = ? LIMIT 1 + ) + SELECT r.object, r.field, r.value, r.name, r.format, r.line, + COALESCE( + (SELECT f2.blob_sha1 FROM file f2, kopia + WHERE f2.copy_id = kopia.copy_id + AND (f2.path = r.name OR f2.path LIKE '%/' || r.name) LIMIT 1), + (SELECT ae.sha1 FROM archive_entry ae + JOIN file f3 ON f3.blob_sha1 = ae.container_sha1, kopia + WHERE f3.copy_id = kopia.copy_id AND ae.name = r.name LIMIT 1) + ) AS target, + (SELECT d.speaker FROM dialogue_line d + WHERE d.audio_name = r.name LIMIT 1) AS speaker, + (SELECT d.trigger FROM dialogue_line d + WHERE d.audio_name = r.name LIMIT 1) AS trigger + FROM script_ref r + WHERE r.script_sha1 = ? + ORDER BY r.line, r.object + """)) { + st.setString(1, scriptSha1); + st.setString(2, scriptSha1); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + JsonObject row = new JsonObject(); + for (String column : List.of("object", "field", "value", "name", "format", + "target", "speaker", "trigger")) { + row.addProperty(column, rs.getString(column)); + } + row.addProperty("line", rs.getInt("line")); + out.add(row); + } + } + } + + // fakty o zasobach dokładamy jednym przebiegiem, żeby nie pytać bazy po razie na wiersz + java.util.Set targets = new java.util.LinkedHashSet<>(); + for (var element : out) { + String target = element.getAsJsonObject().get("target").isJsonNull() + ? null : element.getAsJsonObject().get("target").getAsString(); + if (target != null) { + targets.add(target); + } + } + Map facts = mediaFacts(targets); + for (var element : out) { + JsonObject row = element.getAsJsonObject(); + if (!row.get("target").isJsonNull()) { + JsonObject fact = facts.get(row.get("target").getAsString()); + if (fact != null) { + row.add("media", fact); + } + } + } + return out; + } + + private Map mediaFacts(java.util.Set sha1s) throws Exception { + Map out = new LinkedHashMap<>(); + if (sha1s.isEmpty()) { + return out; + } + String placeholders = String.join(",", java.util.Collections.nCopies(sha1s.size(), "?")); + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT m.sha1, m.codec, m.duration_ms, m.sample_rate, m.channels, + t.text AS transcript, t.model AS transcript_model + FROM media m + LEFT JOIN transcript t ON t.sha1 = m.sha1 + WHERE m.sha1 IN (%s) + """.formatted(placeholders))) { + int index = 1; + for (String sha1 : sha1s) { + st.setString(index++, sha1); + } + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + JsonObject row = new JsonObject(); + row.addProperty("codec", rs.getString("codec")); + row.addProperty("duration_ms", rs.getInt("duration_ms")); + row.addProperty("sample_rate", rs.getInt("sample_rate")); + row.addProperty("channels", rs.getInt("channels")); + row.addProperty("transcript", rs.getString("transcript")); + row.addProperty("transcript_model", rs.getString("transcript_model")); + out.put(rs.getString("sha1"), row); + } + } + } + return out; + } + + /** + * Szuka nagrań po nazwie, a gdy transkrypty już są — również po tym, co słychać. + * + *

Nazwa i treść to dwa różne źródła, więc trafienia z transkryptu są osobno + * oznaczone: jedno jest zapisane na płycie, drugie zgadnięte przez model. + */ + private JsonObject audioSearch(String query, int limit) throws Exception { + JsonObject out = new JsonObject(); + JsonArray hits = new JsonArray(); + out.addProperty("query", query); + out.add("hits", hits); + if (query.isBlank()) { + return out; + } + + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT klip.sha1, klip.name, klip.copy, m.duration_ms, m.codec, + (SELECT d.speaker FROM dialogue_line d + WHERE d.audio_name = klip.short LIMIT 1) AS speaker, + t.text AS transcript, + CASE WHEN instr(lower(klip.name), lower(?)) > 0 THEN 1 ELSE 0 END AS by_name + FROM ( + SELECT f.blob_sha1 AS sha1, COALESCE(f.path_raw, f.path) AS name, + CASE WHEN instr(f.path, '/') > 0 + THEN replace(f.path, rtrim(f.path, replace(f.path, '/', '')), '') + ELSE f.path END AS short, + c.display_name AS copy + FROM file f JOIN blob b ON b.sha1 = f.blob_sha1 + JOIN copy c ON c.id = f.copy_id + WHERE b.format = 'WAV' + UNION + SELECT ae.sha1, ae.name_raw, ae.name, c.display_name + FROM archive_entry ae + JOIN file f ON f.blob_sha1 = ae.container_sha1 + JOIN copy c ON c.id = f.copy_id + ) AS klip + JOIN media m ON m.sha1 = klip.sha1 + LEFT JOIN transcript t ON t.sha1 = klip.sha1 + WHERE instr(lower(klip.name), lower(?)) > 0 + OR (t.text IS NOT NULL AND instr(lower(t.text), lower(?)) > 0) + GROUP BY klip.sha1 + ORDER BY by_name DESC, klip.name + LIMIT ? + """)) { + st.setString(1, query); + st.setString(2, query); + st.setString(3, query); + st.setInt(4, Math.min(limit, 300)); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + JsonObject row = new JsonObject(); + row.addProperty("sha1", rs.getString("sha1")); + row.addProperty("name", rs.getString("name")); + row.addProperty("copy", rs.getString("copy")); + row.addProperty("duration_ms", rs.getInt("duration_ms")); + row.addProperty("codec", rs.getString("codec")); + row.addProperty("speaker", rs.getString("speaker")); + row.addProperty("transcript", rs.getString("transcript")); + row.addProperty("by_name", rs.getInt("by_name") == 1); + hits.add(row); + } + } + } + return out; + } + + /** Wszystko, co katalog wie o jednym zasobie: fakty, miejsca, dialog, transkrypt. */ + private JsonObject mediaInfo(String sha1) throws Exception { + JsonObject out = new JsonObject(); + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT m.sha1, m.kind, m.codec, m.duration_ms, m.sample_rate, m.channels, + m.bits, m.note, t.text AS transcript, t.model AS transcript_model, + t.lang AS transcript_lang, t.created_at AS transcript_at + FROM media m + LEFT JOIN transcript t ON t.sha1 = m.sha1 + WHERE m.sha1 = ? + """)) { + st.setString(1, sha1); + try (ResultSet rs = st.executeQuery()) { + if (!rs.next()) { + return null; + } + for (String column : List.of("kind", "codec", "note", "transcript", + "transcript_model", "transcript_lang", "transcript_at")) { + out.addProperty(column, rs.getString(column)); + } + out.addProperty("sha1", sha1); + out.addProperty("duration_ms", rs.getInt("duration_ms")); + out.addProperty("sample_rate", rs.getInt("sample_rate")); + out.addProperty("channels", rs.getInt("channels")); + out.addProperty("bits", rs.getInt("bits")); + } + } + + JsonArray places = new JsonArray(); + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT COALESCE(f.path_raw, f.path) AS path, c.display_name, 0 AS in_archive + FROM file f JOIN copy c ON c.id = f.copy_id WHERE f.blob_sha1 = ? + UNION ALL + SELECT ae.name_raw, c.display_name, 1 + FROM archive_entry ae + JOIN file f ON f.blob_sha1 = ae.container_sha1 + JOIN copy c ON c.id = f.copy_id + WHERE ae.sha1 = ? + """)) { + st.setString(1, sha1); + st.setString(2, sha1); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + JsonObject row = new JsonObject(); + row.addProperty("path", rs.getString(1)); + row.addProperty("copy", rs.getString(2)); + row.addProperty("in_archive", rs.getInt(3) == 1); + places.add(row); + } + } + } + out.add("places", places); + + JsonArray dialogue = new JsonArray(); + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT DISTINCT d.speaker, d.trigger, d.section, d.extra + FROM dialogue_line d + WHERE d.audio_name IN ( + SELECT CASE WHEN instr(f.path, '/') > 0 + THEN replace(f.path, rtrim(f.path, replace(f.path, '/', '')), '') + ELSE f.path END + FROM file f WHERE f.blob_sha1 = ? + UNION SELECT ae.name FROM archive_entry ae WHERE ae.sha1 = ? + ) + """)) { + st.setString(1, sha1); + st.setString(2, sha1); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + JsonObject row = new JsonObject(); + row.addProperty("speaker", rs.getString("speaker")); + row.addProperty("trigger", rs.getString("trigger")); + row.addProperty("section", rs.getString("section")); + row.addProperty("extra", rs.getString("extra")); + dialogue.add(row); + } + } + } + out.add("dialogue", dialogue); + + JsonArray usedBy = new JsonArray(); + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT DISTINCT r.script_sha1, r.object, r.field, a.path + FROM script_ref r + JOIN artifact a ON a.blob_sha1 = r.script_sha1 AND a.kind = 'script' + WHERE r.name IN ( + SELECT CASE WHEN instr(f.path, '/') > 0 + THEN replace(f.path, rtrim(f.path, replace(f.path, '/', '')), '') + ELSE f.path END + FROM file f WHERE f.blob_sha1 = ? + UNION SELECT ae.name FROM archive_entry ae WHERE ae.sha1 = ? + ) + LIMIT 50 + """)) { + st.setString(1, sha1); + st.setString(2, sha1); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + JsonObject row = new JsonObject(); + row.addProperty("script_sha1", rs.getString("script_sha1")); + row.addProperty("object", rs.getString("object")); + row.addProperty("field", rs.getString("field")); + usedBy.add(row); + } + } + } + out.add("used_by", usedBy); return out; } diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 672b32a..6375db1 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -34,6 +34,9 @@ nav button { } nav button.on { background: var(--accent); border-color: var(--accent); color: #fff; } main { padding: 18px 20px; max-width: 1500px; } +/* własna reguła display bije [hidden] z arkusza przeglądarki, więc sekcja + z klasą .cols pokazywałaby się mimo ukrycia — stąd to wymuszenie */ +[hidden] { display: none !important; } .cols { display: grid; grid-template-columns: minmax(320px, 460px) 1fr; gap: 18px; align-items: start; } @media (max-width: 900px) { .cols { grid-template-columns: 1fr; } } .card { @@ -81,6 +84,19 @@ h2 { font-size: 14px; margin: 0 0 10px; } h3 { font-size: 12px; margin: 16px 0 6px; color: var(--dim); text-transform: uppercase; letter-spacing: .05em; } a { color: var(--accent); } +.zasob { display: grid; grid-template-columns: 1fr auto; gap: 6px 12px; + padding: 7px 8px; border-bottom: 1px solid var(--line); align-items: center; } +.zasob:last-child { border-bottom: 0; } +.zasob audio { height: 30px; width: 240px; } +.kwestia { grid-column: 1 / -1; font-size: 12.5px; padding: 2px 0 0 2px; + border-left: 2px solid var(--accent); padding-left: 8px; } +.maszyna { color: var(--dim); font-size: 11px; font-style: italic; } +.postep { display: flex; align-items: center; gap: 10px; margin: 10px 0; } +.postep .bar { flex: 1; height: 10px; } +button.akcja { border: 1px solid var(--line); background: var(--panel); color: var(--ink); + padding: 6px 14px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12.5px; } +button.akcja:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +button.akcja:disabled { opacity: .45; cursor: default; } @@ -91,6 +107,7 @@ a { color: var(--accent); }

@@ -124,6 +141,28 @@ a { color: var(--accent); }
Wybierz trafienie, żeby zobaczyć skrypt.
+ +