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; + } +}