Compare commits
13
Commits
aaf2073ff7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a870a60bb | ||
|
|
c2036e1f72 | ||
|
|
20ce26364c | ||
|
|
b99f239553 | ||
|
|
a773d791a7 | ||
|
|
930cfb3462 | ||
|
|
cf6fd4e55e | ||
|
|
f1b36f0e29 | ||
|
|
6feb2254b2 | ||
|
|
285b4ed316 | ||
|
|
02988297ec | ||
|
|
393bcde6a8 | ||
|
|
2a051c561b |
@@ -0,0 +1,12 @@
|
||||
# submoduł vendor/ MUSI zostać — composite build kompiluje z niego :core
|
||||
.git
|
||||
.gitmodules
|
||||
.gradle
|
||||
.idea
|
||||
build
|
||||
out
|
||||
data
|
||||
collection
|
||||
vendor/Rex-EMoolator/.git
|
||||
vendor/Rex-EMoolator/build
|
||||
vendor/Rex-EMoolator/.gradle
|
||||
@@ -7,3 +7,6 @@ out/
|
||||
|
||||
# baza i cache pochodnych — generowane, nie wersjonowane
|
||||
data/
|
||||
|
||||
# modele whisper.cpp montowane do kontenera — kilkaset MB, nie wersjonujemy
|
||||
models/
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# Build wymaga submodułu vendor/Rex-EMoolator — composite build kompiluje :core
|
||||
# ze źródeł. Jeśli katalog jest pusty, settings.gradle przerwie z komunikatem.
|
||||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /src
|
||||
|
||||
# najpierw sam opis buildu: zmiana w kodzie nie unieważnia pobranych zależności
|
||||
COPY gradlew settings.gradle build.gradle gradle.properties ./
|
||||
COPY gradle ./gradle
|
||||
COPY vendor ./vendor
|
||||
RUN ./gradlew --no-daemon dependencies --configuration runtimeClasspath > /dev/null 2>&1 || true
|
||||
|
||||
COPY src ./src
|
||||
# installDist zamiast build: pomija check, a więc i verifyCoreVersion, które
|
||||
# potrzebowałoby gita w obrazie. Wersję i tak przypina submoduł.
|
||||
RUN ./gradlew --no-daemon installDist \
|
||||
&& grep '^coreVersion=' gradle.properties | cut -d= -f2 > /src/core-version
|
||||
|
||||
# whisper.cpp budowany ze źródeł: w repozytoriach Debiana go nie ma, a binarka
|
||||
# z hosta jest nie do użycia — tam macOS, tu Linux.
|
||||
#
|
||||
# GGML_NATIVE=OFF, bo domyślnie ggml stroi kod pod procesor maszyny budującej
|
||||
# (na arm64 przekazuje -mcpu=native+nodotprod..., czego GCC 12 nie rozumie),
|
||||
# a obraz i tak ma chodzić także na innym sprzęcie niż ten, na którym powstał
|
||||
FROM debian:bookworm-slim AS whisper
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git cmake build-essential ca-certificates \
|
||||
&& git clone --depth 1 --branch v1.9.2 https://github.com/ggerganov/whisper.cpp /src \
|
||||
&& cmake -S /src -B /src/build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \
|
||||
-DGGML_NATIVE=OFF -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \
|
||||
&& cmake --build /src/build --target whisper-cli -j "$(nproc)" \
|
||||
&& mkdir -p /out/bin /out/lib \
|
||||
&& cp /src/build/bin/whisper-cli /out/bin/ \
|
||||
&& find /src/build \( -name 'libwhisper.so*' -o -name 'libggml*.so*' \) \
|
||||
-exec cp -P {} /out/lib/ \; \
|
||||
&& rm -rf /var/lib/apt/lists/* /src
|
||||
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
|
||||
# obrazy płyt montowane z zewnątrz, tylko do odczytu; katalog danych to wolumen
|
||||
VOLUME ["/data"]
|
||||
|
||||
# whisper.cpp i ffmpeg wchodzą do obrazu, bo to kilkadziesiąt megabajtów.
|
||||
# Model NIE wchodzi: waży kilkaset megabajtów do kilku gigabajtów, każdy trzyma
|
||||
# inny i wybór należy do użytkownika — montuje się go pod /models i wskazuje
|
||||
# przez WHISPER_MODEL. Bez modelu katalog działa normalnie, a front mówi wprost,
|
||||
# czego brakuje: mówcę i kontekst kwestii bierzemy z dialogi.dta, nie z modelu.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# kopiujemy wprost z drzewa budowania: `cmake --install` chciałby zainstalować
|
||||
# wszystkie przykłady, a budujemy tylko whisper-cli
|
||||
COPY --from=whisper /out/bin/whisper-cli /usr/local/bin/whisper-cli
|
||||
COPY --from=whisper /out/lib/ /usr/local/lib/
|
||||
RUN ldconfig
|
||||
ENV WHISPER_BIN=/usr/local/bin/whisper-cli
|
||||
# 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
|
||||
|
||||
COPY --from=build /src/build/install/rex-catalog /app
|
||||
COPY --from=build /src/core-version /app/core-version
|
||||
COPY docker-entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh \
|
||||
&& useradd --uid 10001 --create-home katalog \
|
||||
&& mkdir -p /data /media \
|
||||
&& chown katalog:katalog /data
|
||||
USER katalog
|
||||
|
||||
EXPOSE 8765
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
# domyślnie wstajemy jako serwer; front i MCP dzielą port
|
||||
CMD ["serve", "--host", "0.0.0.0"]
|
||||
@@ -0,0 +1,76 @@
|
||||
# Wariant pod NVIDIĘ — do transkrypcji na maszynie z kartą.
|
||||
#
|
||||
# Osobny plik, a nie przełącznik w Dockerfile, bo różnią się obrazy bazowe obu
|
||||
# etapów: whisper.cpp trzeba zbudować przy toolkicie CUDA, a obraz uruchomieniowy
|
||||
# musi nieść biblioteki CUDA i dopiero do nich dołożyć JRE. Wciśnięcie tego w
|
||||
# jeden plik dałoby więcej warunków niż treści.
|
||||
#
|
||||
# Budowanie i uruchomienie (wymaga NVIDIA Container Toolkit po stronie hosta;
|
||||
# na Windowsie: Docker Desktop z WSL2):
|
||||
# docker build -f Dockerfile.cuda -t rex-catalog:cuda .
|
||||
# docker run --rm --gpus all -v D:\Reksio:/media:ro -v katalog:/data \
|
||||
# -v D:\modele:/models:ro -e WHISPER_MODEL=/models/ggml-medium.bin \
|
||||
# rex-catalog:cuda transcribe
|
||||
#
|
||||
# Po przeniesieniu bazy z innej maszyny najpierw przypnij ją do kolekcji:
|
||||
# docker run --rm -v D:\Reksio:/media:ro -v katalog:/data rex-catalog:cuda relocate /media
|
||||
|
||||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY gradlew settings.gradle build.gradle gradle.properties ./
|
||||
COPY gradle ./gradle
|
||||
COPY vendor ./vendor
|
||||
RUN ./gradlew --no-daemon dependencies --configuration runtimeClasspath > /dev/null 2>&1 || true
|
||||
|
||||
COPY src ./src
|
||||
RUN ./gradlew --no-daemon installDist \
|
||||
&& grep '^coreVersion=' gradle.properties | cut -d= -f2 > /src/core-version
|
||||
|
||||
# CMAKE_CUDA_ARCHITECTURES=86 to Ampere z GeForce RTX 30 (3060 ma 8.6).
|
||||
# Budowanie pod wszystkie architektury trwa wielokrotnie dłużej i puchnie,
|
||||
# a i tak nie przyda się na innej karcie bez przebudowy.
|
||||
FROM nvidia/cuda:12.6.2-devel-ubuntu24.04 AS whisper
|
||||
ARG CUDA_ARCH=86
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git cmake build-essential ca-certificates \
|
||||
&& git clone --depth 1 --branch v1.9.2 https://github.com/ggerganov/whisper.cpp /src \
|
||||
&& cmake -S /src -B /src/build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \
|
||||
-DGGML_NATIVE=OFF -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=${CUDA_ARCH} \
|
||||
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \
|
||||
&& cmake --build /src/build --target whisper-cli -j "$(nproc)" \
|
||||
&& mkdir -p /out/bin /out/lib \
|
||||
&& cp /src/build/bin/whisper-cli /out/bin/ \
|
||||
&& find /src/build \( -name 'libwhisper.so*' -o -name 'libggml*.so*' \) \
|
||||
-exec cp -P {} /out/lib/ \; \
|
||||
&& rm -rf /var/lib/apt/lists/* /src
|
||||
|
||||
FROM nvidia/cuda:12.6.2-runtime-ubuntu24.04
|
||||
WORKDIR /app
|
||||
|
||||
VOLUME ["/data"]
|
||||
ENV CATALOG_DATA=/data
|
||||
ENV WHISPER_BIN=/usr/local/bin/whisper-cli
|
||||
|
||||
# JRE z repozytoriów, bo obraz bazowy niesie CUDA, a nie Javę; ffmpeg do
|
||||
# przepróbkowania nagrań na 16 kHz mono, których whisper.cpp wymaga
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openjdk-21-jre-headless ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=whisper /out/bin/whisper-cli /usr/local/bin/whisper-cli
|
||||
COPY --from=whisper /out/lib/ /usr/local/lib/
|
||||
RUN ldconfig
|
||||
|
||||
COPY --from=build /src/build/install/rex-catalog /app
|
||||
COPY --from=build /src/core-version /app/core-version
|
||||
COPY docker-entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh \
|
||||
&& useradd --uid 10001 --create-home katalog \
|
||||
&& mkdir -p /data /media \
|
||||
&& chown katalog:katalog /data
|
||||
USER katalog
|
||||
|
||||
EXPOSE 8765
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["serve", "--host", "0.0.0.0"]
|
||||
@@ -0,0 +1,168 @@
|
||||
# rex-catalog
|
||||
|
||||
Katalog kolekcji gier Aidem Media — serii *Reksio* i *Poznaj Mity*. Indeksuje obrazy płyt,
|
||||
zagląda do środka i pozwala je przeglądać: odszyfrowane skrypty, odtwarzalne kwestie
|
||||
mówione, podgląd grafiki i animacji oraz powiązania między nimi.
|
||||
|
||||
Powstał do celów archiwizacyjnych i testów emulatora. Niczego nie modyfikuje w kolekcji —
|
||||
obrazy płyt otwiera wyłącznie do odczytu, a wszystko, co wytworzy, ląduje w osobnym
|
||||
katalogu `data/`.
|
||||
|
||||
## Skąd bierze wiedzę o formatach
|
||||
|
||||
Formaty Aidem — zaszyfrowane skrypty CNV, obrazy `PIK\0`, animacje `NVP\0`, kompresje
|
||||
CLZW i CRLE — czyta przez `:core` z [Rex-EMoolatora](https://github.com/patryk025/Rex-EMoolator),
|
||||
podpiętego jako **composite build**, a nie fork. Emulator jest przypięty submodułem na
|
||||
konkretnym tagu (`gradle.properties` → `coreVersion`), a zadanie `verifyCoreVersion`
|
||||
pilnuje, żeby tag i submoduł się nie rozjechały.
|
||||
|
||||
Dzięki temu wiedza o formatach ma jedno źródło. Tam, gdzie katalog musiał napisać własny
|
||||
parser, jest to powiedziane wprost w komentarzu razem z powodem — najczęściej dlatego,
|
||||
że loader z `:core` buduje tekstury OpenGL-a i w procesie bez okna nie da się go użyć.
|
||||
|
||||
## Jak to jest poukładane
|
||||
|
||||
**Trzy poziomy tożsamości.** `title` (dzieło) → `edition` (wydanie) → `copy` (konkretny
|
||||
plik na dysku). Między wydaniem a kopią stoi jeszcze `game_root`, bo jedna płyta bywa
|
||||
dwupackiem z dwiema grami, każdą z własnym silnikiem.
|
||||
|
||||
**Plik osobno, treść osobno.** `file` mówi, gdzie coś leży, `blob` — czym jest. Porównanie
|
||||
dwóch wydań jest więc operacją na zbiorach odcisków, a wszystko, co wytworzone
|
||||
(odszyfrowany skrypt, miniatura), kluczuje się treścią: ten sam plik na trzech płytach
|
||||
przerabiamy raz.
|
||||
|
||||
**Fakty z dowodem.** Detektory nie nadpisują pól, tylko dopisują wiersze do `detection`
|
||||
z wartością, pewnością i dowodem. Poprawki człowieka siedzą osobno, w polach `*_override`,
|
||||
i nigdy nie są nadpisywane przez kolejny przebieg.
|
||||
|
||||
**Nic nie wypakowujemy.** Dźwięk i klatki animacji czytamy z obrazu płyty na żądanie,
|
||||
sięgając od razu na właściwą pozycję. W `data/` zostaje tylko to, co drogo policzyć:
|
||||
odszyfrowane skrypty i miniatury.
|
||||
|
||||
## Uruchomienie
|
||||
|
||||
Potrzebne: **JDK 21** i submoduł z emulatorem.
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
./gradlew installDist
|
||||
```
|
||||
|
||||
Dalej wygodnie przez launcher (`build/install/rex-catalog/bin/rex-catalog`) albo
|
||||
`./gradlew run --args="..."`. Katalog bazy wskazuje `-Dcatalog.data` (domyślnie `./data`).
|
||||
|
||||
### Potok
|
||||
|
||||
```bash
|
||||
rex-catalog ingest /ścieżka/do/kolekcji # indeksuje obrazy, katalogi gier i całe kolekcje
|
||||
rex-catalog decode # odszyfrowuje skrypty do cache'u i indeksu FTS5
|
||||
rex-catalog probe # dźwięk, grafika, dialogi, powiązania
|
||||
rex-catalog analyze # metadane z application.def i install.ini
|
||||
rex-catalog promote # tworzy tytuły i wydania z wykrytych faktów
|
||||
```
|
||||
|
||||
Każdy krok jest **przyrostowy** — dołożenie nowej płyty przerabia tylko ją. Ponowne
|
||||
uruchomienie bez `--force` nie robi nic, jeśli nic się nie zmieniło.
|
||||
|
||||
`ingest` przyjmuje obraz `.iso` (ISO 9660, Joliet i UDF), archiwum `.zip`, katalog
|
||||
rozpakowanej gry albo katalog z całą kolekcją.
|
||||
|
||||
### Przeglądanie
|
||||
|
||||
```bash
|
||||
rex-catalog serve # front i MCP na http://127.0.0.1:8765
|
||||
rex-catalog find CANVAS_OBSERVER # przeszukanie skryptów (składnia FTS5)
|
||||
rex-catalog cat arcade.cnv # odszyfrowany skrypt na wyjście
|
||||
rex-catalog titles # lista wydań z metadanymi
|
||||
```
|
||||
|
||||
Front otwiera się na widoku **Zasoby** — paginowanej galerii obrazów, animacji i nagrań,
|
||||
filtrowanej po grze, rodzaju i nazwie. Zaznaczenie otwiera inspektor z pełnym podglądem,
|
||||
parametrami i miejscami użycia. Animacje można odtwarzać w tempie zapisanym w ANN — jako
|
||||
wszystkie obrazy albo konkretne nazwane zdarzenie, np. `BEZRUCH` czy `GADA_1`, razem z jego
|
||||
właściwą kolejnością klatek i zakresem pętli. Dalej są **Skrypty** (wyszukiwanie
|
||||
pełnotekstowe i podgląd z listą użytych zasobów), **Dźwięk** (wyszukiwanie nagrań,
|
||||
odtwarzacz, transkrypcja), **Kolekcja** (tytuły, wydania, kopie, pliki) i **Statystyki**.
|
||||
|
||||
Pod `/mcp` stoi serwer MCP (Streamable HTTP, tylko do odczytu) z dziewięcioma narzędziami —
|
||||
od `list_titles` i `search_scripts` po `get_script_assets`, które pokazuje, co dany skrypt
|
||||
odtwarza, kto to mówi i jak długo trwa.
|
||||
|
||||
### Metadane ręczne
|
||||
|
||||
Tego, czego nie da się wykryć, nie zgadujemy:
|
||||
|
||||
```bash
|
||||
rex-catalog set copy "Wojna Troj" source_kind=wlasny_zgraj rip_tool=dd
|
||||
rex-catalog set edition 3 distributor="Aidem Media" release_date_override=2003-11-18
|
||||
rex-catalog lang add 3 cs audio
|
||||
```
|
||||
|
||||
## Transkrypcja mowy
|
||||
|
||||
Opcjonalna i uruchamiana świadomie — wymaga [whisper.cpp](https://github.com/ggerganov/whisper.cpp)
|
||||
i modelu, których repozytorium nie dostarcza.
|
||||
|
||||
```bash
|
||||
brew install whisper-cpp # albo dowolna inna instalacja
|
||||
rex-catalog transcribe --limit 100 # -Dcatalog.whisper.model=ścieżka/do/ggml-medium.bin
|
||||
```
|
||||
|
||||
Da się też wyklikać z zakładki **Dźwięk** — z paskiem postępu i zatrzymaniem, które nie
|
||||
gubi tego, co już policzone.
|
||||
|
||||
Kwestie są sklejane po kilka w trzydziestosekundowe okna z sekundą ciszy między nimi,
|
||||
a wynik rozcinany z powrotem po znacznikach czasu. Whisper koduje zawsze pełne okno,
|
||||
więc podawanie mu czterosekundowych kwestii po jednej marnuje większość pracy.
|
||||
|
||||
Transkrypt jest **wygenerowany, nie odczytany z płyty**. Siedzi w osobnej tabeli razem
|
||||
z nazwą modelu i narzędzia, a interfejs oznacza go jako maszynowy. Zdania, które model
|
||||
dopisuje z siebie na ciszy — podpisy ekip od napisów — są odsiewane.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
COLLECTION=/ścieżka/do/kolekcji docker compose up -d
|
||||
docker compose exec katalog /app/entrypoint.sh ingest /media
|
||||
```
|
||||
|
||||
Kolekcja montuje się tylko do odczytu, baza i cache lądują w wolumenie. Obraz niesie
|
||||
whisper.cpp i ffmpeg, ale **nie model** — ten podmontuj pod `/models` i wskaż zmienną
|
||||
`WHISPER_MODEL`.
|
||||
|
||||
`Dockerfile.cuda` buduje wariant z akceleracją NVIDII (domyślnie pod compute capability
|
||||
8.6, czyli GeForce RTX 30). Na macOS akceleracja w kontenerze nie działa — tam transkrypcję
|
||||
uruchamiaj na hoście, gdzie whisper.cpp sam korzysta z Metala.
|
||||
|
||||
## Przenoszenie między maszynami
|
||||
|
||||
Baza zapisuje bezwzględne ścieżki do obrazów płyt, bo czyta z nich dźwięk i grafikę.
|
||||
Po skopiowaniu `data/` na inną maszynę trzeba ją przypiąć do kolekcji:
|
||||
|
||||
```bash
|
||||
rex-catalog relocate D:\Reksio --dry-run # najpierw pokaż, co zrobisz
|
||||
rex-catalog relocate D:\Reksio
|
||||
```
|
||||
|
||||
Dopasowuje po nazwie i sprawdza rozmiar (`--verify` dokłada SHA-256), a nazwy porównuje po
|
||||
normalizacji Unicode — bez tego kolekcja z macOS nie dogaduje się z tą samą kolekcją na
|
||||
Windowsie, bo `ń` jest tam zapisane inaczej.
|
||||
|
||||
## Co siedzi w bazie
|
||||
|
||||
SQLite, jeden plik obok cache'u, więc przeniesienie katalogu to skopiowanie `data/`.
|
||||
|
||||
| tabela | co trzyma |
|
||||
|---|---|
|
||||
| `title`, `edition`, `copy`, `game_root` | tożsamość: dzieło, wydanie, nośnik, korzeń gry |
|
||||
| `file`, `blob` | gdzie plik leży i czym jest |
|
||||
| `detection` | fakty detektorów z dowodem i pewnością |
|
||||
| `artifact` | wytworzone: odszyfrowane skrypty, miniatury |
|
||||
| `media` | fakty o zasobie: długość, wymiary, klatki, kodek |
|
||||
| `archive_entry` | zawartość archiwów `.snd` — kwestie spakowane w jeden plik |
|
||||
| `script_ref` | odwołania skryptów do zasobów |
|
||||
| `dialogue_line`, `anim_event` | mówca i zdarzenie kwestii, nazwane sekwencje animacji |
|
||||
| `transcript` | rozpoznana mowa, osobno od reszty |
|
||||
| `script_fts`, `transcript_fts` | indeksy pełnotekstowe |
|
||||
|
||||
`rex-catalog stats` pokazuje, co w danej chwili jest w środku.
|
||||
@@ -29,6 +29,10 @@ dependencies {
|
||||
runtimeOnly "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop"
|
||||
|
||||
implementation 'org.xerial:sqlite-jdbc:3.46.1.3'
|
||||
|
||||
// JSON-RPC serwera MCP. Transport stoi na com.sun.net.httpserver z JDK,
|
||||
// więc poza parserem nie dokładamy nic.
|
||||
implementation 'com.google.code.gson:gson:2.11.0'
|
||||
}
|
||||
|
||||
application {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
katalog:
|
||||
build: .
|
||||
image: rex-catalog:latest
|
||||
ports:
|
||||
# front pod /, MCP pod /mcp — jeden port, jeden proces
|
||||
- "127.0.0.1:8765:8765"
|
||||
volumes:
|
||||
# kolekcja tylko do odczytu: katalog niczego w niej nie zmienia,
|
||||
# a obrazy płyt są nieodtwarzalne
|
||||
- ${COLLECTION:-./collection}:/media:ro
|
||||
# baza oraz cache skryptów i miniatur — jedyny stan wart trzymania
|
||||
- catalog-data:/data
|
||||
# modele whisper.cpp: nie wchodzą do obrazu, bo ważą więcej niż on sam
|
||||
# i każdy trzyma inny. Bez nich katalog działa, tylko bez transkrypcji.
|
||||
- ${WHISPER_MODELS:-./models}:/models:ro
|
||||
environment:
|
||||
# wskaż konkretny plik modelu, np. ggml-large-v3.bin z podmontowanego katalogu
|
||||
WHISPER_MODEL: ${WHISPER_MODEL:-/models/ggml-large-v3.bin}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
catalog-data:
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Launcher generowany przez Gradle traktuje wszystko po nazwie skryptu jako
|
||||
# argumenty aplikacji, więc właściwości JVM muszą iść przez JAVA_OPTS.
|
||||
set -e
|
||||
|
||||
# wersja :core jest przypięta submodułem w czasie budowania obrazu; bez niej
|
||||
# copy.core_version zapisałoby "nieznana" i przestałoby działać unieważnianie
|
||||
# cache'u pochodnych po bumpie emulatora
|
||||
CORE_VERSION="${CORE_VERSION:-$(cat /app/core-version 2>/dev/null || echo nieznana)}"
|
||||
|
||||
JAVA_OPTS="-Dfile.encoding=UTF-8 -Dcatalog.data=${CATALOG_DATA:-/data} -Dcatalog.coreVersion=${CORE_VERSION} ${JAVA_OPTS}"
|
||||
export JAVA_OPTS
|
||||
|
||||
exec /app/bin/rex-catalog "$@"
|
||||
@@ -1,14 +1,34 @@
|
||||
package pl.genschu.rexcatalog;
|
||||
|
||||
import pl.genschu.rexcatalog.catalog.Editor;
|
||||
import pl.genschu.rexcatalog.catalog.Promoter;
|
||||
import pl.genschu.rexcatalog.catalog.Relocator;
|
||||
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.media.GraphicsIndexer;
|
||||
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;
|
||||
import pl.genschu.rexcatalog.script.ScriptSearch;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* CLI katalogu. Krok 1: indeksowanie obrazów do SQLite.
|
||||
@@ -20,6 +40,8 @@ public final class Main {
|
||||
System.getProperty("catalog.coreVersion", "nieznana");
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
// dekodowanie grafiki idzie przez AWT; bez tego JVM próbowałby otworzyć ekran
|
||||
System.setProperty("java.awt.headless", "true");
|
||||
if (args.length == 0) {
|
||||
usage();
|
||||
return;
|
||||
@@ -31,6 +53,19 @@ public final class Main {
|
||||
case "ingest" -> ingest(dataDir, args);
|
||||
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 "relocate" -> relocate(dataDir, args);
|
||||
case "titles" -> titles(dataDir);
|
||||
case "set" -> set(dataDir, args);
|
||||
case "lang" -> lang(dataDir, args);
|
||||
case "mcp" -> mcp(dataDir, args);
|
||||
case "serve" -> serve(dataDir, args);
|
||||
case "find" -> find(dataDir, args);
|
||||
case "cat" -> cat(dataDir, args);
|
||||
default -> usage();
|
||||
}
|
||||
} finally {
|
||||
@@ -97,6 +132,509 @@ public final class Main {
|
||||
}
|
||||
}
|
||||
|
||||
private static void decode(Path dataDir, String[] args) throws Exception {
|
||||
boolean force = args.length > 1 && args[1].equals("--force");
|
||||
Headless.boot();
|
||||
|
||||
try (Database db = Database.open(dataDir)) {
|
||||
ScriptDecoder decoder = new ScriptDecoder(db, dataDir, CORE_VERSION);
|
||||
int pending = force ? -1 : decoder.pendingCount();
|
||||
if (pending == 0) {
|
||||
System.out.println("Cache skryptów aktualny (core " + CORE_VERSION + ").");
|
||||
return;
|
||||
}
|
||||
System.out.printf("Odszyfrowuję skrypty (core %s)%s%n", CORE_VERSION,
|
||||
force ? ", wymuszone od nowa" : "");
|
||||
|
||||
long started = System.currentTimeMillis();
|
||||
ScriptDecoder.Stats stats = decoder.run(force);
|
||||
long seconds = (System.currentTimeMillis() - started) / 1000;
|
||||
|
||||
System.out.printf("%nOdszyfrowanych: %d powtórzeń pominiętych: %d błędów: %d (%ds)%n",
|
||||
stats.decoded(), stats.skipped(), stats.failed(), seconds);
|
||||
System.out.printf("W indeksie: %d skryptów%n",
|
||||
new ScriptSearch(db, dataDir).indexedCount());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
GraphicsIndexer graphics = new GraphicsIndexer(db, dataDir);
|
||||
DialogueIndexer dialogue = new DialogueIndexer(db);
|
||||
RefIndexer refs = new RefIndexer(db, dataDir);
|
||||
|
||||
if (!force && audio.pendingCount() == 0 && graphics.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("Dekoduję grafikę i buduję miniatury...");
|
||||
GraphicsIndexer.Stats graphicsStats = graphics.run(force);
|
||||
System.out.printf(" %d obrazów, %d animacji (%d klatek), nieczytelnych: %d%n",
|
||||
graphicsStats.images(), graphicsStats.animations(), graphicsStats.frames(),
|
||||
graphicsStats.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<MetadataDetector.RootResult> results =
|
||||
new MetadataDetector(db, dataDir, CORE_VERSION).run();
|
||||
if (results.isEmpty()) {
|
||||
System.out.println("Brak plików application.def — uruchom najpierw: ingest, decode");
|
||||
return;
|
||||
}
|
||||
System.out.printf("Analizuję application.def w %d korzeniach%n%n", results.size());
|
||||
|
||||
for (MetadataDetector.RootResult result : results) {
|
||||
String where = result.rootPath().isEmpty()
|
||||
? result.copyName()
|
||||
: result.copyName() + " / " + result.rootPath();
|
||||
System.out.println(" " + truncate(where, 60));
|
||||
if (result.note() != null) {
|
||||
System.out.println(" ! " + result.note());
|
||||
continue;
|
||||
}
|
||||
for (MetadataDetector.Fact fact : result.facts()) {
|
||||
if (fact.field().equals("app_def_sha1")) {
|
||||
continue;
|
||||
}
|
||||
System.out.printf(" %-16s %-28s (pewność %.2f)%n",
|
||||
fact.field(), truncate(fact.value(), 28), fact.confidence());
|
||||
}
|
||||
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<String> drift = LanguageDetector.verifyAgainstCore();
|
||||
if (!drift.isEmpty()) {
|
||||
System.err.println("! tablica języków rozjechała się z :core: "
|
||||
+ String.join("; ", drift));
|
||||
}
|
||||
|
||||
List<LanguageDetector.RootResult> 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<Promoter.Assignment> assignments = new Promoter(db).run();
|
||||
if (assignments.isEmpty()) {
|
||||
System.out.println("Brak korzeni gier — uruchom najpierw: ingest");
|
||||
return;
|
||||
}
|
||||
System.out.printf("Podpinam %d korzeni do tytułów i wydań%n%n", assignments.size());
|
||||
|
||||
int review = 0;
|
||||
for (Promoter.Assignment a : assignments) {
|
||||
String where = a.rootPath().isEmpty() ? a.copyName()
|
||||
: a.copyName() + " / " + a.rootPath();
|
||||
System.out.printf(" %-46s → %-32s %s%s%n", truncate(where, 46),
|
||||
truncate(a.title(), 32),
|
||||
a.label() == null ? "" : "[" + truncate(a.label(), 30) + "]",
|
||||
a.ambiguous() ? " ⚠ do przejrzenia" : "");
|
||||
if (a.ambiguous()) {
|
||||
review++;
|
||||
}
|
||||
}
|
||||
if (review > 0) {
|
||||
System.out.printf("%n%d tytułów wymaga potwierdzenia nazwy — popraw przez:%n"
|
||||
+ " set title <id|fragment> name=\"...\"%n", review);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Przestawia zapisane ścieżki do obrazów na nowe miejsce. Potrzebne po
|
||||
* przeniesieniu bazy na inną maszynę albo po przełożeniu kolekcji — bez tego
|
||||
* dźwięk, klatki animacji i transkrypcja nie mają skąd czytać.
|
||||
*/
|
||||
private static void relocate(Path dataDir, String[] args) throws Exception {
|
||||
if (args.length < 2) {
|
||||
System.err.println("""
|
||||
relocate <katalog-kolekcji> [--verify] [--dry-run]
|
||||
|
||||
relocate /Volumes/Dysk/Reksio
|
||||
relocate D:\\Reksio --verify
|
||||
|
||||
Dopasowuje po nazwie pliku i sprawdza rozmiar; --verify dokłada
|
||||
porównanie pełnego odcisku SHA-256 (wolniej, ale bez wątpliwości).
|
||||
Nazwy porównuje po normalizacji Unicode, więc kolekcja z macOS
|
||||
dogaduje się z tą samą kolekcją na Windowsie.
|
||||
|
||||
PRZENIESIENIE KATALOGU NA INNĄ MASZYNĘ
|
||||
|
||||
1. skopiuj data/catalog.sqlite (a jeśli chcesz mieć podglądy
|
||||
i skrypty — cały katalog data/)
|
||||
2. na nowej maszynie: relocate <gdzie-leży-kolekcja>
|
||||
3. sprawdź: list — kolumna STATUS nie może być pusta
|
||||
|
||||
Baza zapisuje bezwzględne ścieżki do obrazów płyt, bo katalog
|
||||
czyta z nich dźwięk, klatki animacji i nagrania do transkrypcji.
|
||||
Bez tego kroku wszystko widać, ale niczego nie da się odtworzyć.
|
||||
|
||||
Wracając z powrotem, uruchom relocate jeszcze raz. Uwaga: baza
|
||||
wędruje w całości, więc jeśli w międzyczasie zmienisz coś po
|
||||
obu stronach, jedna z wersji przepadnie — najbezpieczniej
|
||||
trzymać się zasady, że w danej chwili pracuje jedna maszyna.
|
||||
""");
|
||||
return;
|
||||
}
|
||||
boolean verify = Arrays.asList(args).contains("--verify");
|
||||
boolean dryRun = Arrays.asList(args).contains("--dry-run");
|
||||
|
||||
try (Database db = Database.open(dataDir)) {
|
||||
List<Relocator.Result> results;
|
||||
try {
|
||||
results = new Relocator(db).run(Path.of(args[1]), verify, dryRun);
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
int przypiete = 0;
|
||||
int problemy = 0;
|
||||
for (Relocator.Result result : results) {
|
||||
switch (result.status()) {
|
||||
case "przypięta" -> {
|
||||
System.out.printf(" %-44s → %s%n",
|
||||
truncate(result.displayName(), 44), result.newPath());
|
||||
przypiete++;
|
||||
}
|
||||
case "bez zmian" -> System.out.printf(" %-44s bez zmian%n",
|
||||
truncate(result.displayName(), 44));
|
||||
default -> {
|
||||
System.out.printf(" %-44s ! %s%n",
|
||||
truncate(result.displayName(), 44), result.note());
|
||||
problemy++;
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.printf("%nPrzypiętych: %d nierozwiązanych: %d%s%n",
|
||||
przypiete, problemy, dryRun ? " (próba, nic nie zapisano)" : "");
|
||||
if (problemy > 0) {
|
||||
System.out.println("Kopie bez dopasowania zostają ze starą ścieżką — "
|
||||
+ "katalog nadal je pokazuje, ale nie odczyta z nich zawartości.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void titles(Path dataDir) throws Exception {
|
||||
try (Database db = Database.open(dataDir);
|
||||
Statement st = db.connection().createStatement();
|
||||
ResultSet rs = st.executeQuery("""
|
||||
SELECT t.id AS title_id, t.name AS title, t.series, t.publisher,
|
||||
e.id AS edition_id, e.label,
|
||||
COALESCE(e.release_date_override, (
|
||||
SELECT d.value FROM game_root g
|
||||
JOIN detection d ON d.copy_id = g.copy_id
|
||||
AND d.root_path = g.root_path
|
||||
WHERE g.edition_id = e.id AND d.field = 'release_date' LIMIT 1
|
||||
)) AS release_date,
|
||||
COALESCE(e.engine_override, (
|
||||
SELECT g.engine_detected FROM game_root g
|
||||
WHERE g.edition_id = e.id LIMIT 1
|
||||
)) AS engine,
|
||||
COALESCE(e.engine_version_override, (
|
||||
SELECT d.value FROM game_root g
|
||||
JOIN detection d ON d.copy_id = g.copy_id
|
||||
AND d.root_path = g.root_path
|
||||
WHERE g.edition_id = e.id AND d.field = 'engine_version' LIMIT 1
|
||||
)) AS engine_version,
|
||||
(SELECT GROUP_CONCAT(DISTINCT lang) FROM edition_language
|
||||
WHERE edition_id = e.id) AS langs,
|
||||
(SELECT COUNT(*) FROM game_root WHERE edition_id = e.id) AS roots
|
||||
FROM edition e
|
||||
LEFT JOIN title t ON t.id = e.title_id
|
||||
ORDER BY t.series IS NULL, t.series, t.name, release_date
|
||||
""")) {
|
||||
|
||||
System.out.printf("%-4s %-30s %-26s %-11s %-13s %-9s %s%n",
|
||||
"ID", "TYTUŁ", "WYDANIE", "DATA", "SILNIK", "JĘZYKI", "KOPIE");
|
||||
while (rs.next()) {
|
||||
System.out.printf("%-4d %-30s %-26s %-11s %-13s %-9s %d%n",
|
||||
rs.getInt("edition_id"),
|
||||
truncate(rs.getString("title"), 30),
|
||||
truncate(nvl(rs.getString("label")), 26),
|
||||
nvl(rs.getString("release_date")),
|
||||
truncate(nvl(rs.getString("engine")), 13),
|
||||
truncate(nvl(rs.getString("langs")), 9),
|
||||
rs.getInt("roots"));
|
||||
}
|
||||
System.out.println("\nID to identyfikator wydania — używaj go w set edition i lang.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void set(Path dataDir, String[] args) throws Exception {
|
||||
if (args.length < 4) {
|
||||
System.err.println("""
|
||||
set <encja> <id|fragment> pole=wartość [pole=wartość...]
|
||||
|
||||
set copy "Wojna Troj" source_kind=wlasny_zgraj rip_tool=dd
|
||||
set edition 3 release_date_override=2004-11-15 distributor="Aidem Media"
|
||||
set title 2 name="Poznaj Mity: Herkules"
|
||||
""");
|
||||
System.err.println("Encje: " + String.join(", ", Editor.entities()));
|
||||
return;
|
||||
}
|
||||
try (Database db = Database.open(dataDir)) {
|
||||
try {
|
||||
int changed = new Editor(db).set(args[1], args[2],
|
||||
Arrays.asList(args).subList(3, args.length));
|
||||
System.out.printf("Zmienionych wierszy: %d%n", changed);
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void lang(Path dataDir, String[] args) throws Exception {
|
||||
if (args.length < 5 || !(args[1].equals("add") || args[1].equals("rm"))) {
|
||||
System.err.println("""
|
||||
lang add|rm <wydanie> <kod> <rola>
|
||||
|
||||
lang add 3 pl audio
|
||||
lang add 3 cs text
|
||||
lang rm 3 hu ui
|
||||
|
||||
Rola: audio, text, ui. Wydanie: id albo fragment nazwy tytułu.
|
||||
""");
|
||||
return;
|
||||
}
|
||||
try (Database db = Database.open(dataDir)) {
|
||||
Editor editor = new Editor(db);
|
||||
try {
|
||||
if (args[1].equals("add")) {
|
||||
editor.addLanguage(args[2], args[3], args[4]);
|
||||
System.out.println("Dodano.");
|
||||
} else {
|
||||
int removed = editor.removeLanguage(args[2], args[3], args[4]);
|
||||
System.out.println(removed > 0 ? "Usunięto." : "Nie było takiego wpisu.");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void mcp(Path dataDir, String[] args) throws Exception {
|
||||
String host = option(args, "--host", "127.0.0.1");
|
||||
int port = Integer.parseInt(option(args, "--port", "8765"));
|
||||
|
||||
try (Database db = Database.open(dataDir)) {
|
||||
McpServer server = new McpServer(db, dataDir);
|
||||
server.start(host, port);
|
||||
|
||||
System.out.printf("Serwer MCP nasłuchuje na http://%s:%d/mcp%n", host, port);
|
||||
System.out.println("Transport: Streamable HTTP, bez sesji, tylko do odczytu.");
|
||||
System.out.println("Zatrzymanie: Ctrl+C");
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
// HttpServer pracuje na własnych wątkach, więc główny musi zaczekać
|
||||
Thread.currentThread().join();
|
||||
}
|
||||
}
|
||||
|
||||
/** Front i MCP na jednym porcie — jeden proces, jeden kontener, jedno połączenie z bazą. */
|
||||
private static void serve(Path dataDir, String[] args) throws Exception {
|
||||
String host = option(args, "--host", "127.0.0.1");
|
||||
int port = Integer.parseInt(option(args, "--port", "8765"));
|
||||
|
||||
try (Database db = Database.open(dataDir)) {
|
||||
McpServer mcp = new McpServer(db, dataDir);
|
||||
HttpServer http = HttpServer.create(new InetSocketAddress(host, port), 0);
|
||||
mcp.register(http);
|
||||
new WebServer(db, dataDir, mcp.lock()).register(http);
|
||||
http.setExecutor(Executors.newFixedThreadPool(4));
|
||||
http.start();
|
||||
|
||||
System.out.printf("Front: http://%s:%d/%n", host, port);
|
||||
System.out.printf("MCP: http://%s:%d/mcp%n", host, port);
|
||||
System.out.println("Zatrzymanie: Ctrl+C");
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> http.stop(0)));
|
||||
Thread.currentThread().join();
|
||||
}
|
||||
}
|
||||
|
||||
private static String option(String[] args, String name, String fallback) {
|
||||
for (int i = 1; i < args.length - 1; i++) {
|
||||
if (args[i].equals(name)) {
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static void find(Path dataDir, String[] args) throws Exception {
|
||||
if (args.length < 2) {
|
||||
System.err.println("find wymaga zapytania, np. find SHOWCURSOR");
|
||||
return;
|
||||
}
|
||||
String query = String.join(" ", Arrays.copyOfRange(args, 1, args.length));
|
||||
|
||||
try (Database db = Database.open(dataDir)) {
|
||||
ScriptSearch search = new ScriptSearch(db, dataDir);
|
||||
ScriptSearch.Result result;
|
||||
try {
|
||||
result = search.find(query, 25);
|
||||
} catch (SQLException e) {
|
||||
System.err.println("Nieprawidłowe zapytanie FTS5: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
List<ScriptSearch.Hit> hits = result.hits();
|
||||
if (hits.isEmpty()) {
|
||||
System.out.println("Brak trafień dla: " + result.effectiveQuery());
|
||||
return;
|
||||
}
|
||||
System.out.printf("Trafienia dla %s (%d)%s:%n%n",
|
||||
result.effectiveQuery(), hits.size(),
|
||||
result.quoted() ? " — potraktowane jako fraza" : "");
|
||||
for (ScriptSearch.Hit hit : hits) {
|
||||
System.out.println(" " + String.join("\n ", hit.paths()));
|
||||
System.out.println(" w: " + String.join(", ", hit.games()));
|
||||
System.out.println(" " + hit.snippet().replaceAll("\\s+", " ").trim());
|
||||
System.out.println(" sha1: " + hit.sha1());
|
||||
System.out.println();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void cat(Path dataDir, String[] args) throws Exception {
|
||||
if (args.length < 2) {
|
||||
System.err.println("cat wymaga ścieżki albo SHA-1, np. cat arcade.cnv");
|
||||
return;
|
||||
}
|
||||
try (Database db = Database.open(dataDir)) {
|
||||
ScriptSearch search = new ScriptSearch(db, dataDir);
|
||||
List<String> candidates = search.resolve(args[1]);
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
System.err.println("Nic nie pasuje do: " + args[1]);
|
||||
return;
|
||||
}
|
||||
if (candidates.size() > 1) {
|
||||
System.err.printf("Niejednoznaczne — %d różnych wersji tego pliku:%n",
|
||||
candidates.size());
|
||||
for (String sha1 : candidates) {
|
||||
System.err.printf(" %s %s%n", sha1,
|
||||
String.join(", ", search.gamesFor(sha1)));
|
||||
}
|
||||
System.err.println("Podaj SHA-1, żeby wskazać konkretną.");
|
||||
return;
|
||||
}
|
||||
String sha1 = candidates.get(0);
|
||||
String body = search.body(sha1);
|
||||
if (body == null) {
|
||||
System.err.println("Ten blob nie jest w cache'u — uruchom najpierw: decode");
|
||||
return;
|
||||
}
|
||||
System.out.println(body);
|
||||
}
|
||||
}
|
||||
|
||||
private static void list(Path dataDir) throws Exception {
|
||||
try (Database db = Database.open(dataDir);
|
||||
Statement st = db.connection().createStatement();
|
||||
@@ -180,6 +718,22 @@ public final class Main {
|
||||
list wypisz zaindeksowane kopie
|
||||
stats statystyki bazy
|
||||
|
||||
decode [--force] odszyfruj skrypty do cache'u i indeksu
|
||||
probe [--force] zbadaj dźwięk, grafikę, 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
|
||||
relocate <katalog> przypnij bazę do kolekcji w nowym miejscu
|
||||
titles lista tytułów z metadanymi
|
||||
set <encja> <id> ... edytuj metadane ręczne
|
||||
lang add|rm ... wersje językowe wydania
|
||||
find <zapytanie> przeszukaj skrypty (składnia FTS5)
|
||||
cat <ścieżka|sha1> wypisz odszyfrowany skrypt
|
||||
|
||||
mcp [--host H] [--port P] serwer MCP (Streamable HTTP, domyślnie 127.0.0.1:8765)
|
||||
serve [--host H] [--port P] front i MCP na jednym porcie
|
||||
|
||||
Baza: -Dcatalog.data=<katalog> (domyślnie ./data)
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package pl.genschu.rexcatalog.catalog;
|
||||
|
||||
import pl.genschu.rexcatalog.db.Database;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Ręczna edycja metadanych, których żaden detektor nie wymyśli: skąd pochodzi plik,
|
||||
* jakie ma wersje językowe, czym to skompilowano.
|
||||
*
|
||||
* <p>Pola są na białej liście per encja — literówka w nazwie kolumny ma się skończyć
|
||||
* komunikatem, a nie zapytaniem SQL sklejonym z wejścia.
|
||||
*/
|
||||
public final class Editor {
|
||||
|
||||
private static final Map<String, Set<String>> EDITABLE = Map.of(
|
||||
"copy", new LinkedHashSet<>(List.of(
|
||||
"source_kind", "source_url", "source_ref", "acquired_at",
|
||||
"rip_tool", "media_note", "verified", "notes", "display_name")),
|
||||
"edition", new LinkedHashSet<>(List.of(
|
||||
"label", "engine_override", "engine_version_override",
|
||||
"compiler_override", "release_date_override", "distributor", "notes")),
|
||||
"title", new LinkedHashSet<>(List.of("name", "series", "publisher"))
|
||||
);
|
||||
|
||||
private static final Set<String> SOURCE_KINDS =
|
||||
Set.of("wlasny_zgraj", "archive_org", "redump", "inne", "nieznane");
|
||||
|
||||
private static final Set<String> LANGUAGE_ROLES = Set.of("audio", "text", "ui");
|
||||
|
||||
private final Database db;
|
||||
|
||||
public Editor(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public static Set<String> editableFields(String entity) {
|
||||
return EDITABLE.getOrDefault(entity, Set.of());
|
||||
}
|
||||
|
||||
public static Set<String> entities() {
|
||||
return EDITABLE.keySet();
|
||||
}
|
||||
|
||||
/** Zwraca liczbę zmienionych wierszy albo rzuca z opisem, co jest nie tak. */
|
||||
public int set(String entity, String id, List<String> assignments) throws Exception {
|
||||
Set<String> allowed = EDITABLE.get(entity);
|
||||
if (allowed == null) {
|
||||
throw new IllegalArgumentException("Nieznana encja: " + entity
|
||||
+ " (dostępne: " + String.join(", ", EDITABLE.keySet()) + ")");
|
||||
}
|
||||
if (assignments.isEmpty()) {
|
||||
throw new IllegalArgumentException("Podaj co najmniej jedno przypisanie pole=wartość");
|
||||
}
|
||||
|
||||
List<String> columns = new ArrayList<>();
|
||||
List<String> values = new ArrayList<>();
|
||||
for (String assignment : merge(assignments)) {
|
||||
int eq = assignment.indexOf('=');
|
||||
if (eq <= 0) {
|
||||
throw new IllegalArgumentException("Oczekiwano pole=wartość, dostałem: " + assignment);
|
||||
}
|
||||
String column = assignment.substring(0, eq).trim().toLowerCase(Locale.ROOT);
|
||||
String value = unquote(assignment.substring(eq + 1).trim());
|
||||
|
||||
if (!allowed.contains(column)) {
|
||||
throw new IllegalArgumentException("Pole '" + column + "' nie jest edytowalne w "
|
||||
+ entity + " (dostępne: " + String.join(", ", allowed) + ")");
|
||||
}
|
||||
if (column.equals("source_kind") && !SOURCE_KINDS.contains(value)) {
|
||||
throw new IllegalArgumentException("source_kind musi być jednym z: "
|
||||
+ String.join(", ", SOURCE_KINDS));
|
||||
}
|
||||
columns.add(column);
|
||||
values.add(value.isEmpty() ? null : value);
|
||||
}
|
||||
|
||||
long rowId = resolveId(entity, id);
|
||||
String sql = "UPDATE " + entity + " SET "
|
||||
+ String.join(", ", columns.stream().map(c -> c + " = ?").toList())
|
||||
+ " WHERE id = ?";
|
||||
try (PreparedStatement st = db.connection().prepareStatement(sql)) {
|
||||
int index = 1;
|
||||
for (String value : values) {
|
||||
st.setString(index++, value);
|
||||
}
|
||||
st.setLong(index, rowId);
|
||||
return st.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skleja wartości wielowyrazowe. Token bez znaku równości nie może być osobnym
|
||||
* przypisaniem, więc dopisujemy go do poprzedniej wartości — dzięki temu
|
||||
* {@code distributor=Komputer Świat Gry} działa bez walki z cudzysłowami,
|
||||
* które i tak gubi {@code gradle run --args}.
|
||||
*/
|
||||
private static List<String> merge(List<String> tokens) {
|
||||
List<String> merged = new ArrayList<>();
|
||||
for (String token : tokens) {
|
||||
if (token.contains("=") || merged.isEmpty()) {
|
||||
merged.add(token);
|
||||
} else {
|
||||
merged.set(merged.size() - 1, merged.get(merged.size() - 1) + " " + token);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static String unquote(String value) {
|
||||
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Przyjmuje albo liczbowy identyfikator, albo fragment nazwy — przy pracy z CLI
|
||||
* nikt nie pamięta, że Wojna Trojańska ma copy_id 13.
|
||||
*/
|
||||
private long resolveId(String entity, String id) throws Exception {
|
||||
if (id.matches("\\d+")) {
|
||||
return Long.parseLong(id);
|
||||
}
|
||||
String column = switch (entity) {
|
||||
case "copy" -> "display_name";
|
||||
case "title" -> "name";
|
||||
case "edition" -> "label";
|
||||
default -> throw new IllegalArgumentException("Encja " + entity
|
||||
+ " wymaga liczbowego id");
|
||||
};
|
||||
List<Long> ids = new ArrayList<>();
|
||||
List<String> names = new ArrayList<>();
|
||||
try (PreparedStatement st = db.connection().prepareStatement(
|
||||
"SELECT id, " + column + " FROM " + entity
|
||||
+ " WHERE lower(" + column + ") LIKE ? ORDER BY id")) {
|
||||
st.setString(1, "%" + id.toLowerCase(Locale.ROOT) + "%");
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
ids.add(rs.getLong(1));
|
||||
names.add(rs.getString(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
throw new IllegalArgumentException("Nic nie pasuje do: " + id);
|
||||
}
|
||||
if (ids.size() > 1) {
|
||||
StringBuilder sb = new StringBuilder("Niejednoznaczne — pasuje " + ids.size() + ":");
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
sb.append("\n ").append(ids.get(i)).append(" ").append(names.get(i));
|
||||
}
|
||||
throw new IllegalArgumentException(sb.toString());
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wersje językowe są listą, nie polem — wydanie bywa miksem (polski + czeski +
|
||||
* węgierski), a rola rozdziela dubbing od napisów i interfejsu.
|
||||
*/
|
||||
public void addLanguage(String editionId, String lang, String role) throws Exception {
|
||||
if (!LANGUAGE_ROLES.contains(role)) {
|
||||
throw new IllegalArgumentException("Rola musi być jedną z: "
|
||||
+ String.join(", ", LANGUAGE_ROLES));
|
||||
}
|
||||
long id = resolveEdition(editionId);
|
||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||
INSERT INTO edition_language (edition_id, lang, role) VALUES (?, ?, ?)
|
||||
ON CONFLICT(edition_id, lang, role) DO NOTHING
|
||||
""")) {
|
||||
st.setLong(1, id);
|
||||
st.setString(2, lang.toLowerCase(Locale.ROOT));
|
||||
st.setString(3, role);
|
||||
st.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int removeLanguage(String editionId, String lang, String role) throws Exception {
|
||||
long id = resolveEdition(editionId);
|
||||
try (PreparedStatement st = db.connection().prepareStatement(
|
||||
"DELETE FROM edition_language WHERE edition_id = ? AND lang = ? AND role = ?")) {
|
||||
st.setLong(1, id);
|
||||
st.setString(2, lang.toLowerCase(Locale.ROOT));
|
||||
st.setString(3, role);
|
||||
return st.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Wydanie wskazujemy po id albo po nazwie tytułu, do którego należy. */
|
||||
private long resolveEdition(String identifier) throws Exception {
|
||||
if (identifier.matches("\\d+")) {
|
||||
return Long.parseLong(identifier);
|
||||
}
|
||||
List<Long> ids = new ArrayList<>();
|
||||
List<String> labels = new ArrayList<>();
|
||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||
SELECT e.id, t.name || COALESCE(' — ' || e.label, '')
|
||||
FROM edition e LEFT JOIN title t ON t.id = e.title_id
|
||||
WHERE lower(t.name) LIKE ? OR lower(e.label) LIKE ?
|
||||
ORDER BY e.id
|
||||
""")) {
|
||||
String pattern = "%" + identifier.toLowerCase(Locale.ROOT) + "%";
|
||||
st.setString(1, pattern);
|
||||
st.setString(2, pattern);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
ids.add(rs.getLong(1));
|
||||
labels.add(rs.getString(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
throw new IllegalArgumentException("Żadne wydanie nie pasuje do: " + identifier);
|
||||
}
|
||||
if (ids.size() > 1) {
|
||||
StringBuilder sb = new StringBuilder("Niejednoznaczne — pasuje " + ids.size() + ":");
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
sb.append("\n ").append(ids.get(i)).append(" ").append(labels.get(i));
|
||||
}
|
||||
throw new IllegalArgumentException(sb.toString());
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package pl.genschu.rexcatalog.catalog;
|
||||
|
||||
import pl.genschu.rexcatalog.db.Database;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.text.Normalizer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Materializuje encje katalogu z faktów zebranych przez detektory: tworzy tytuły
|
||||
* i wydania, po czym podpina do nich korzenie gier.
|
||||
*
|
||||
* <p>Operacja jest powtarzalna i nie nadpisuje pracy człowieka — pola {@code *_override},
|
||||
* {@code label}, {@code distributor} i {@code notes} zostają nietknięte, a tytuł raz
|
||||
* nazwany zachowuje nazwę, bo kluczem jest slug, nie nazwa.
|
||||
*/
|
||||
public final class Promoter {
|
||||
|
||||
private final Database db;
|
||||
|
||||
public Promoter(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public record Assignment(String copyName, String rootPath, String title, String label,
|
||||
String fingerprint, boolean ambiguous) {
|
||||
}
|
||||
|
||||
private record RootFacts(long copyId, String copyName, String rootPath, String copySha256,
|
||||
String dllSha1, String appDefSha1, String edition, String family,
|
||||
int rootsOnCopy) {
|
||||
}
|
||||
|
||||
public List<Assignment> run() throws Exception {
|
||||
List<RootFacts> roots = collect();
|
||||
List<Assignment> assignments = new ArrayList<>();
|
||||
|
||||
Connection conn = db.connection();
|
||||
boolean previousAutoCommit = conn.getAutoCommit();
|
||||
conn.setAutoCommit(false);
|
||||
try {
|
||||
for (RootFacts root : roots) {
|
||||
String fingerprint = fingerprint(root);
|
||||
Naming naming = naming(root);
|
||||
|
||||
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()));
|
||||
}
|
||||
// po zmianie reguły nazewniczej stare tytuły zostają bez wydań — sprzątamy je,
|
||||
// żeby `titles` nie pokazywał duchów po poprzednim przebiegu
|
||||
try (PreparedStatement st = conn.prepareStatement("""
|
||||
DELETE FROM title WHERE id NOT IN
|
||||
(SELECT title_id FROM edition WHERE title_id IS NOT NULL)
|
||||
""")) {
|
||||
st.executeUpdate();
|
||||
}
|
||||
conn.commit();
|
||||
} catch (Exception e) {
|
||||
conn.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
conn.setAutoCommit(previousAutoCommit);
|
||||
}
|
||||
return assignments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Klucz scalania. Hash biblioteki plus odcisk application.def — dwie kopie tego
|
||||
* samego wydania (obraz i katalog rozpakowany) dają ten sam odcisk i jedno wydanie,
|
||||
* a dwie gry z jednej płyty rozchodzą się na application.def.
|
||||
*
|
||||
* <p>Gdy nie ma ani biblioteki, ani opisu projektu (np. silnik KI), zostaje
|
||||
* tożsamość nośnika — świadomie ostrożna, bo nie mamy czym scalać.
|
||||
*/
|
||||
private static String fingerprint(RootFacts root) {
|
||||
if (root.dllSha1() != null || root.appDefSha1() != null) {
|
||||
return "engine:" + nvl(root.dllSha1()) + "|app:" + nvl(root.appDefSha1());
|
||||
}
|
||||
return "copy:" + nvl(root.copySha256()) + "|root:" + root.rootPath();
|
||||
}
|
||||
|
||||
/** Rozbiór nazwy z tablicy hashy na trzy poziomy katalogu. */
|
||||
private record Naming(String title, String label, String series, boolean needsReview) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wpis w {@code KnownHashes} miesza dzieło z wydaniem: „Reksio i UFO (pierwsza wersja)"
|
||||
* to tytuł plus etykieta w nawiasie. Rozdzielenie ich sprawia, że dwa wydania tej samej
|
||||
* gry trafiają pod jeden tytuł, zamiast udawać dwie różne gry.
|
||||
*
|
||||
* <p>Dwupack wymaga dodatkowo rozbicia po ukośniku: jeden wpis opisuje obie gry
|
||||
* („Poznaj Mity: Herkules/Odyseusz"), bo obie stoją na tej samej bibliotece.
|
||||
* Wybór członu opieramy na nazwie katalogu korzenia.
|
||||
*/
|
||||
private static Naming naming(RootFacts root) {
|
||||
String edition = root.edition();
|
||||
if (edition == null || edition.isBlank()) {
|
||||
// brak wpisu w tablicy hashy — z nazwy pliku wyjdzie co najwyżej przybliżenie
|
||||
return new Naming(root.copyName().replaceAll("(?i)\\.(iso|zip)$", "").trim(),
|
||||
null, null, true);
|
||||
}
|
||||
|
||||
String base = edition;
|
||||
String label = null;
|
||||
int paren = edition.indexOf(" (");
|
||||
if (paren > 0) {
|
||||
base = edition.substring(0, paren).trim();
|
||||
// w danych trafia się nawias bez domknięcia, stąd replaceAll zamiast substring
|
||||
label = edition.substring(paren + 2).replaceAll("\\)\\s*$", "").trim();
|
||||
}
|
||||
|
||||
boolean needsReview = false;
|
||||
if (root.rootsOnCopy() > 1 && !root.rootPath().isEmpty()) {
|
||||
int colon = base.lastIndexOf(':');
|
||||
String prefix = colon >= 0 ? base.substring(0, colon + 1).trim() + " " : "";
|
||||
String rest = colon >= 0 ? base.substring(colon + 1).trim() : base;
|
||||
|
||||
String matched = null;
|
||||
for (String part : rest.split("/")) {
|
||||
if (part.trim().equalsIgnoreCase(root.rootPath().trim())) {
|
||||
matched = prefix + part.trim();
|
||||
}
|
||||
}
|
||||
if (matched != null) {
|
||||
base = matched;
|
||||
} else {
|
||||
base = base + " (" + root.rootPath() + ")";
|
||||
needsReview = true;
|
||||
}
|
||||
}
|
||||
return new Naming(base, label, series(base), needsReview);
|
||||
}
|
||||
|
||||
/** „Poznaj Mity: Herkules" → seria „Poznaj Mity". Bez dwukropka nie zgadujemy. */
|
||||
private static String series(String title) {
|
||||
int colon = title.indexOf(": ");
|
||||
return colon > 0 ? title.substring(0, colon).trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Przenosi wykryte wersje językowe do wydania.
|
||||
*
|
||||
* <p>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
|
||||
try (PreparedStatement st = conn.prepareStatement("""
|
||||
INSERT INTO title (slug, name, series) VALUES (?, ?, ?)
|
||||
ON CONFLICT(slug) DO UPDATE SET series = COALESCE(title.series, excluded.series)
|
||||
""")) {
|
||||
st.setString(1, slug);
|
||||
st.setString(2, naming.title());
|
||||
st.setString(3, naming.series());
|
||||
st.executeUpdate();
|
||||
}
|
||||
try (PreparedStatement st = conn.prepareStatement("SELECT id FROM title WHERE slug = ?")) {
|
||||
st.setString(1, slug);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long upsertEdition(Connection conn, String fingerprint, long titleId,
|
||||
Naming naming, RootFacts root) throws Exception {
|
||||
// aktualizujemy wyłącznie pola mechaniczne — poprawki człowieka zostają,
|
||||
// dlatego label tylko uzupełniamy, gdy jest pusty
|
||||
try (PreparedStatement st = conn.prepareStatement("""
|
||||
INSERT INTO edition (fingerprint, title_id, label, dll_sha1, app_def_sha1)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(fingerprint) DO UPDATE SET
|
||||
title_id = excluded.title_id,
|
||||
label = COALESCE(edition.label, excluded.label),
|
||||
dll_sha1 = excluded.dll_sha1,
|
||||
app_def_sha1 = excluded.app_def_sha1
|
||||
""")) {
|
||||
st.setString(1, fingerprint);
|
||||
st.setLong(2, titleId);
|
||||
st.setString(3, naming.label());
|
||||
st.setString(4, root.dllSha1());
|
||||
st.setString(5, root.appDefSha1());
|
||||
st.executeUpdate();
|
||||
}
|
||||
try (PreparedStatement st = conn.prepareStatement(
|
||||
"SELECT id FROM edition WHERE fingerprint = ?")) {
|
||||
st.setString(1, fingerprint);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void linkRoot(Connection conn, long copyId, String rootPath, long editionId)
|
||||
throws Exception {
|
||||
try (PreparedStatement st = conn.prepareStatement(
|
||||
"UPDATE game_root SET edition_id = ? WHERE copy_id = ? AND root_path = ?")) {
|
||||
st.setLong(1, editionId);
|
||||
st.setLong(2, copyId);
|
||||
st.setString(3, rootPath);
|
||||
st.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private List<RootFacts> collect() throws Exception {
|
||||
Map<Long, Integer> rootsPerCopy = new LinkedHashMap<>();
|
||||
try (PreparedStatement st = db.connection().prepareStatement(
|
||||
"SELECT copy_id, COUNT(*) n FROM game_root GROUP BY copy_id");
|
||||
ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
rootsPerCopy.put(rs.getLong("copy_id"), rs.getInt("n"));
|
||||
}
|
||||
}
|
||||
|
||||
List<RootFacts> out = new ArrayList<>();
|
||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||
SELECT g.copy_id, g.root_path, c.display_name, c.sha256,
|
||||
MAX(CASE WHEN d.field = 'engine_dll_sha1' THEN d.value END) AS dll_sha1,
|
||||
MAX(CASE WHEN d.field = 'app_def_sha1' THEN d.value END) AS app_def_sha1,
|
||||
MAX(CASE WHEN d.field = 'edition' THEN d.value END) AS edition,
|
||||
MAX(CASE WHEN d.field = 'family' THEN d.value END) AS family
|
||||
FROM game_root g
|
||||
JOIN copy c ON c.id = g.copy_id
|
||||
LEFT JOIN detection d ON d.copy_id = g.copy_id AND d.root_path = g.root_path
|
||||
GROUP BY g.copy_id, g.root_path
|
||||
ORDER BY c.display_name, g.root_path
|
||||
""");
|
||||
ResultSet rs = st.executeQuery()) {
|
||||
|
||||
while (rs.next()) {
|
||||
long copyId = rs.getLong("copy_id");
|
||||
out.add(new RootFacts(copyId, rs.getString("display_name"),
|
||||
rs.getString("root_path"), rs.getString("sha256"),
|
||||
rs.getString("dll_sha1"), rs.getString("app_def_sha1"),
|
||||
rs.getString("edition"), rs.getString("family"),
|
||||
rootsPerCopy.getOrDefault(copyId, 1)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Slug bez ogonków i znaków spoza [a-z0-9-], żeby był stabilnym kluczem tytułu. */
|
||||
static String slug(String name) {
|
||||
String ascii = Normalizer.normalize(name.replace('ł', 'l').replace('Ł', 'L'),
|
||||
Normalizer.Form.NFD)
|
||||
.replaceAll("\\p{M}", "");
|
||||
return ascii.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9]+", "-")
|
||||
.replaceAll("^-+|-+$", "");
|
||||
}
|
||||
|
||||
private static String nvl(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package pl.genschu.rexcatalog.catalog;
|
||||
|
||||
import pl.genschu.rexcatalog.db.Database;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.text.Normalizer;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Przypina katalog do kolekcji leżącej w innym miejscu.
|
||||
*
|
||||
* <p>{@code copy.path} zapisuje, gdzie obraz płyty był w chwili indeksowania —
|
||||
* u autora {@code /Users/.../Reksio/...}, w kontenerze {@code /media/...},
|
||||
* a na innej maszynie jeszcze inaczej. Baza jest przenośna, ale bez przestawienia
|
||||
* tych ścieżek nic z niej nie odczytamy: podgląd dźwięku, renderowanie klatek
|
||||
* i transkrypcja czytają wprost z obrazów.
|
||||
*
|
||||
* <p>Dopasowanie idzie po nazwie pliku i jest sprawdzane rozmiarem — a na żądanie
|
||||
* także pełnym odciskiem. Nazwa sama w sobie niczego nie dowodzi, więc kopia,
|
||||
* której rozmiar się nie zgadza, zostaje nietknięta i jest zgłaszana.
|
||||
*
|
||||
* <p>Nazwy porównujemy po normalizacji Unicode. macOS trzyma je rozłożone (NFD:
|
||||
* {@code n} plus znak łączący), Windows i Linux złożone (NFC), więc
|
||||
* {@code Wojna Trojańska.iso} z jednego systemu nie jest równe temu samemu
|
||||
* napisowi z drugiego, choć wygląda identycznie. To właśnie przy przenoszeniu
|
||||
* między maszynami boli najbardziej.
|
||||
*/
|
||||
public final class Relocator {
|
||||
|
||||
private final Database db;
|
||||
|
||||
public Relocator(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param status {@code przypięta}, {@code bez zmian}, {@code nie znaleziono}
|
||||
* albo {@code rozjazd}
|
||||
*/
|
||||
public record Result(String displayName, String oldPath, String newPath, String status,
|
||||
String note) {
|
||||
}
|
||||
|
||||
private record Copy(long id, String displayName, String path, long size, String sha256) {
|
||||
}
|
||||
|
||||
public List<Result> run(Path root, boolean verify, boolean dryRun) throws Exception {
|
||||
if (!Files.isDirectory(root)) {
|
||||
throw new IllegalArgumentException("to nie jest katalog: " + root);
|
||||
}
|
||||
Map<String, List<Path>> byName = index(root);
|
||||
List<Result> results = new ArrayList<>();
|
||||
|
||||
try (PreparedStatement update = db.connection().prepareStatement(
|
||||
"UPDATE copy SET path = ? WHERE id = ?")) {
|
||||
|
||||
for (Copy copy : copies()) {
|
||||
Path current = Path.of(copy.path());
|
||||
String name = current.getFileName().toString();
|
||||
|
||||
if (Files.exists(current)) {
|
||||
results.add(new Result(copy.displayName(), copy.path(), copy.path(),
|
||||
"bez zmian", "jest tam, gdzie było"));
|
||||
continue;
|
||||
}
|
||||
|
||||
List<Path> candidates = byName.getOrDefault(key(name), List.of());
|
||||
Path match = pick(candidates, copy, verify);
|
||||
if (match == null) {
|
||||
results.add(new Result(copy.displayName(), copy.path(), null,
|
||||
candidates.isEmpty() ? "nie znaleziono" : "rozjazd",
|
||||
candidates.isEmpty()
|
||||
? "nie ma pliku o tej nazwie w podanym katalogu"
|
||||
: "znalazłem nazwę, ale nie zgadza się zawartość"));
|
||||
continue;
|
||||
}
|
||||
|
||||
String target = match.toAbsolutePath().toString();
|
||||
if (!dryRun) {
|
||||
update.setString(1, target);
|
||||
update.setLong(2, copy.id());
|
||||
update.executeUpdate();
|
||||
}
|
||||
results.add(new Result(copy.displayName(), copy.path(), target,
|
||||
"przypięta", null));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wybiera kandydata, którego zawartość zgadza się z zapisaną. Rozmiar odsiewa
|
||||
* pomyłki od ręki; {@code verify} dokłada odcisk, co przy sześciu gigabajtach
|
||||
* trwa, ale daje pewność.
|
||||
*/
|
||||
private Path pick(List<Path> candidates, Copy copy, boolean verify) throws Exception {
|
||||
for (Path candidate : candidates) {
|
||||
long size = Files.isDirectory(candidate) ? directorySize(candidate)
|
||||
: Files.size(candidate);
|
||||
if (copy.size() > 0 && size != copy.size()) {
|
||||
continue;
|
||||
}
|
||||
if (verify && copy.sha256() != null && !Files.isDirectory(candidate)
|
||||
&& !copy.sha256().equalsIgnoreCase(sha256(candidate))) {
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Zbiera nazwy plików i katalogów w nowym miejscu — jedno przejście, potem tylko odpytywanie. */
|
||||
private static Map<String, List<Path>> index(Path root) throws IOException {
|
||||
Map<String, List<Path>> out = new HashMap<>();
|
||||
try (var stream = Files.walk(root, 3)) {
|
||||
stream.forEach(path -> out.computeIfAbsent(key(path.getFileName().toString()),
|
||||
k -> new ArrayList<>()).add(path));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Wspólna postać nazwy: złożona i bez wielkości liter. */
|
||||
private static String key(String name) {
|
||||
return Normalizer.normalize(name, Normalizer.Form.NFC).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static long directorySize(Path directory) throws IOException {
|
||||
try (var stream = Files.walk(directory)) {
|
||||
return stream.filter(Files::isRegularFile).mapToLong(path -> {
|
||||
try {
|
||||
return Files.size(path);
|
||||
} catch (IOException e) {
|
||||
return 0;
|
||||
}
|
||||
}).sum();
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha256(Path file) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] buffer = new byte[1 << 20];
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
int read;
|
||||
while ((read = in.read(buffer)) > 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (byte b : digest.digest()) {
|
||||
out.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
out.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private List<Copy> copies() throws Exception {
|
||||
List<Copy> out = new ArrayList<>();
|
||||
try (PreparedStatement st = db.connection().prepareStatement(
|
||||
"SELECT id, display_name, path, size, sha256 FROM copy ORDER BY id");
|
||||
ResultSet rs = st.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(new Copy(rs.getLong("id"), rs.getString("display_name"),
|
||||
rs.getString("path"), rs.getLong("size"), rs.getString("sha256")));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
@@ -41,6 +43,7 @@ public final class Database implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void applySchema() throws SQLException {
|
||||
dropLegacyEdition();
|
||||
try (Statement st = connection.createStatement()) {
|
||||
for (String ddl : SCHEMA) {
|
||||
st.executeUpdate(ddl);
|
||||
@@ -49,6 +52,52 @@ public final class Database implements AutoCloseable {
|
||||
migrate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stara wersja {@code edition} miała {@code dll_sha1 UNIQUE}, co jest nie do
|
||||
* pogodzenia z dwupackiem — dwie gry na jednej płycie dzielą bibliotekę silnika.
|
||||
* Tabela jest przebudowywana tylko wtedy, gdy jest pusta; jeśli zdążyły w niej
|
||||
* osiąść dane kuratorskie, wołamy o ręczną migrację zamiast je skasować.
|
||||
*/
|
||||
private void dropLegacyEdition() throws SQLException {
|
||||
if (!tableExists("edition") || hasColumn("edition", "fingerprint")) {
|
||||
return;
|
||||
}
|
||||
try (Statement st = connection.createStatement();
|
||||
ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM edition")) {
|
||||
if (rs.next() && rs.getLong(1) > 0) {
|
||||
throw new SQLException("Tabela edition ma stary układ i zawiera dane. "
|
||||
+ "Przenieś je ręcznie albo usuń data/catalog.sqlite i przebuduj "
|
||||
+ "(ingest, decode, analyze, promote).");
|
||||
}
|
||||
}
|
||||
try (Statement st = connection.createStatement()) {
|
||||
st.executeUpdate("DROP TABLE edition");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tableExists(String table) throws SQLException {
|
||||
try (PreparedStatement st = connection.prepareStatement(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")) {
|
||||
st.setString(1, table);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code table_xinfo}, nie {@code table_info}: to drugie pomija kolumny wyliczane. */
|
||||
private boolean hasColumn(String table, String column) throws SQLException {
|
||||
try (Statement st = connection.createStatement();
|
||||
ResultSet rs = st.executeQuery("PRAGMA table_xinfo(" + table + ")")) {
|
||||
while (rs.next()) {
|
||||
if (column.equalsIgnoreCase(rs.getString("name"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dokłada kolumny, których {@code CREATE TABLE IF NOT EXISTS} nie doda do już
|
||||
* istniejącej tabeli. Tylko {@code ADD COLUMN} — bez przebudowy tabel, bo na
|
||||
@@ -56,17 +105,48 @@ public final class Database implements AutoCloseable {
|
||||
*/
|
||||
private void migrate() throws SQLException {
|
||||
addColumnIfMissing("copy", "fs_type", "TEXT");
|
||||
// media zaczęło od dźwięku, ale grafika opisuje się tymi samymi kategoriami:
|
||||
// fakt o blobie, kluczowany odciskiem treści
|
||||
addColumnIfMissing("media", "width", "INTEGER");
|
||||
addColumnIfMissing("media", "height", "INTEGER");
|
||||
addColumnIfMissing("media", "frames", "INTEGER");
|
||||
addColumnIfMissing("media", "fps", "INTEGER");
|
||||
addColumnIfMissing("media", "author", "TEXT");
|
||||
// Starszy indeks zdarzeń przechowywał tylko ich długość. Do wiernego
|
||||
// podglądu potrzebna jest właściwa kolejność obrazów i reguły pętli.
|
||||
addColumnIfMissing("anim_event", "frames", "TEXT");
|
||||
addColumnIfMissing("anim_event", "repeat_count", "INTEGER");
|
||||
addColumnIfMissing("anim_event", "flags", "INTEGER");
|
||||
addBasenameIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nazwa pliku bez katalogu, jako kolumna wyliczana i zaindeksowana.
|
||||
*
|
||||
* <p>Rozwiązywanie odwołań ze skryptów pyta o nią raz na odwołanie, a jeden
|
||||
* skrypt potrafi ich mieć siedemset — bez indeksu każde z nich przeglądało
|
||||
* wszystkie 38 tysięcy ścieżek. Kolumna jest wirtualna, więc nie zajmuje
|
||||
* miejsca i nie wymaga uzupełniania przy zapisie.
|
||||
*/
|
||||
private void addBasenameIndex() throws SQLException {
|
||||
if (!hasColumn("file", "basename")) {
|
||||
try (Statement st = connection.createStatement()) {
|
||||
st.executeUpdate("ALTER TABLE file ADD COLUMN basename TEXT "
|
||||
+ "GENERATED ALWAYS AS "
|
||||
+ "(replace(path, rtrim(path, replace(path, '/', '')), '')) VIRTUAL");
|
||||
}
|
||||
}
|
||||
try (Statement st = connection.createStatement()) {
|
||||
st.executeUpdate("CREATE INDEX IF NOT EXISTS idx_file_basename "
|
||||
+ "ON file(copy_id, basename)");
|
||||
}
|
||||
}
|
||||
|
||||
private void addColumnIfMissing(String table, String column, String type) throws SQLException {
|
||||
try (Statement st = connection.createStatement()) {
|
||||
try (java.sql.ResultSet rs = st.executeQuery("PRAGMA table_info(" + table + ")")) {
|
||||
while (rs.next()) {
|
||||
if (column.equalsIgnoreCase(rs.getString("name"))) {
|
||||
if (hasColumn(table, column)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
try (Statement st = connection.createStatement()) {
|
||||
st.executeUpdate("ALTER TABLE " + table + " ADD COLUMN " + column + " " + type);
|
||||
}
|
||||
}
|
||||
@@ -95,17 +175,25 @@ public final class Database implements AutoCloseable {
|
||||
publisher TEXT DEFAULT 'Aidem Media'
|
||||
)
|
||||
""",
|
||||
// Wydanie jest tożsamością kuratorską: co wykryte, siedzi w `detection`
|
||||
// z dowodem, a tutaj trzymamy tylko poprawki człowieka (*_override)
|
||||
// i pola, których nie da się wykryć.
|
||||
//
|
||||
// fingerprint to klucz scalania kopii w jedno wydanie. Sam hash biblioteki
|
||||
// nie wystarcza — Herkules i Odyseusz mają identyczny Piklib 8, a różnią się
|
||||
// dopiero odciskiem application.def.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS edition (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title_id INTEGER REFERENCES title(id),
|
||||
fingerprint TEXT NOT NULL UNIQUE,
|
||||
label TEXT,
|
||||
dll_sha1 TEXT UNIQUE,
|
||||
engine_detected TEXT,
|
||||
dll_sha1 TEXT,
|
||||
app_def_sha1 TEXT,
|
||||
engine_override TEXT,
|
||||
compiler_detected TEXT,
|
||||
engine_version_override TEXT,
|
||||
compiler_override TEXT,
|
||||
release_date TEXT,
|
||||
release_date_override TEXT,
|
||||
distributor TEXT,
|
||||
notes TEXT
|
||||
)
|
||||
@@ -216,6 +304,124 @@ public final class Database implements AutoCloseable {
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (blob_sha1, kind)
|
||||
)
|
||||
""",
|
||||
// Indeks pełnotekstowy odszyfrowanych skryptów. Kluczem jest blob, nie plik —
|
||||
// ten sam skrypt bywa w kilku obrazach, a wtedy szukamy raz i pokazujemy,
|
||||
// w których kopiach występuje.
|
||||
//
|
||||
// tokenchars '_' trzyma identyfikatory w całości (SHOW_CURSOR to jeden token,
|
||||
// nie dwa), a remove_diacritics pozwala znaleźć polski tekst bez ogonków.
|
||||
"""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS script_fts USING fts5(
|
||||
body,
|
||||
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 '_'"
|
||||
)
|
||||
""",
|
||||
// Nazwane sekwencje w animacji: BEZRUCH, GADA_START, GADA_1...
|
||||
// Mówią, co postać potrafi zrobić, więc są treścią, a nie tylko liczbą.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS anim_event (
|
||||
ann_sha1 TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
frames_count INTEGER,
|
||||
loop_start INTEGER,
|
||||
loop_end INTEGER,
|
||||
frames TEXT,
|
||||
repeat_count INTEGER,
|
||||
flags INTEGER,
|
||||
PRIMARY KEY (ann_sha1, name)
|
||||
)
|
||||
"""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/}.
|
||||
*
|
||||
* <p>Sekcja {@code [Language]} instalatora wymienia identyfikatory LCID Windows:
|
||||
* <pre>
|
||||
* 0415 = Polish
|
||||
* 040e = Hungarian
|
||||
* 0405 = Czech
|
||||
* 041b = Slovak
|
||||
* </pre>
|
||||
* 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.
|
||||
*
|
||||
* <p>Dubbing widać dodatkowo w drzewie plików: <i>Wojna Trojańska</i> 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.
|
||||
*
|
||||
* <p>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<String, String> 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<String> languages, List<String> 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<String> verifyAgainstCore() {
|
||||
List<String> 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<RootResult> run() throws Exception {
|
||||
List<Target> targets = targets();
|
||||
List<RootResult> 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<String, IFileSystem> 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<String, String> 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<String> audioDirs = audioDirectories(target.copyId(), target.rootPath());
|
||||
List<String> languages = new ArrayList<>();
|
||||
List<String> audio = new ArrayList<>();
|
||||
List<String> withoutDirectory = new ArrayList<>();
|
||||
StringBuilder evidence = new StringBuilder("install.ini [Language]:");
|
||||
|
||||
for (Map.Entry<String, String> 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<String> dirs, List<String> 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<String, String> parseLanguages(String text) {
|
||||
Map<String, String> 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<String> audioDirectories(long copyId, String rootPath) throws Exception {
|
||||
String prefix = (rootPath.isEmpty() ? "" : rootPath + "/") + "wavs/";
|
||||
Set<String> 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.
|
||||
*
|
||||
* <p>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<Target> targets() throws Exception {
|
||||
Map<Long, Copy> copies = copies();
|
||||
List<Candidate> candidates = candidates(copies.keySet());
|
||||
|
||||
List<Target> 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<String> roots) {
|
||||
}
|
||||
|
||||
private record Candidate(long copyId, String canonical, String filePath) {
|
||||
}
|
||||
|
||||
private static Candidate shallowest(List<Candidate> candidates, long copyId,
|
||||
java.util.function.Predicate<String> 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<Long, Copy> copies() throws Exception {
|
||||
Map<Long, Copy> 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<Candidate> candidates(Set<Long> knownCopies) throws Exception {
|
||||
List<Candidate> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package pl.genschu.rexcatalog.ingest;
|
||||
|
||||
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.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Wyciąga metadane wydania z {@code dane/application.def} — pliku opisu projektu,
|
||||
* który Piklib i BlooMoo trzymają razem z grą.
|
||||
*
|
||||
* <p>Źródło jest lepsze niż tablice hashy, bo pochodzi z samych danych gry, a nie
|
||||
* z listy rozpoznanych bibliotek. Działa na odszyfrowanym cache'u, więc wymaga
|
||||
* wcześniejszego {@code decode}.
|
||||
*/
|
||||
public final class MetadataDetector {
|
||||
|
||||
private static final String DETECTOR = "MetadataDetector";
|
||||
|
||||
/** Obiekt typu APPLICATION nie zawsze nazywa się GAME — bywa UFO, PIRACI. */
|
||||
private static final Pattern APPLICATION =
|
||||
Pattern.compile("^(\\w+):TYPE=APPLICATION\\s*$", Pattern.MULTILINE);
|
||||
|
||||
private static final Pattern ISO_DATE = Pattern.compile("^(\\d{4}-\\d{2}-\\d{2})");
|
||||
|
||||
private final Database db;
|
||||
private final Path dataDir;
|
||||
private final String coreVersion;
|
||||
|
||||
public MetadataDetector(Database db, Path dataDir, String coreVersion) {
|
||||
this.db = db;
|
||||
this.dataDir = dataDir;
|
||||
this.coreVersion = coreVersion;
|
||||
}
|
||||
|
||||
public record Fact(String field, String value, double confidence, String evidence) {
|
||||
}
|
||||
|
||||
public record RootResult(String copyName, String rootPath, String note, List<Fact> facts) {
|
||||
}
|
||||
|
||||
private record Target(long copyId, String copyName, String rootPath,
|
||||
String filePath, String blobSha1, String artifactPath) {
|
||||
}
|
||||
|
||||
public List<RootResult> run() throws Exception {
|
||||
List<Target> targets = targets();
|
||||
List<RootResult> 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();
|
||||
for (Target target : targets) {
|
||||
delete.setLong(1, target.copyId());
|
||||
delete.setString(2, target.rootPath());
|
||||
delete.setString(3, DETECTOR);
|
||||
delete.executeUpdate();
|
||||
|
||||
if (target.artifactPath() == null) {
|
||||
results.add(new RootResult(target.copyName(), target.rootPath(),
|
||||
"brak application.def w cache'u — uruchom decode", List.of()));
|
||||
continue;
|
||||
}
|
||||
|
||||
String body = Files.readString(dataDir.resolve(target.artifactPath()),
|
||||
StandardCharsets.UTF_8);
|
||||
List<Fact> facts = parse(body, target.filePath(), target.blobSha1());
|
||||
if (facts.isEmpty()) {
|
||||
results.add(new RootResult(target.copyName(), target.rootPath(),
|
||||
"nie znaleziono obiektu TYPE=APPLICATION", List.of()));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (Fact fact : facts) {
|
||||
insert.setLong(1, target.copyId());
|
||||
insert.setString(2, target.rootPath());
|
||||
insert.setString(3, fact.field());
|
||||
insert.setString(4, fact.value());
|
||||
insert.setDouble(5, fact.confidence());
|
||||
insert.setString(6, fact.evidence());
|
||||
insert.setString(7, DETECTOR);
|
||||
insert.setString(8, coreVersion);
|
||||
insert.setString(9, now);
|
||||
insert.executeUpdate();
|
||||
}
|
||||
results.add(new RootResult(target.copyName(), target.rootPath(), null, facts));
|
||||
}
|
||||
conn.commit();
|
||||
} catch (Exception e) {
|
||||
conn.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
conn.setAutoCommit(previousAutoCommit);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private List<Fact> parse(String body, String filePath, String blobSha1) {
|
||||
Matcher application = APPLICATION.matcher(body);
|
||||
if (!application.find()) {
|
||||
return List.of();
|
||||
}
|
||||
String object = application.group(1);
|
||||
List<Fact> facts = new ArrayList<>();
|
||||
|
||||
// odcisk pliku opisu — w dwupacku to jedyne, co odróżnia dwa korzenie,
|
||||
// bo bibliotekę Piklib obie gry mają bit w bit tę samą
|
||||
facts.add(new Fact("app_def_sha1", blobSha1, 1.0, filePath));
|
||||
facts.add(new Fact("app_object", object, 1.0, filePath + ": OBJECT=" + object));
|
||||
|
||||
field(body, object, "LASTMODIFYTIME").ifPresent(raw -> {
|
||||
Matcher iso = ISO_DATE.matcher(raw);
|
||||
// data ostatniej modyfikacji projektu przybliża datę builda, nie wydania —
|
||||
// stąd pewność poniżej jedynki nawet przy czytelnym formacie
|
||||
facts.add(iso.find()
|
||||
? new Fact("release_date", iso.group(1), 0.7,
|
||||
evidence(filePath, object, "LASTMODIFYTIME", raw))
|
||||
: new Fact("release_date", raw, 0.2,
|
||||
evidence(filePath, object, "LASTMODIFYTIME", raw)
|
||||
+ " (nierozpoznany format daty)"));
|
||||
});
|
||||
|
||||
field(body, object, "CREATIONTIME").ifPresent(raw ->
|
||||
// wspólna dla całej serii — to data założenia projektu, nie wydania
|
||||
facts.add(new Fact("project_created", raw, 1.0,
|
||||
evidence(filePath, object, "CREATIONTIME", raw))));
|
||||
|
||||
field(body, object, "VERSION").ifPresent(raw ->
|
||||
facts.add(new Fact("game_version", raw, 1.0,
|
||||
evidence(filePath, object, "VERSION", raw))));
|
||||
|
||||
field(body, object, "BLOOMOO_VERSION").ifPresent(raw ->
|
||||
facts.add(new Fact("engine_version", raw, 0.95,
|
||||
evidence(filePath, object, "BLOOMOO_VERSION", raw))));
|
||||
|
||||
field(body, object, "EPISODES").ifPresent(raw ->
|
||||
facts.add(new Fact("episodes", raw, 1.0,
|
||||
evidence(filePath, object, "EPISODES", raw))));
|
||||
|
||||
field(body, object, "AUTHOR")
|
||||
.map(MetadataDetector::unquote)
|
||||
.filter(value -> !value.isBlank())
|
||||
.ifPresent(value -> facts.add(new Fact("author", value, 1.0,
|
||||
evidence(filePath, object, "AUTHOR", value))));
|
||||
|
||||
return facts;
|
||||
}
|
||||
|
||||
private static java.util.Optional<String> field(String body, String object, String key) {
|
||||
Matcher m = Pattern.compile("^" + Pattern.quote(object) + ":" + key + "=(.*)$",
|
||||
Pattern.MULTILINE).matcher(body);
|
||||
if (!m.find()) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
String value = m.group(1).trim();
|
||||
return value.isEmpty() ? java.util.Optional.empty() : java.util.Optional.of(value);
|
||||
}
|
||||
|
||||
/** Skrypty bywają cytowane zarówno ASCII, jak i typograficznymi cudzysłowami. */
|
||||
private static String unquote(String value) {
|
||||
return value.replaceAll("^[\"”„“]+|[\"”„“]+$", "").trim();
|
||||
}
|
||||
|
||||
private static String evidence(String filePath, String object, String key, String value) {
|
||||
return filePath + ": " + object + ":" + key + "=" + value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dopasowuje application.def do korzenia gry. W dwupacku każdy korzeń ma własny,
|
||||
* więc wybieramy plik leżący najpłycej pod danym prefiksem.
|
||||
*/
|
||||
private List<Target> targets() throws Exception {
|
||||
Map<String, Target> best = new LinkedHashMap<>();
|
||||
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||
SELECT g.copy_id, c.display_name, g.root_path,
|
||||
COALESCE(f.path_raw, f.path) AS file_path, f.path AS canonical,
|
||||
f.blob_sha1, a.path AS artifact_path
|
||||
FROM game_root g
|
||||
JOIN copy c ON c.id = g.copy_id
|
||||
JOIN file f ON f.copy_id = g.copy_id
|
||||
LEFT JOIN artifact a ON a.blob_sha1 = f.blob_sha1 AND a.kind = 'script'
|
||||
WHERE f.path LIKE '%application.def'
|
||||
ORDER BY g.copy_id, g.root_path
|
||||
""");
|
||||
ResultSet rs = st.executeQuery()) {
|
||||
|
||||
while (rs.next()) {
|
||||
String rootPath = rs.getString("root_path");
|
||||
String canonical = rs.getString("canonical");
|
||||
String prefix = rootPath.isEmpty() ? "" : rootPath + "/";
|
||||
if (!canonical.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
String key = rs.getLong("copy_id") + " | ||||