From 20ce26364cf71097d4a34358c50e5282f2634444 Mon Sep 17 00:00:00 2001 From: Patryk Gensch <43010113+patryk025@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:10:36 +0200 Subject: [PATCH] Add relocate, and a CUDA image for transcribing on an NVIDIA box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 --- Dockerfile.cuda | 76 ++++++++ src/main/java/pl/genschu/rexcatalog/Main.java | 78 ++++++++ .../genschu/rexcatalog/catalog/Relocator.java | 178 ++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 Dockerfile.cuda create mode 100644 src/main/java/pl/genschu/rexcatalog/catalog/Relocator.java diff --git a/Dockerfile.cuda b/Dockerfile.cuda new file mode 100644 index 0000000..7023b8d --- /dev/null +++ b/Dockerfile.cuda @@ -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"] diff --git a/src/main/java/pl/genschu/rexcatalog/Main.java b/src/main/java/pl/genschu/rexcatalog/Main.java index 5ebf12e..6bdc2bb 100644 --- a/src/main/java/pl/genschu/rexcatalog/Main.java +++ b/src/main/java/pl/genschu/rexcatalog/Main.java @@ -2,6 +2,7 @@ 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; @@ -57,6 +58,7 @@ public final class Main { 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); @@ -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 [--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 + 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 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(); @@ -647,6 +724,7 @@ public final class Main { 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 przypnij bazę do kolekcji w nowym miejscu titles lista tytułów z metadanymi set ... edytuj metadane ręczne lang add|rm ... wersje językowe wydania diff --git a/src/main/java/pl/genschu/rexcatalog/catalog/Relocator.java b/src/main/java/pl/genschu/rexcatalog/catalog/Relocator.java new file mode 100644 index 0000000..be159be --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/catalog/Relocator.java @@ -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. + * + *

{@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. + * + *

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. + * + *

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 run(Path root, boolean verify, boolean dryRun) throws Exception { + if (!Files.isDirectory(root)) { + throw new IllegalArgumentException("to nie jest katalog: " + root); + } + Map> byName = index(root); + List 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 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 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> index(Path root) throws IOException { + Map> 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 copies() throws Exception { + List 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; + } +}