Add relocate, and a CUDA image for transcribing on an NVIDIA box

The database records absolute paths to the disc images, because audio, animation
frames and transcription all read straight out of them. Carrying catalog.sqlite
to another machine therefore leaves a catalogue that displays everything and can
play nothing — and the same already applied between host and container, where the
collection is /Users/... on one side and /media on the other.

`relocate <directory>` re-points every copy, matching on filename and confirming
by size, with --verify adding a full SHA-256 and --dry-run showing the outcome
first. Copies that cannot be matched keep their old path and are reported rather
than quietly rewritten.

Names are compared after Unicode normalisation, which turned out to be the whole
problem in practice: macOS stores "Wojna Trojańska.iso" decomposed (n + combining
acute) while the database held it composed. Two of thirteen copies failed to match
until that was fixed — and the same mismatch is exactly what would happen carrying
a collection between macOS and Windows.

Verified by rewriting the paths in a copy of the database to a Windows-shaped
D:\Kolekcja, relocating, and then reading a voice line and an animation frame out
of the discs through the relocated database.

Dockerfile.cuda builds whisper.cpp with GGML_CUDA for compute capability 8.6
(GeForce RTX 30), on nvidia/cuda for both stages, with the JRE installed on top of
the CUDA runtime. It is a separate file because both base images differ; folding it
into the main Dockerfile would be more conditionals than content. Not verified
beyond `docker build --check`: there is no NVIDIA GPU here, and the images are
amd64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Patryk Gensch
2026-08-21 17:10:36 +02:00
co-authored by Claude Opus 5
parent b99f239553
commit 20ce26364c
3 changed files with 332 additions and 0 deletions
+76
View File
@@ -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"]
@@ -2,6 +2,7 @@ package pl.genschu.rexcatalog;
import pl.genschu.rexcatalog.catalog.Editor; import pl.genschu.rexcatalog.catalog.Editor;
import pl.genschu.rexcatalog.catalog.Promoter; import pl.genschu.rexcatalog.catalog.Promoter;
import pl.genschu.rexcatalog.catalog.Relocator;
import pl.genschu.rexcatalog.db.Database; import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.ingest.Ingestor; import pl.genschu.rexcatalog.ingest.Ingestor;
import pl.genschu.rexcatalog.ingest.LanguageDetector; import pl.genschu.rexcatalog.ingest.LanguageDetector;
@@ -57,6 +58,7 @@ public final class Main {
case "transcribe" -> transcribe(dataDir, args); case "transcribe" -> transcribe(dataDir, args);
case "analyze" -> analyze(dataDir); case "analyze" -> analyze(dataDir);
case "promote" -> promote(dataDir); case "promote" -> promote(dataDir);
case "relocate" -> relocate(dataDir, args);
case "titles" -> titles(dataDir); case "titles" -> titles(dataDir);
case "set" -> set(dataDir, args); case "set" -> set(dataDir, args);
case "lang" -> lang(dataDir, args); case "lang" -> lang(dataDir, args);
@@ -344,6 +346,81 @@ public final class Main {
} }
} }
/**
* 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 { private static void titles(Path dataDir) throws Exception {
try (Database db = Database.open(dataDir); try (Database db = Database.open(dataDir);
Statement st = db.connection().createStatement(); Statement st = db.connection().createStatement();
@@ -647,6 +724,7 @@ public final class Main {
rozpoznaj mowę przez whisper.cpp (wynik maszynowy) rozpoznaj mowę przez whisper.cpp (wynik maszynowy)
analyze wyciągnij metadane z application.def analyze wyciągnij metadane z application.def
promote utwórz tytuły i wydania z wykrytych faktów 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 titles lista tytułów z metadanymi
set <encja> <id> ... edytuj metadane ręczne set <encja> <id> ... edytuj metadane ręczne
lang add|rm ... wersje językowe wydania lang add|rm ... wersje językowe wydania
@@ -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;
}
}