init
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// Provider abstraction, Immich client, upload pipeline, configuration.
|
||||
// Nothing in this module may import a Minecraft or Fabric type.
|
||||
@@ -0,0 +1,113 @@
|
||||
package dev.photosync.core;
|
||||
|
||||
import dev.photosync.core.config.ConfigService;
|
||||
import dev.photosync.core.config.ConfigStore;
|
||||
import dev.photosync.core.config.PhotoSyncConfig;
|
||||
import dev.photosync.core.provider.ProviderCatalog;
|
||||
import dev.photosync.core.provider.ProviderFactory;
|
||||
import dev.photosync.core.provider.ProviderSession;
|
||||
import dev.photosync.core.provider.immich.ImmichProviderFactory;
|
||||
import dev.photosync.core.thumbnail.ThumbnailLoader;
|
||||
import dev.photosync.core.timeline.TimelineBrowser;
|
||||
import dev.photosync.core.upload.UploadCoordinator;
|
||||
import dev.photosync.core.upload.UploadQueue;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Everything the mod does that is not Minecraft, assembled.
|
||||
*
|
||||
* <p>This is the seam the platform code sees: a Fabric entrypoint builds one of
|
||||
* these with a config directory and then only ever talks to the services hanging
|
||||
* off it. Nothing below this package knows what a {@code Screen} is, and nothing
|
||||
* here reaches back up.
|
||||
*
|
||||
* <p>The wiring is done in a constructor rather than by a container because
|
||||
* there are seven objects and their order is fixed. The one piece of behaviour
|
||||
* that lives here is the subscription that rebuilds the provider whenever the
|
||||
* player edits their credentials.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class PhotoSync implements AutoCloseable {
|
||||
|
||||
private static final String CONFIG_FILE = "photosync.json";
|
||||
private static final String QUEUE_FILE = "uploads.json";
|
||||
|
||||
@Getter
|
||||
private final ProviderCatalog catalog;
|
||||
@Getter
|
||||
private final ConfigService config;
|
||||
@Getter
|
||||
private final ProviderSession session;
|
||||
@Getter
|
||||
private final UploadQueue queue;
|
||||
@Getter
|
||||
private final UploadCoordinator uploads;
|
||||
@Getter
|
||||
private final TimelineBrowser browser;
|
||||
@Getter
|
||||
private final ThumbnailLoader thumbnails;
|
||||
|
||||
/** The standard set of backends. */
|
||||
public PhotoSync(Path directory) {
|
||||
this(directory, List.of(new ImmichProviderFactory()));
|
||||
}
|
||||
|
||||
/** Takes the backend list explicitly so tests can run against a fake one. */
|
||||
public PhotoSync(Path directory, List<ProviderFactory> providers) {
|
||||
this.catalog = new ProviderCatalog(providers);
|
||||
this.config = new ConfigService(new ConfigStore(directory.resolve(CONFIG_FILE), catalog.preferred()));
|
||||
this.session = new ProviderSession(catalog);
|
||||
// Reads and repairs the on-disk queue, so anything interrupted by the
|
||||
// last quit is already pending again by the time uploads start.
|
||||
this.queue = new UploadQueue(directory.resolve(QUEUE_FILE));
|
||||
this.uploads = new UploadCoordinator(queue, session, () -> config.current().upload());
|
||||
this.browser = new TimelineBrowser(session);
|
||||
this.thumbnails = new ThumbnailLoader(session);
|
||||
}
|
||||
|
||||
/** Connects to the configured backend and starts draining the queue. */
|
||||
public PhotoSync start() {
|
||||
config.onChange(this::applyConnection);
|
||||
applyConnection(config.current());
|
||||
uploads.start();
|
||||
int resumed = queue.activeCount();
|
||||
if (resumed > 0) {
|
||||
log.info("Resuming {} upload(s) left over from the last session", resumed);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** True while anything is still queued, uploading or waiting to retry. */
|
||||
public boolean isBusy() {
|
||||
return queue.hasActiveWork();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the queue, giving up after {@code timeout}.
|
||||
*
|
||||
* <p>Answers whether it finished, which is what the quit dialog reports back
|
||||
* to the player. Whatever is left is already on disk and will be picked up
|
||||
* the next time the game starts.
|
||||
*/
|
||||
public boolean drain(Duration timeout) {
|
||||
return uploads.shutdown(timeout);
|
||||
}
|
||||
|
||||
private void applyConnection(PhotoSyncConfig current) {
|
||||
session.configure(current.provider(), current.connection());
|
||||
uploads.wake();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
thumbnails.close();
|
||||
browser.close();
|
||||
uploads.close();
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.photosync.core.capture;
|
||||
|
||||
/** Whether the player pressed the key, or the auto-capture timer fired. */
|
||||
public enum CaptureOrigin {
|
||||
MANUAL,
|
||||
AUTOMATIC
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.photosync.core.capture;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* A screenshot that has just landed in the screenshots folder.
|
||||
*
|
||||
* <p>Produced by the platform layer -- which is the only part that knows how
|
||||
* Minecraft writes the file -- and consumed by the upload queue.
|
||||
*/
|
||||
public record CapturedScreenshot(Path file, String fileName, Instant capturedAt, CaptureOrigin origin, long sizeBytes) {
|
||||
|
||||
public static CapturedScreenshot of(Path file, CaptureOrigin origin) throws IOException {
|
||||
return new CapturedScreenshot(
|
||||
file.toAbsolutePath(),
|
||||
file.getFileName().toString(),
|
||||
Files.getLastModifiedTime(file).toInstant(),
|
||||
origin,
|
||||
Files.size(file));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* Unattended screenshots on a timer.
|
||||
*
|
||||
* <p>Off by default, and deliberately so: a mod that silently starts writing
|
||||
* files and uploading them the moment it is installed would be a bad neighbour.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record AutoCaptureSettings(
|
||||
boolean enabled,
|
||||
int intervalSeconds,
|
||||
String fileNameSuffix,
|
||||
boolean onlyInWorld,
|
||||
boolean skipWhenScreenOpen) {
|
||||
|
||||
public static final int MIN_INTERVAL_SECONDS = 5;
|
||||
public static final int MAX_INTERVAL_SECONDS = 3600;
|
||||
|
||||
public static AutoCaptureSettings defaults() {
|
||||
return AutoCaptureSettings.builder()
|
||||
.enabled(false)
|
||||
.intervalSeconds(300)
|
||||
.fileNameSuffix("_auto")
|
||||
.onlyInWorld(true)
|
||||
.skipWhenScreenOpen(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
AutoCaptureSettings normalized() {
|
||||
return toBuilder()
|
||||
.intervalSeconds(Math.min(MAX_INTERVAL_SECONDS, Math.max(MIN_INTERVAL_SECONDS, intervalSeconds)))
|
||||
.fileNameSuffix(sanitizeSuffix(fileNameSuffix))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The suffix lands in a file name, so anything that would break a path has
|
||||
* to go. An empty result is allowed -- it just means "no suffix".
|
||||
*/
|
||||
private static String sanitizeSuffix(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder clean = new StringBuilder(raw.length());
|
||||
raw.chars()
|
||||
.filter(ch -> ch > 0x1F && "\\/:*?\"<>|".indexOf(ch) < 0)
|
||||
.forEach(ch -> clean.append((char) ch));
|
||||
return clean.toString().strip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** Look and memory budget of the timeline browser. */
|
||||
@Builder(toBuilder = true)
|
||||
public record BrowserSettings(int tileSize, int thumbnailCacheEntries, boolean showVideoBadge) {
|
||||
|
||||
public static BrowserSettings defaults() {
|
||||
return BrowserSettings.builder()
|
||||
.tileSize(96)
|
||||
// Each cached tile is an uploaded GPU texture plus its decoded
|
||||
// bytes, so this is the single biggest memory knob in the mod.
|
||||
.thumbnailCacheEntries(256)
|
||||
.showVideoBadge(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
BrowserSettings normalized() {
|
||||
return toBuilder()
|
||||
.tileSize(Math.min(192, Math.max(48, tileSize)))
|
||||
.thumbnailCacheEntries(Math.min(2048, Math.max(32, thumbnailCacheEntries)))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* Holds the live configuration and tells the rest of the mod when it changes.
|
||||
*
|
||||
* <p>The upload coordinator, the auto-capture timer and the browser all need to
|
||||
* react to a settings edit without the settings screen knowing they exist, so
|
||||
* they subscribe here instead.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ConfigService {
|
||||
|
||||
private final ConfigStore store;
|
||||
private final AtomicReference<PhotoSyncConfig> current;
|
||||
private final List<Consumer<PhotoSyncConfig>> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
public ConfigService(ConfigStore store) {
|
||||
this.store = store;
|
||||
this.current = new AtomicReference<>(store.load());
|
||||
}
|
||||
|
||||
public PhotoSyncConfig current() {
|
||||
return current.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to later changes. Listeners run on whichever thread made the
|
||||
* change -- in practice the client thread, from the settings screen.
|
||||
*/
|
||||
public void onChange(Consumer<PhotoSyncConfig> listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
public void update(UnaryOperator<PhotoSyncConfig> change) {
|
||||
PhotoSyncConfig updated = current.updateAndGet(previous -> change.apply(previous).normalized(previous.provider()));
|
||||
persist(updated);
|
||||
for (Consumer<PhotoSyncConfig> listener : listeners) {
|
||||
try {
|
||||
listener.accept(updated);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("A config listener failed; the change itself was still applied", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void persist(PhotoSyncConfig config) {
|
||||
try {
|
||||
store.save(config);
|
||||
} catch (IOException e) {
|
||||
// Losing the write is bad but not fatal: the in-memory config is
|
||||
// already updated, so the session behaves as the player asked.
|
||||
log.error("Could not write {}", store.path(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import dev.photosync.core.persistence.JsonFile;
|
||||
import dev.photosync.core.provider.ProviderId;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads and writes {@code photosync.json}.
|
||||
*
|
||||
* <p>Loading merges the stored file <em>onto</em> the serialized defaults rather
|
||||
* than deserializing it directly. A setting added in a later release is therefore
|
||||
* present with its default in an old config file, and a setting removed in a
|
||||
* later release is ignored -- no schema version, no migration step. This is not
|
||||
* a nicety: Gson gives every absent record component {@code null} or {@code 0},
|
||||
* so without the merge a newly added {@code lingerMillis} would arrive as zero
|
||||
* and notifications would vanish for everyone upgrading.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ConfigStore {
|
||||
|
||||
private final JsonFile file;
|
||||
private final ProviderId fallbackProvider;
|
||||
|
||||
public ConfigStore(Path path, ProviderId fallbackProvider) {
|
||||
this.file = new JsonFile(path);
|
||||
this.fallbackProvider = fallbackProvider;
|
||||
}
|
||||
|
||||
public Path path() {
|
||||
return file.path();
|
||||
}
|
||||
|
||||
/** Never throws: a broken config falls back to defaults rather than blocking start-up. */
|
||||
public PhotoSyncConfig load() {
|
||||
PhotoSyncConfig defaults = PhotoSyncConfig.defaults(fallbackProvider);
|
||||
Optional<JsonElement> stored = file.readTree();
|
||||
if (stored.isEmpty() || !stored.get().isJsonObject()) {
|
||||
return defaults;
|
||||
}
|
||||
try {
|
||||
JsonObject merged = file.gson().toJsonTree(defaults).getAsJsonObject();
|
||||
overlay(merged, stored.get().getAsJsonObject());
|
||||
return file.gson().fromJson(merged, PhotoSyncConfig.class).normalized(fallbackProvider);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("{} could not be understood; using defaults", file.path(), e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
public void save(PhotoSyncConfig config) throws IOException {
|
||||
file.write(file.gson().toJsonTree(config));
|
||||
}
|
||||
|
||||
/** Recursively copies {@code stored} over {@code base}, object by object. */
|
||||
private static void overlay(JsonObject base, JsonObject stored) {
|
||||
for (Map.Entry<String, JsonElement> entry : stored.entrySet()) {
|
||||
JsonElement current = base.get(entry.getKey());
|
||||
JsonElement incoming = entry.getValue();
|
||||
if (current != null && current.isJsonObject() && incoming.isJsonObject()) {
|
||||
overlay(current.getAsJsonObject(), incoming.getAsJsonObject());
|
||||
} else {
|
||||
base.add(entry.getKey(), incoming);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
/** Which of the three things worth a corner message just happened. */
|
||||
public enum NotificationKind {
|
||||
CAPTURED,
|
||||
UPLOADED,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* The one-line messages that appear in the bottom-left corner.
|
||||
*
|
||||
* <p>Each event is switchable on its own, because the useful setting for a
|
||||
* player running auto-capture every thirty seconds is different from the one for
|
||||
* a player who screenshots twice an hour.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record NotificationSettings(
|
||||
boolean enabled,
|
||||
boolean onCapture,
|
||||
boolean onUploaded,
|
||||
boolean onFailed,
|
||||
int lingerMillis) {
|
||||
|
||||
public static NotificationSettings defaults() {
|
||||
return NotificationSettings.builder()
|
||||
.enabled(true)
|
||||
.onCapture(true)
|
||||
.onUploaded(true)
|
||||
.onFailed(true)
|
||||
.lingerMillis(3000)
|
||||
.build();
|
||||
}
|
||||
|
||||
NotificationSettings normalized() {
|
||||
return toBuilder()
|
||||
.lingerMillis(Math.min(15_000, Math.max(500, lingerMillis)))
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean shows(NotificationKind kind) {
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
return switch (kind) {
|
||||
case CAPTURED -> onCapture;
|
||||
case UPLOADED -> onUploaded;
|
||||
case FAILED -> onFailed;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.ProviderConnection;
|
||||
import dev.photosync.core.provider.ProviderId;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* The whole of PhotoSync's settings, as one immutable value.
|
||||
*
|
||||
* <p>Screens never mutate this. They build a new one -- {@code
|
||||
* config.toBuilder().albumId(id).build()} -- and hand it to {@link
|
||||
* ConfigService}, which persists it and tells everyone who cares. That makes
|
||||
* "cancel" free and "what changed?" answerable.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record PhotoSyncConfig(
|
||||
ProviderId provider,
|
||||
ProviderConnection connection,
|
||||
String albumId,
|
||||
UploadSettings upload,
|
||||
AutoCaptureSettings autoCapture,
|
||||
NotificationSettings notifications,
|
||||
BrowserSettings browser) {
|
||||
|
||||
public static PhotoSyncConfig defaults(ProviderId provider) {
|
||||
return PhotoSyncConfig.builder()
|
||||
.provider(provider)
|
||||
.connection(ProviderConnection.empty())
|
||||
.albumId("")
|
||||
.upload(UploadSettings.defaults())
|
||||
.autoCapture(AutoCaptureSettings.defaults())
|
||||
.notifications(NotificationSettings.defaults())
|
||||
.browser(BrowserSettings.defaults())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs a config that came off disk: fills in anything a hand edit removed
|
||||
* and clamps anything a hand edit made absurd. Called on every load, so the
|
||||
* rest of the mod may assume its values are sane.
|
||||
*/
|
||||
public PhotoSyncConfig normalized(ProviderId fallbackProvider) {
|
||||
return PhotoSyncConfig.builder()
|
||||
.provider(provider == null ? fallbackProvider : provider)
|
||||
.connection(connection == null ? ProviderConnection.empty() : connection)
|
||||
.albumId(albumId == null ? "" : albumId.trim())
|
||||
.upload((upload == null ? UploadSettings.defaults() : upload).normalized())
|
||||
.autoCapture((autoCapture == null ? AutoCaptureSettings.defaults() : autoCapture).normalized())
|
||||
.notifications((notifications == null ? NotificationSettings.defaults() : notifications).normalized())
|
||||
.browser((browser == null ? BrowserSettings.defaults() : browser).normalized())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Where uploads go and what the browser shows. Empty album id means the whole library. */
|
||||
public AlbumRef album() {
|
||||
return AlbumRef.of(albumId);
|
||||
}
|
||||
|
||||
/** True once there is enough here to actually talk to a server. */
|
||||
public boolean isReady() {
|
||||
return connection.isConfigured();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** How aggressively the queue drains, and what happens when the player quits. */
|
||||
@Builder(toBuilder = true)
|
||||
public record UploadSettings(
|
||||
boolean uploadOnCapture,
|
||||
int concurrency,
|
||||
int maxAttempts,
|
||||
int retryBackoffSeconds,
|
||||
boolean waitOnQuit,
|
||||
boolean deleteLocalAfterUpload) {
|
||||
|
||||
public static final int MAX_CONCURRENCY = 4;
|
||||
|
||||
public static UploadSettings defaults() {
|
||||
return UploadSettings.builder()
|
||||
.uploadOnCapture(true)
|
||||
.concurrency(2)
|
||||
.maxAttempts(5)
|
||||
// Multiplied by 2^(attempt-1), so 5s grows to 80s by the fifth try.
|
||||
.retryBackoffSeconds(5)
|
||||
.waitOnQuit(true)
|
||||
.deleteLocalAfterUpload(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
UploadSettings normalized() {
|
||||
return toBuilder()
|
||||
.concurrency(Math.min(MAX_CONCURRENCY, Math.max(1, concurrency)))
|
||||
.maxAttempts(Math.min(20, Math.max(1, maxAttempts)))
|
||||
.retryBackoffSeconds(Math.min(300, Math.max(1, retryBackoffSeconds)))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Everything PhotoSync does that is not about Minecraft.
|
||||
*
|
||||
* <p>Nothing under {@code dev.photosync.core} may import a Minecraft, Fabric or
|
||||
* LWJGL type. That rule is what lets this module be compiled once, to Java 17
|
||||
* bytecode, and folded unchanged into all nine platform jars. It is also what
|
||||
* makes the upload pipeline testable without a game running.
|
||||
*
|
||||
* <p>The dependency arrow only ever points inwards: platform code depends on
|
||||
* {@code :shared:ui}, which depends on {@code :shared:mc-api} and this module.
|
||||
*/
|
||||
package dev.photosync.core;
|
||||
@@ -0,0 +1,74 @@
|
||||
package dev.photosync.core.persistence;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import dev.photosync.core.provider.ProviderId;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* The handful of value types PhotoSync persists that Gson would otherwise write
|
||||
* as nested objects.
|
||||
*
|
||||
* <p>Everything else -- records, enums, primitives -- Gson already handles, and
|
||||
* on purpose: the config and queue models are built out of types the library
|
||||
* understands so that this class stays this short. Note the Gson version we
|
||||
* compile against (2.10, the oldest Minecraft ships in the supported range) is
|
||||
* also the first that can construct records, which is what makes that possible.
|
||||
*/
|
||||
final class Adapters {
|
||||
|
||||
private Adapters() {
|
||||
}
|
||||
|
||||
static Gson newGson() {
|
||||
return new GsonBuilder()
|
||||
.registerTypeAdapter(ProviderId.class, new ProviderIdAdapter().nullSafe())
|
||||
.registerTypeAdapter(Instant.class, new InstantAdapter().nullSafe())
|
||||
.setPrettyPrinting()
|
||||
.disableHtmlEscaping()
|
||||
.create();
|
||||
}
|
||||
|
||||
/** Writes {@code "immich"} rather than {@code {"key":"immich"}}. */
|
||||
private static final class ProviderIdAdapter extends TypeAdapter<ProviderId> {
|
||||
|
||||
@Override
|
||||
public void write(JsonWriter out, ProviderId value) throws IOException {
|
||||
out.value(value.key());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderId read(JsonReader in) throws IOException {
|
||||
String key = in.nextString();
|
||||
return key.isBlank() ? null : new ProviderId(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** ISO-8601, so the queue file stays readable when someone goes looking. */
|
||||
private static final class InstantAdapter extends TypeAdapter<Instant> {
|
||||
|
||||
@Override
|
||||
public void write(JsonWriter out, Instant value) throws IOException {
|
||||
out.value(value.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant read(JsonReader in) throws IOException {
|
||||
if (in.peek() == JsonToken.NUMBER) {
|
||||
return Instant.ofEpochMilli(in.nextLong());
|
||||
}
|
||||
try {
|
||||
return Instant.parse(in.nextString());
|
||||
} catch (DateTimeParseException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package dev.photosync.core.persistence;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* One JSON document on disk, written so that it cannot be found half-written.
|
||||
*
|
||||
* <p>PhotoSync keeps two of these -- the settings and the upload queue -- and
|
||||
* both have the same requirement: the game can be killed at any moment, and what
|
||||
* survives has to be either the old file or the new one. Writing in place would
|
||||
* make a hard shutdown during a save look identical to "the mod lost my API key"
|
||||
* or "the mod lost my queue", so every write goes to a scratch file and is moved
|
||||
* over the target.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class JsonFile {
|
||||
|
||||
private final Path path;
|
||||
private final Gson gson;
|
||||
|
||||
public JsonFile(Path path) {
|
||||
this.path = path;
|
||||
this.gson = Adapters.newGson();
|
||||
}
|
||||
|
||||
public Path path() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Configured with PhotoSync's persistence conventions -- see {@link Adapters}. */
|
||||
public Gson gson() {
|
||||
return gson;
|
||||
}
|
||||
|
||||
public boolean exists() {
|
||||
return Files.isRegularFile(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the file, or returns empty when it is missing, unreadable or not
|
||||
* valid JSON. Callers fall back to a default rather than failing start-up:
|
||||
* losing settings is annoying, refusing to launch is worse.
|
||||
*/
|
||||
public Optional<JsonElement> readTree() {
|
||||
if (!exists()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
String text = Files.readString(path, StandardCharsets.UTF_8);
|
||||
JsonElement parsed = JsonParser.parseString(text);
|
||||
return parsed.isJsonNull() ? Optional.empty() : Optional.of(parsed);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.warn("Could not read {}; falling back to defaults", path, e);
|
||||
quarantine();
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public void write(JsonElement tree) throws IOException {
|
||||
Path directory = path.toAbsolutePath().getParent();
|
||||
Files.createDirectories(directory);
|
||||
|
||||
Path scratch = Files.createTempFile(directory, path.getFileName() + ".", ".tmp");
|
||||
try {
|
||||
Files.writeString(scratch, gson.toJson(tree), StandardCharsets.UTF_8);
|
||||
restrictToOwner(scratch);
|
||||
try {
|
||||
Files.move(scratch, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
// Network shares and some Windows setups refuse atomic moves. A
|
||||
// plain replace is still better than writing the target in place.
|
||||
Files.move(scratch, path, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a corrupt file around under a {@code .broken} name instead of
|
||||
* silently overwriting it on the next save, so a player who lost their
|
||||
* settings still has something to send us.
|
||||
*/
|
||||
private void quarantine() {
|
||||
Path broken = path.resolveSibling(path.getFileName() + ".broken");
|
||||
try {
|
||||
Files.move(path, broken, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.warn("Moved the unreadable file aside as {}", broken);
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not move {} aside", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings file holds an API key. Where the filesystem can express it,
|
||||
* keep the file to its owner; where it cannot, carry on.
|
||||
*/
|
||||
private static void restrictToOwner(Path file) {
|
||||
try {
|
||||
if (file.getFileSystem().supportedFileAttributeViews().contains("posix")) {
|
||||
Files.setPosixFilePermissions(file,
|
||||
Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
|
||||
}
|
||||
} catch (IOException | UnsupportedOperationException e) {
|
||||
log.debug("Could not restrict permissions on {}", file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** One album as shown in the album picker. */
|
||||
public record Album(String id, String name, int assetCount, Optional<String> coverAssetId) {
|
||||
|
||||
public Album {
|
||||
coverAssetId = coverAssetId == null ? Optional.empty() : coverAssetId;
|
||||
}
|
||||
|
||||
public AlbumRef ref() {
|
||||
return AlbumRef.of(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Which slice of the backend we are talking about: one album, or the whole
|
||||
* library.
|
||||
*
|
||||
* <p>Both browsing and uploading take one of these, and "no album configured"
|
||||
* is a first-class case rather than a null id sprinkled through the code.
|
||||
*/
|
||||
public record AlbumRef(Optional<String> id) {
|
||||
|
||||
public AlbumRef {
|
||||
id = id == null ? Optional.empty() : id.filter(value -> !value.isBlank());
|
||||
}
|
||||
|
||||
public static AlbumRef library() {
|
||||
return new AlbumRef(Optional.empty());
|
||||
}
|
||||
|
||||
public static AlbumRef of(String albumId) {
|
||||
return new AlbumRef(Optional.ofNullable(albumId));
|
||||
}
|
||||
|
||||
public boolean isLibrary() {
|
||||
return id.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* PhotoSync never plays video. A video asset is shown as its still preview with
|
||||
* a marker drawn over it, which is why this is a two-value enum and not a media
|
||||
* type hierarchy.
|
||||
*/
|
||||
public enum AssetKind {
|
||||
IMAGE,
|
||||
VIDEO
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The assets of one {@link TimelineBucket}, newest first. */
|
||||
public record BucketPage(TimelineBucket bucket, List<RemoteAsset> assets) {
|
||||
|
||||
public BucketPage {
|
||||
assets = assets == null ? List.of() : List.copyOf(assets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The seam between PhotoSync and whatever is storing the photos.
|
||||
*
|
||||
* <p>Every method is blocking and is only ever called off the render thread, by
|
||||
* the upload workers or the timeline loader. Implementations must be safe to
|
||||
* call from several threads at once.
|
||||
*
|
||||
* <p>The interface is deliberately narrow and read-mostly. PhotoSync uploads,
|
||||
* and it browses; it does not delete, favourite or move remote assets, so no
|
||||
* such capability is exposed here. Keeping it that way is what makes a second
|
||||
* backend a weekend's work instead of a rewrite.
|
||||
*/
|
||||
public interface PhotoProvider extends AutoCloseable {
|
||||
|
||||
ProviderDescriptor descriptor();
|
||||
|
||||
/**
|
||||
* Verifies that the endpoint answers and the credentials are accepted.
|
||||
*
|
||||
* @throws ProviderException with {@link ProviderException.Kind#AUTHENTICATION}
|
||||
* if the secret is wrong, or {@link ProviderException.Kind#NETWORK}
|
||||
* if the endpoint cannot be reached
|
||||
*/
|
||||
ProviderIdentity identify() throws ProviderException;
|
||||
|
||||
/** Albums the account can upload into, for the settings picker. */
|
||||
List<Album> albums() throws ProviderException;
|
||||
|
||||
Album createAlbum(String name) throws ProviderException;
|
||||
|
||||
/**
|
||||
* Uploads one file and, when {@link UploadRequest#album()} names an album,
|
||||
* puts it there.
|
||||
*
|
||||
* <p>Must be idempotent with respect to file content: re-uploading bytes the
|
||||
* backend already has has to succeed with
|
||||
* {@link UploadReceipt.Outcome#DUPLICATE} rather than fail or duplicate.
|
||||
* The upload queue leans on this when it retries a job whose response was
|
||||
* lost -- for instance because the game was killed mid-request.
|
||||
*/
|
||||
UploadReceipt upload(UploadRequest request, TransferProgress progress) throws ProviderException;
|
||||
|
||||
/**
|
||||
* Every bucket of the timeline with its asset count, newest first.
|
||||
*
|
||||
* <p>One cheap call that gives the browser enough to size its scrollbar for
|
||||
* the entire album without fetching any asset.
|
||||
*/
|
||||
List<TimelineBucket> timeline(AlbumRef album) throws ProviderException;
|
||||
|
||||
/** The assets of a single bucket returned by {@link #timeline(AlbumRef)}. */
|
||||
BucketPage page(AlbumRef album, TimelineBucket bucket) throws ProviderException;
|
||||
|
||||
/**
|
||||
* Encoded image bytes for one asset.
|
||||
*
|
||||
* <p>Must be PNG or JPEG. The game decodes these with stb_image, which reads
|
||||
* neither WebP nor AVIF, so a backend that stores those has to transcode or
|
||||
* ask for a different rendition -- the mod cannot recover from it later.
|
||||
*/
|
||||
byte[] thumbnail(String assetId, ThumbnailSize size) throws ProviderException;
|
||||
|
||||
/** Releases connections. Never throws, so callers can use try-with-resources cleanly. */
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The set of backends this build knows about, in the order the settings screen
|
||||
* offers them.
|
||||
*
|
||||
* <p>An instance rather than a static registry: it is created once by
|
||||
* {@code PhotoSync} and handed to whoever needs it, which keeps the wiring
|
||||
* visible and the whole thing constructible in a test.
|
||||
*/
|
||||
public final class ProviderCatalog {
|
||||
|
||||
private final Map<ProviderId, ProviderFactory> factories;
|
||||
private final ProviderId preferred;
|
||||
|
||||
public ProviderCatalog(List<ProviderFactory> factories) {
|
||||
if (factories.isEmpty()) {
|
||||
throw new IllegalArgumentException("A catalog needs at least one provider");
|
||||
}
|
||||
Map<ProviderId, ProviderFactory> byId = new LinkedHashMap<>();
|
||||
for (ProviderFactory factory : factories) {
|
||||
ProviderFactory clash = byId.put(factory.descriptor().id(), factory);
|
||||
if (clash != null) {
|
||||
throw new IllegalArgumentException("Duplicate provider id: " + factory.descriptor().id());
|
||||
}
|
||||
}
|
||||
this.factories = Map.copyOf(byId);
|
||||
this.preferred = factories.get(0).descriptor().id();
|
||||
}
|
||||
|
||||
/** Used as the default in a fresh config, and as the fallback for an unknown id. */
|
||||
public ProviderId preferred() {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
public List<ProviderDescriptor> descriptors() {
|
||||
return factories.values().stream().map(ProviderFactory::descriptor).toList();
|
||||
}
|
||||
|
||||
public Optional<ProviderDescriptor> describe(ProviderId id) {
|
||||
return Optional.ofNullable(factories.get(id)).map(ProviderFactory::descriptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects using the named provider, falling back to {@link #preferred()}
|
||||
* when the config names one this build does not have -- which happens when a
|
||||
* player downgrades the mod, and is not worth crashing over.
|
||||
*/
|
||||
public PhotoProvider connect(ProviderId id, ProviderConnection connection) {
|
||||
ProviderFactory factory = factories.getOrDefault(id, factories.get(preferred));
|
||||
return factory.connect(connection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* The two things every token-authenticated photo backend needs: where it lives
|
||||
* and how we prove who we are.
|
||||
*
|
||||
* <p>Deliberately not a provider-specific credential bag. Immich calls these a
|
||||
* server URL and an API key; a future backend may call them something else, and
|
||||
* the labels come from {@link ProviderDescriptor} rather than from here.
|
||||
*/
|
||||
public record ProviderConnection(String endpoint, String secret) {
|
||||
|
||||
public ProviderConnection {
|
||||
endpoint = endpoint == null ? "" : endpoint.trim();
|
||||
secret = secret == null ? "" : secret.trim();
|
||||
}
|
||||
|
||||
public static ProviderConnection empty() {
|
||||
return new ProviderConnection("", "");
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
return !endpoint.isEmpty() && !secret.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacted on purpose. This record ends up in exception messages and debug
|
||||
* logs, and an API key in a pasted log is a real leak.
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ProviderConnection[endpoint=" + endpoint
|
||||
+ ", secret=" + (secret.isEmpty() ? "<unset>" : "<redacted>") + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What the settings screen needs in order to render a provider's connection
|
||||
* form without knowing which provider it is looking at.
|
||||
*
|
||||
* <p>The point of this type is that adding a second backend means writing a
|
||||
* {@link ProviderFactory} and a language file -- not editing the GUI. The UI
|
||||
* resolves labels by appending fixed suffixes to {@link #translationPrefix()}:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code .name} -- provider name in the picker</li>
|
||||
* <li>{@code .endpoint} / {@code .endpoint.hint} -- first credential field</li>
|
||||
* <li>{@code .secret} / {@code .secret.hint} -- second credential field</li>
|
||||
* </ul>
|
||||
*/
|
||||
public record ProviderDescriptor(ProviderId id, String translationPrefix, boolean supportsAlbums) {
|
||||
|
||||
public ProviderDescriptor {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(translationPrefix, "translationPrefix");
|
||||
}
|
||||
|
||||
public String nameKey() {
|
||||
return translationPrefix + ".name";
|
||||
}
|
||||
|
||||
public String endpointKey() {
|
||||
return translationPrefix + ".endpoint";
|
||||
}
|
||||
|
||||
public String endpointHintKey() {
|
||||
return translationPrefix + ".endpoint.hint";
|
||||
}
|
||||
|
||||
public String secretKey() {
|
||||
return translationPrefix + ".secret";
|
||||
}
|
||||
|
||||
public String secretHintKey() {
|
||||
return translationPrefix + ".secret.hint";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Anything a backend can fail with, classified well enough for the upload queue
|
||||
* to decide between "retry later" and "stop and tell the player".
|
||||
*/
|
||||
@Getter
|
||||
public class ProviderException extends Exception {
|
||||
|
||||
public enum Kind {
|
||||
/** Connection refused, DNS failure, timeout. Worth retrying. */
|
||||
NETWORK(true),
|
||||
/** Bad or revoked credentials. Retrying will not help. */
|
||||
AUTHENTICATION(false),
|
||||
/** The album or asset is gone. Retrying will not help. */
|
||||
NOT_FOUND(false),
|
||||
/** Server asked us to slow down. Worth retrying, after a longer wait. */
|
||||
RATE_LIMITED(true),
|
||||
/** 5xx. The server may recover. */
|
||||
SERVER(true),
|
||||
/** The response did not look like what the API promised. */
|
||||
PROTOCOL(false),
|
||||
/** The file we were asked to upload is unreadable or gone. */
|
||||
SOURCE_UNREADABLE(false);
|
||||
|
||||
private final boolean retryable;
|
||||
|
||||
Kind(boolean retryable) {
|
||||
this.retryable = retryable;
|
||||
}
|
||||
|
||||
public boolean isRetryable() {
|
||||
return retryable;
|
||||
}
|
||||
}
|
||||
|
||||
private final Kind kind;
|
||||
|
||||
public ProviderException(Kind kind, String message) {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public ProviderException(Kind kind, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public boolean isRetryable() {
|
||||
return kind.isRetryable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Creates providers of one kind. Registered in a {@link ProviderCatalog} at
|
||||
* start-up; that registration is the only place a new backend has to be named.
|
||||
*/
|
||||
public interface ProviderFactory {
|
||||
|
||||
ProviderDescriptor descriptor();
|
||||
|
||||
/**
|
||||
* Builds a provider bound to these credentials. Cheap and non-blocking: no
|
||||
* network call happens until {@link PhotoProvider#identify()} or a real
|
||||
* request, because this runs while the settings screen is being built.
|
||||
*/
|
||||
PhotoProvider connect(ProviderConnection connection);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Stable key for one backend implementation, for example {@code immich}.
|
||||
*
|
||||
* <p>This value is written to the config file, so it must not change once a
|
||||
* version has shipped.
|
||||
*/
|
||||
public record ProviderId(String key) implements Comparable<ProviderId> {
|
||||
|
||||
public ProviderId {
|
||||
Objects.requireNonNull(key, "key");
|
||||
key = key.trim().toLowerCase(Locale.ROOT);
|
||||
if (key.isEmpty()) {
|
||||
throw new IllegalArgumentException("A provider id must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(ProviderId other) {
|
||||
return key.compareTo(other.key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Proof that the configured credentials work, and something human-readable to
|
||||
* show next to the "Test connection" button.
|
||||
*/
|
||||
public record ProviderIdentity(String serverVersion, String accountName) {
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The one live {@link PhotoProvider}, rebuilt whenever the credentials change.
|
||||
*
|
||||
* <p>Uploads and browsing share it, so they also share its connection pool
|
||||
* rather than each opening their own. It knows nothing about the config file --
|
||||
* it is <em>told</em> to reconfigure -- which keeps the provider package free of
|
||||
* a dependency on the config package.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ProviderSession implements AutoCloseable {
|
||||
|
||||
private final ProviderCatalog catalog;
|
||||
private final Object lock = new Object();
|
||||
|
||||
private ProviderId currentId;
|
||||
private ProviderConnection currentConnection = ProviderConnection.empty();
|
||||
private PhotoProvider provider;
|
||||
|
||||
public ProviderSession(ProviderCatalog catalog) {
|
||||
this.catalog = catalog;
|
||||
this.currentId = catalog.preferred();
|
||||
}
|
||||
|
||||
/** Cheap and idempotent: unchanged credentials leave the provider alone. */
|
||||
public void configure(ProviderId id, ProviderConnection connection) {
|
||||
PhotoProvider discarded = null;
|
||||
synchronized (lock) {
|
||||
if (Objects.equals(currentId, id) && Objects.equals(currentConnection, connection)) {
|
||||
return;
|
||||
}
|
||||
discarded = provider;
|
||||
currentId = id;
|
||||
currentConnection = connection;
|
||||
provider = connection.isConfigured() ? catalog.connect(id, connection) : null;
|
||||
}
|
||||
if (discarded != null) {
|
||||
closeQuietly(discarded);
|
||||
}
|
||||
}
|
||||
|
||||
/** Empty until the player has entered a server and a key. */
|
||||
public Optional<PhotoProvider> provider() {
|
||||
synchronized (lock) {
|
||||
return Optional.ofNullable(provider);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
synchronized (lock) {
|
||||
return provider != null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A throwaway provider for the settings screen's "test connection" button,
|
||||
* so the player can check credentials before committing them.
|
||||
*/
|
||||
public PhotoProvider probe(ProviderId id, ProviderConnection connection) {
|
||||
return catalog.connect(id, connection);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
PhotoProvider discarded;
|
||||
synchronized (lock) {
|
||||
discarded = provider;
|
||||
provider = null;
|
||||
}
|
||||
if (discarded != null) {
|
||||
closeQuietly(discarded);
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeQuietly(PhotoProvider target) {
|
||||
try {
|
||||
target.close();
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Closing a provider threw", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* One item in the remote timeline, reduced to what the grid actually draws.
|
||||
*
|
||||
* <p>{@code localCapturedAt} is the wall-clock time where the photo was taken,
|
||||
* not where the player is sitting now. Day grouping uses it, so a screenshot
|
||||
* taken at 00:30 in Tokyo stays on the Tokyo day even when browsed from Berlin
|
||||
* -- which is what every photo app does and what users expect.
|
||||
*
|
||||
* <p>{@code aspectRatio} arrives with the timeline page, before any image data,
|
||||
* so the grid can lay out and reserve space without a single thumbnail request.
|
||||
*/
|
||||
public record RemoteAsset(
|
||||
String id,
|
||||
AssetKind kind,
|
||||
Instant capturedAt,
|
||||
LocalDateTime localCapturedAt,
|
||||
double aspectRatio,
|
||||
Optional<String> thumbHash,
|
||||
Duration duration) {
|
||||
|
||||
public RemoteAsset {
|
||||
thumbHash = thumbHash == null ? Optional.empty() : thumbHash.filter(hash -> !hash.isBlank());
|
||||
duration = duration == null ? Duration.ZERO : duration;
|
||||
// A zero or negative ratio would divide by zero in the layout pass.
|
||||
if (!(aspectRatio > 0) || Double.isInfinite(aspectRatio)) {
|
||||
aspectRatio = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
public LocalDate localDay() {
|
||||
return localCapturedAt.toLocalDate();
|
||||
}
|
||||
|
||||
public boolean isVideo() {
|
||||
return kind == AssetKind.VIDEO;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Named after what we use the image for rather than after a pixel count, since
|
||||
* every backend has its own idea of what "small" means.
|
||||
*/
|
||||
public enum ThumbnailSize {
|
||||
/** The tile in the timeline grid. */
|
||||
GRID,
|
||||
/** The single-photo view opened by clicking a tile. */
|
||||
DETAIL
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* A month of the remote timeline, and how many assets are in it.
|
||||
*
|
||||
* <p>This is the whole trick behind the virtual list: the backend hands us every
|
||||
* bucket's <em>count</em> in one cheap call, so the scrollbar and the total
|
||||
* height are exact before a single asset has been fetched. Pages are then loaded
|
||||
* only for the months the viewport actually reaches.
|
||||
*
|
||||
* <p>{@code key} is the backend's own opaque token for the bucket and is passed
|
||||
* straight back when requesting its page; {@code month} is the parsed form the
|
||||
* UI sorts and labels with.
|
||||
*/
|
||||
public record TimelineBucket(String key, LocalDate month, int assetCount) implements Comparable<TimelineBucket> {
|
||||
|
||||
/** Newest first, matching how the timeline is displayed. */
|
||||
@Override
|
||||
public int compareTo(TimelineBucket other) {
|
||||
return other.month.compareTo(month);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Thrown out of a {@link TransferProgress} callback to abandon a transfer that
|
||||
* is already on the wire.
|
||||
*
|
||||
* <p>This is how "remove from queue" works on an upload that has started: the
|
||||
* queue flags the job, the next progress callback throws, and the body stream
|
||||
* unwinds. Providers must let it escape rather than wrapping it in a
|
||||
* {@link ProviderException} -- a cancelled upload is not a failure and must not
|
||||
* be retried.
|
||||
*
|
||||
* <p>Unchecked, because it travels through {@link TransferProgress}, which has
|
||||
* no business declaring it.
|
||||
*/
|
||||
public final class TransferCancelledException extends RuntimeException {
|
||||
|
||||
public TransferCancelledException(String jobId) {
|
||||
super("Transfer " + jobId + " was cancelled", null, false, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Byte-level progress callback for an in-flight upload.
|
||||
*
|
||||
* <p>Called from the upload worker thread, possibly very often. Implementations
|
||||
* must not block and must not touch the render thread directly.
|
||||
*
|
||||
* <p>Throwing {@link TransferCancelledException} from here aborts the transfer.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface TransferProgress {
|
||||
|
||||
void onProgress(long bytesTransferred, long totalBytes);
|
||||
|
||||
static TransferProgress ignored() {
|
||||
return (transferred, total) -> {
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/** What the backend did with an {@link UploadRequest}. */
|
||||
public record UploadReceipt(String assetId, Outcome outcome) {
|
||||
|
||||
public enum Outcome {
|
||||
/** A new asset was stored. */
|
||||
CREATED,
|
||||
/**
|
||||
* The backend already had a byte-identical asset and kept the original.
|
||||
* This is a success, not an error: it is exactly what we want to happen
|
||||
* when a retry follows an upload whose response we never saw.
|
||||
*/
|
||||
DUPLICATE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One file to push to the backend.
|
||||
*
|
||||
* <p>Carries no checksum and no device identifier: those are backend-specific
|
||||
* and are derived inside the provider that needs them.
|
||||
*/
|
||||
public record UploadRequest(
|
||||
Path file,
|
||||
String fileName,
|
||||
Instant capturedAt,
|
||||
Instant modifiedAt,
|
||||
AlbumRef album) {
|
||||
|
||||
public UploadRequest {
|
||||
album = album == null ? AlbumRef.library() : album;
|
||||
modifiedAt = modifiedAt == null ? capturedAt : modifiedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import dev.photosync.core.provider.ProviderConnection;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.TransferCancelledException;
|
||||
import dev.photosync.core.provider.TransferProgress;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
/**
|
||||
* Everything HTTP about talking to Immich: URL shape, authentication, timeouts,
|
||||
* and turning a status code into a {@link ProviderException} the upload queue
|
||||
* can act on.
|
||||
*
|
||||
* <p>Keeping this apart from {@link ImmichProvider} means the provider reads as
|
||||
* a list of API calls and their mapping to PhotoSync's model, with no transport
|
||||
* noise in between.
|
||||
*/
|
||||
final class ImmichApi {
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
|
||||
/** Uploads get their own budget: a screenshot over a slow uplink is not a hung server. */
|
||||
private static final Duration UPLOAD_TIMEOUT = Duration.ofMinutes(10);
|
||||
|
||||
private final HttpClient http;
|
||||
private final URI root;
|
||||
private final String apiKey;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
ImmichApi(ProviderConnection connection) {
|
||||
this.root = apiRoot(connection.endpoint());
|
||||
this.apiKey = connection.secret();
|
||||
this.http = HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
// HTTP/1.1 on purpose. Immich is nearly always behind a
|
||||
// self-hosted reverse proxy, and multipart uploads over an
|
||||
// upgraded HTTP/2 connection are the first thing to break when
|
||||
// that proxy is misconfigured.
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
}
|
||||
|
||||
URI root() {
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns whatever the player typed into the API root.
|
||||
*
|
||||
* <p>People paste {@code immich.example.com}, {@code https://immich.example.com/},
|
||||
* and {@code https://example.com/immich/api} in roughly equal measure, and
|
||||
* being told "connection failed" because of a missing scheme is a miserable
|
||||
* first five minutes with a mod.
|
||||
*/
|
||||
static URI apiRoot(String endpoint) {
|
||||
String value = endpoint.trim();
|
||||
if (!value.matches("(?i)^[a-z][a-z0-9+.-]*://.*")) {
|
||||
value = "https://" + value;
|
||||
}
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
if (!value.endsWith("/api")) {
|
||||
value = value + "/api";
|
||||
}
|
||||
return URI.create(value);
|
||||
}
|
||||
|
||||
<T> T get(String path, Map<String, String> query, Type type) throws ProviderException {
|
||||
return decode(send(request(path, query, REQUEST_TIMEOUT).GET().build()), type);
|
||||
}
|
||||
|
||||
byte[] getBytes(String path, Map<String, String> query) throws ProviderException {
|
||||
return send(request(path, query, REQUEST_TIMEOUT).GET().build());
|
||||
}
|
||||
|
||||
<T> T post(String path, Object body, Type type) throws ProviderException {
|
||||
HttpRequest request = request(path, Map.of(), REQUEST_TIMEOUT)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
return decode(send(request), type);
|
||||
}
|
||||
|
||||
<T> T put(String path, Object body, Type type) throws ProviderException {
|
||||
HttpRequest request = request(path, Map.of(), REQUEST_TIMEOUT)
|
||||
.header("Content-Type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
return decode(send(request), type);
|
||||
}
|
||||
|
||||
<T> T upload(String path, MultipartBody body, String checksumBase64, TransferProgress progress, Type type)
|
||||
throws ProviderException {
|
||||
HttpRequest.Builder builder = request(path, Map.of(), UPLOAD_TIMEOUT)
|
||||
.header("Content-Type", body.contentType())
|
||||
// Lets the server recognise bytes it already has and answer
|
||||
// "duplicate" instead of storing a second copy -- which is what
|
||||
// makes retrying an interrupted upload safe.
|
||||
.header("x-immich-checksum", checksumBase64)
|
||||
.POST(body.publisher(progress));
|
||||
return decode(send(builder.build()), type);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(String path, Map<String, String> query, Duration timeout) {
|
||||
return HttpRequest.newBuilder(uri(path, query))
|
||||
.header("x-api-key", apiKey)
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout);
|
||||
}
|
||||
|
||||
private URI uri(String path, Map<String, String> query) {
|
||||
StringBuilder url = new StringBuilder(root.toString()).append(path);
|
||||
if (!query.isEmpty()) {
|
||||
StringJoiner joiner = new StringJoiner("&");
|
||||
query.forEach((key, value) -> joiner.add(encode(key) + "=" + encode(value)));
|
||||
url.append('?').append(joiner);
|
||||
}
|
||||
return URI.create(url.toString());
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private byte[] send(HttpRequest request) throws ProviderException {
|
||||
try {
|
||||
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
int status = response.statusCode();
|
||||
if (status >= 200 && status < 300) {
|
||||
return response.body();
|
||||
}
|
||||
throw failure(status, response.body());
|
||||
} catch (IOException e) {
|
||||
// A cancelled transfer unwinds through the request body stream, and
|
||||
// the client hands it back wrapped. It is not a network failure and
|
||||
// must not be retried, so dig it out before classifying anything.
|
||||
TransferCancelledException cancelled = findCancellation(e);
|
||||
if (cancelled != null) {
|
||||
throw cancelled;
|
||||
}
|
||||
throw new ProviderException(ProviderException.Kind.NETWORK,
|
||||
"Could not reach " + root.getHost() + ": " + e.getMessage(), e);
|
||||
} catch (UncheckedIOException e) {
|
||||
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE,
|
||||
"Could not read the file to upload: " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ProviderException(ProviderException.Kind.NETWORK, "Interrupted while waiting for Immich", e);
|
||||
}
|
||||
}
|
||||
|
||||
private ProviderException failure(int status, byte[] body) {
|
||||
String detail = describe(status, body);
|
||||
ProviderException.Kind kind = switch (status) {
|
||||
case 401, 403 -> ProviderException.Kind.AUTHENTICATION;
|
||||
case 404 -> ProviderException.Kind.NOT_FOUND;
|
||||
case 408, 429 -> ProviderException.Kind.RATE_LIMITED;
|
||||
default -> status >= 500 ? ProviderException.Kind.SERVER : ProviderException.Kind.PROTOCOL;
|
||||
};
|
||||
return new ProviderException(kind, detail);
|
||||
}
|
||||
|
||||
/** Immich answers errors as JSON; fall back to the raw text when it does not. */
|
||||
private String describe(int status, byte[] body) {
|
||||
String text = new String(body, StandardCharsets.UTF_8).trim();
|
||||
Optional<String> message = Optional.empty();
|
||||
if (text.startsWith("{")) {
|
||||
try {
|
||||
ImmichDtos.ApiError error = gson.fromJson(text, ImmichDtos.ApiError.class);
|
||||
message = Optional.ofNullable(error).map(ImmichDtos.ApiError::message);
|
||||
} catch (JsonSyntaxException ignored) {
|
||||
// Fall through to the raw body below.
|
||||
}
|
||||
}
|
||||
String summary = message.filter(value -> !value.isBlank())
|
||||
.orElseGet(() -> text.isEmpty() ? "no details" : abbreviate(text));
|
||||
return "Immich returned HTTP " + status + " (" + summary + ")";
|
||||
}
|
||||
|
||||
private <T> T decode(byte[] body, Type type) throws ProviderException {
|
||||
try {
|
||||
T value = gson.fromJson(new String(body, StandardCharsets.UTF_8), type);
|
||||
if (value == null) {
|
||||
throw new ProviderException(ProviderException.Kind.PROTOCOL, "Immich returned an empty response");
|
||||
}
|
||||
return value;
|
||||
} catch (JsonSyntaxException e) {
|
||||
throw new ProviderException(ProviderException.Kind.PROTOCOL,
|
||||
"Immich returned something that is not the JSON we expected", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static TransferCancelledException findCancellation(Throwable thrown) {
|
||||
for (Throwable cause = thrown; cause != null; cause = cause.getCause()) {
|
||||
if (cause instanceof TransferCancelledException cancelled) {
|
||||
return cancelled;
|
||||
}
|
||||
if (cause.getCause() == cause) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String abbreviate(String text) {
|
||||
String flat = text.replaceAll("\\s+", " ");
|
||||
return flat.length() <= 160 ? flat : flat.substring(0, 157) + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The slices of the Immich API that PhotoSync actually reads, mirrored as
|
||||
* records for Gson.
|
||||
*
|
||||
* <p>Written against Immich's published OpenAPI document, version 3.1.0. Fields
|
||||
* the mod does not use are simply absent -- Gson ignores unknown JSON members,
|
||||
* so the smaller this file is, the fewer ways a server upgrade can break us.
|
||||
*
|
||||
* <p>Two absences are deliberate rather than incidental:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code duration} is not read. It has changed representation between
|
||||
* Immich releases (string, then seconds) and the only thing PhotoSync
|
||||
* would do with it is decorate a badge it already draws from
|
||||
* {@code isImage}.</li>
|
||||
* <li>Boxed types throughout, because every one of these arrays is declared
|
||||
* nullable somewhere in the schema, and an older server that omits one
|
||||
* should degrade rather than throw.</li>
|
||||
* </ul>
|
||||
*/
|
||||
final class ImmichDtos {
|
||||
|
||||
private ImmichDtos() {
|
||||
}
|
||||
|
||||
record ServerAbout(String version) {
|
||||
}
|
||||
|
||||
record UserProfile(String name, String email) {
|
||||
}
|
||||
|
||||
record AlbumSummary(String id, String albumName, Integer assetCount, String albumThumbnailAssetId) {
|
||||
}
|
||||
|
||||
record CreateAlbum(String albumName) {
|
||||
}
|
||||
|
||||
/** {@code status} is {@code created} or {@code duplicate}. */
|
||||
record AssetMediaResponse(String id, String status) {
|
||||
}
|
||||
|
||||
record BulkIds(List<String> ids) {
|
||||
}
|
||||
|
||||
record BulkIdResponse(String id, Boolean success, String error) {
|
||||
}
|
||||
|
||||
/** {@code timeBucket} is an opaque token; we hand it straight back to the server. */
|
||||
record TimeBucket(String timeBucket, Integer count) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Immich returns a bucket columnwise: one array per attribute, all the same
|
||||
* length, indexed in parallel. It is an odd shape for a REST API and a very
|
||||
* good one for this use case -- a thousand assets arrive without a thousand
|
||||
* repetitions of every key name.
|
||||
*/
|
||||
record BucketAssets(
|
||||
List<String> id,
|
||||
List<String> fileCreatedAt,
|
||||
List<Double> localOffsetHours,
|
||||
List<Double> ratio,
|
||||
List<String> thumbhash,
|
||||
List<Boolean> isImage) {
|
||||
|
||||
int size() {
|
||||
return id == null ? 0 : id.size();
|
||||
}
|
||||
}
|
||||
|
||||
/** The body Immich returns on an error, when it returns one at all. */
|
||||
record ApiError(String message, String error, Integer statusCode) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import dev.photosync.core.provider.Album;
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.AssetKind;
|
||||
import dev.photosync.core.provider.BucketPage;
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderConnection;
|
||||
import dev.photosync.core.provider.ProviderDescriptor;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.ProviderIdentity;
|
||||
import dev.photosync.core.provider.RemoteAsset;
|
||||
import dev.photosync.core.provider.ThumbnailSize;
|
||||
import dev.photosync.core.provider.TimelineBucket;
|
||||
import dev.photosync.core.provider.TransferProgress;
|
||||
import dev.photosync.core.provider.UploadReceipt;
|
||||
import dev.photosync.core.provider.UploadRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Immich, expressed in PhotoSync's terms.
|
||||
*
|
||||
* <p>Two Immich design choices carry the whole browsing experience and are worth
|
||||
* naming. Its timeline is exposed as monthly buckets with counts, so the grid
|
||||
* knows its exact total height before fetching a single asset -- that is what
|
||||
* makes a real virtual list possible rather than an endless scroll. And each
|
||||
* bucket page carries a per-asset aspect ratio and thumbhash, so a tile can be
|
||||
* laid out and filled with a recognisable blur before its image request is even
|
||||
* queued.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ImmichProvider implements PhotoProvider {
|
||||
|
||||
private static final Type ALBUM_LIST = new TypeToken<List<ImmichDtos.AlbumSummary>>() {
|
||||
}.getType();
|
||||
private static final Type BUCKET_LIST = new TypeToken<List<ImmichDtos.TimeBucket>>() {
|
||||
}.getType();
|
||||
private static final Type BULK_RESULT = new TypeToken<List<ImmichDtos.BulkIdResponse>>() {
|
||||
}.getType();
|
||||
|
||||
private final ProviderDescriptor descriptor;
|
||||
private final ImmichApi api;
|
||||
|
||||
/**
|
||||
* Whether this server's small thumbnails are usable, learned from the first
|
||||
* one we fetch. See {@link #thumbnail}.
|
||||
*/
|
||||
private volatile boolean smallThumbnails = true;
|
||||
|
||||
ImmichProvider(ProviderDescriptor descriptor, ProviderConnection connection) {
|
||||
this.descriptor = descriptor;
|
||||
this.api = new ImmichApi(connection);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderDescriptor descriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderIdentity identify() throws ProviderException {
|
||||
// The profile call first: a wrong API key should say "not authorised",
|
||||
// not "could not read the version".
|
||||
ImmichDtos.UserProfile me = api.get("/users/me", Map.of(), ImmichDtos.UserProfile.class);
|
||||
ImmichDtos.ServerAbout about = api.get("/server/about", Map.of(), ImmichDtos.ServerAbout.class);
|
||||
String account = Optional.ofNullable(me.name())
|
||||
.filter(name -> !name.isBlank())
|
||||
.orElseGet(() -> Optional.ofNullable(me.email()).orElse("unknown"));
|
||||
return new ProviderIdentity(Optional.ofNullable(about.version()).orElse("unknown"), account);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Album> albums() throws ProviderException {
|
||||
List<ImmichDtos.AlbumSummary> response = api.get("/albums", Map.of(), ALBUM_LIST);
|
||||
return response.stream()
|
||||
.filter(summary -> summary.id() != null)
|
||||
.map(summary -> new Album(
|
||||
summary.id(),
|
||||
Optional.ofNullable(summary.albumName()).orElse(summary.id()),
|
||||
Optional.ofNullable(summary.assetCount()).orElse(0),
|
||||
Optional.ofNullable(summary.albumThumbnailAssetId())))
|
||||
.sorted(Comparator.comparing(Album::name, String.CASE_INSENSITIVE_ORDER))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Album createAlbum(String name) throws ProviderException {
|
||||
ImmichDtos.AlbumSummary created =
|
||||
api.post("/albums", new ImmichDtos.CreateAlbum(name), ImmichDtos.AlbumSummary.class);
|
||||
if (created.id() == null) {
|
||||
throw new ProviderException(ProviderException.Kind.PROTOCOL, "Immich created an album without an id");
|
||||
}
|
||||
return new Album(created.id(), name, 0, Optional.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public UploadReceipt upload(UploadRequest request, TransferProgress progress) throws ProviderException {
|
||||
Path file = request.file();
|
||||
if (!Files.isReadable(file)) {
|
||||
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file);
|
||||
}
|
||||
|
||||
MultipartBody body;
|
||||
try {
|
||||
body = MultipartBody.with(file, "assetData", request.fileName(), mediaTypeOf(request.fileName()))
|
||||
.field("fileCreatedAt", request.capturedAt().toString())
|
||||
.field("fileModifiedAt", request.modifiedAt().toString())
|
||||
.field("filename", request.fileName())
|
||||
.build();
|
||||
} catch (IOException e) {
|
||||
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file, e);
|
||||
}
|
||||
|
||||
ImmichDtos.AssetMediaResponse response =
|
||||
api.upload("/assets", body, sha1Base64(file), progress, ImmichDtos.AssetMediaResponse.class);
|
||||
if (response.id() == null) {
|
||||
throw new ProviderException(ProviderException.Kind.PROTOCOL, "Immich accepted the upload without an id");
|
||||
}
|
||||
|
||||
UploadReceipt.Outcome outcome = "duplicate".equalsIgnoreCase(response.status())
|
||||
? UploadReceipt.Outcome.DUPLICATE
|
||||
: UploadReceipt.Outcome.CREATED;
|
||||
|
||||
Optional<String> album = request.album().id();
|
||||
if (album.isPresent()) {
|
||||
addToAlbum(album.get(), response.id());
|
||||
}
|
||||
return new UploadReceipt(response.id(), outcome);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TimelineBucket> timeline(AlbumRef album) throws ProviderException {
|
||||
List<ImmichDtos.TimeBucket> response = api.get("/timeline/buckets", scope(album), BUCKET_LIST);
|
||||
return response.stream()
|
||||
.filter(bucket -> bucket.timeBucket() != null)
|
||||
.map(bucket -> monthOf(bucket.timeBucket())
|
||||
.map(month -> new TimelineBucket(
|
||||
bucket.timeBucket(), month, Math.max(0, Optional.ofNullable(bucket.count()).orElse(0)))))
|
||||
.flatMap(Optional::stream)
|
||||
.filter(bucket -> bucket.assetCount() > 0)
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BucketPage page(AlbumRef album, TimelineBucket bucket) throws ProviderException {
|
||||
Map<String, String> query = scope(album);
|
||||
query.put("timeBucket", bucket.key());
|
||||
ImmichDtos.BucketAssets columns = api.get("/timeline/bucket", query, ImmichDtos.BucketAssets.class);
|
||||
|
||||
int count = columns.size();
|
||||
List<RemoteAsset> assets = new ArrayList<>(count);
|
||||
for (int index = 0; index < count; index++) {
|
||||
String id = columns.id().get(index);
|
||||
if (id == null) {
|
||||
continue;
|
||||
}
|
||||
Optional<Instant> takenAt = instantOf(column(columns.fileCreatedAt(), index));
|
||||
if (takenAt.isEmpty()) {
|
||||
// Without a timestamp the asset has no day to live under.
|
||||
continue;
|
||||
}
|
||||
double offsetHours = column(columns.localOffsetHours(), index, 0.0);
|
||||
boolean image = column(columns.isImage(), index, Boolean.TRUE);
|
||||
assets.add(new RemoteAsset(
|
||||
id,
|
||||
image ? AssetKind.IMAGE : AssetKind.VIDEO,
|
||||
takenAt.get(),
|
||||
localTimeOf(takenAt.get(), offsetHours),
|
||||
column(columns.ratio(), index, 1.0),
|
||||
Optional.ofNullable(column(columns.thumbhash(), index)),
|
||||
Duration.ZERO));
|
||||
}
|
||||
assets.sort(Comparator.comparing(RemoteAsset::localCapturedAt).reversed());
|
||||
return new BucketPage(bucket, assets);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] thumbnail(String assetId, ThumbnailSize size) throws ProviderException {
|
||||
// Videos have no still of their own to serve, but Immich renders one for
|
||||
// them at the same endpoint -- which is exactly what a video tile needs.
|
||||
String path = "/assets/" + assetId + "/thumbnail";
|
||||
if (size == ThumbnailSize.DETAIL || !smallThumbnails) {
|
||||
return api.getBytes(path, Map.of("size", "preview"));
|
||||
}
|
||||
byte[] small = api.getBytes(path, Map.of("size", "thumbnail"));
|
||||
if (!isWebP(small)) {
|
||||
return small;
|
||||
}
|
||||
// Immich's default thumbnail format is WebP, which the game's image
|
||||
// decoder cannot read; its previews default to JPEG, which it can. One
|
||||
// wasted request per session buys correct tiles on those servers, and
|
||||
// servers already configured for JPEG never take this branch.
|
||||
log.info("This Immich server serves WebP thumbnails; falling back to previews for the grid");
|
||||
smallThumbnails = false;
|
||||
return api.getBytes(path, Map.of("size", "preview"));
|
||||
}
|
||||
|
||||
/** RIFF container with a WEBP fourcc, per the WebP specification. */
|
||||
private static boolean isWebP(byte[] bytes) {
|
||||
return bytes.length >= 12
|
||||
&& bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F'
|
||||
&& bytes[8] == 'W' && bytes[9] == 'E' && bytes[10] == 'B' && bytes[11] == 'P';
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Nothing to release: java.net.http.HttpClient has no close() before
|
||||
// Java 21, and we compile this module to 17. It uses daemon threads and
|
||||
// is collected with the provider.
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void addToAlbum(String albumId, String assetId) throws ProviderException {
|
||||
List<ImmichDtos.BulkIdResponse> results =
|
||||
api.put("/albums/" + albumId + "/assets", new ImmichDtos.BulkIds(List.of(assetId)), BULK_RESULT);
|
||||
for (ImmichDtos.BulkIdResponse result : results) {
|
||||
boolean added = Boolean.TRUE.equals(result.success());
|
||||
if (!added && !"duplicate".equalsIgnoreCase(result.error())) {
|
||||
// The asset itself is safely stored, so this is a warning rather
|
||||
// than a failure -- but the player asked for an album, so say so.
|
||||
log.warn("Uploaded {} but Immich would not add it to album {}: {}",
|
||||
assetId, albumId, Optional.ofNullable(result.error()).orElse("no reason given"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Album filter plus the flags that keep archived and trashed assets out of the library view. */
|
||||
private static Map<String, String> scope(AlbumRef album) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
album.id().ifPresent(id -> query.put("albumId", id));
|
||||
if (album.isLibrary()) {
|
||||
query.put("visibility", "timeline");
|
||||
}
|
||||
query.put("order", "desc");
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immich has returned the bucket token as both {@code 2024-01-01} and
|
||||
* {@code 2024-01-01T00:00:00.000Z} across releases. Only the date part is
|
||||
* ever meaningful, and the token itself goes back to the server untouched.
|
||||
*/
|
||||
private static Optional<LocalDate> monthOf(String bucketToken) {
|
||||
if (bucketToken.length() < 10) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(LocalDate.parse(bucketToken.substring(0, 10)));
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("Ignoring bucket with an unparseable token: {}", bucketToken);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<Instant> instantOf(String iso) {
|
||||
if (iso == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(Instant.parse(iso));
|
||||
} catch (DateTimeParseException e) {
|
||||
try {
|
||||
return Optional.of(LocalDateTime.parse(iso).toInstant(ZoneOffset.UTC));
|
||||
} catch (DateTimeParseException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall-clock time where the photo was taken. Immich stores the capture
|
||||
* instant in UTC alongside the offset that was in force, and some zones are
|
||||
* on a fractional hour, so the offset is converted through seconds.
|
||||
*/
|
||||
private static LocalDateTime localTimeOf(Instant instant, double offsetHours) {
|
||||
double clamped = Math.max(-18.0, Math.min(18.0, offsetHours));
|
||||
return instant.atOffset(ZoneOffset.ofTotalSeconds((int) Math.round(clamped * 3600))).toLocalDateTime();
|
||||
}
|
||||
|
||||
private static <T> T column(List<T> values, int index) {
|
||||
return values == null || index >= values.size() ? null : values.get(index);
|
||||
}
|
||||
|
||||
private static <T> T column(List<T> values, int index, T fallback) {
|
||||
T value = column(values, index);
|
||||
return value == null ? fallback : value;
|
||||
}
|
||||
|
||||
private static String mediaTypeOf(String fileName) {
|
||||
String lower = fileName.toLowerCase(Locale.ROOT);
|
||||
if (lower.endsWith(".png")) {
|
||||
return "image/png";
|
||||
}
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (lower.endsWith(".webp")) {
|
||||
return "image/webp";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-1 of the file, base64 encoded, for the {@code x-immich-checksum}
|
||||
* header. Reading the screenshot a second time costs a millisecond and buys
|
||||
* the server the ability to recognise a retry of an upload it already has.
|
||||
*/
|
||||
private static String sha1Base64(Path file) throws ProviderException {
|
||||
try (InputStream stream = Files.newInputStream(file)) {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = stream.read(buffer)) > 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(digest.digest());
|
||||
} catch (IOException e) {
|
||||
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file, e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("Every JVM is required to provide SHA-1", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderConnection;
|
||||
import dev.photosync.core.provider.ProviderDescriptor;
|
||||
import dev.photosync.core.provider.ProviderFactory;
|
||||
import dev.photosync.core.provider.ProviderId;
|
||||
|
||||
/**
|
||||
* Registers Immich with the {@code ProviderCatalog}.
|
||||
*
|
||||
* <p>The one place in the mod where the string "immich" appears outside a
|
||||
* language file. A second backend is a second class like this one.
|
||||
*/
|
||||
public final class ImmichProviderFactory implements ProviderFactory {
|
||||
|
||||
private final ProviderDescriptor descriptor =
|
||||
new ProviderDescriptor(new ProviderId("immich"), "photosync.provider.immich", true);
|
||||
|
||||
@Override
|
||||
public ProviderDescriptor descriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PhotoProvider connect(ProviderConnection connection) {
|
||||
return new ImmichProvider(descriptor, connection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import dev.photosync.core.provider.TransferProgress;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.SequenceInputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A {@code multipart/form-data} body that streams one file from disk.
|
||||
*
|
||||
* <p>The obvious implementation -- read the screenshot into a byte array and
|
||||
* hand it to {@code BodyPublishers.ofByteArray} -- would double the mod's peak
|
||||
* memory for every concurrent upload, on a heap the game is already competing
|
||||
* for. So the body is assembled as a preamble, the file, and an epilogue, and
|
||||
* the file part is never held in memory.
|
||||
*
|
||||
* <p>The length is computed up front so the request can carry a real
|
||||
* {@code Content-Length}. Streaming with an unknown length would force chunked
|
||||
* transfer encoding, which self-hosted reverse proxies in front of Immich are
|
||||
* not reliably configured for.
|
||||
*/
|
||||
final class MultipartBody {
|
||||
|
||||
/** Report progress at most this often, so a fast local server is not drowned in callbacks. */
|
||||
private static final int PROGRESS_STRIDE_BYTES = 64 * 1024;
|
||||
|
||||
private final String boundary;
|
||||
private final byte[] preamble;
|
||||
private final byte[] epilogue;
|
||||
private final Path file;
|
||||
private final long fileSize;
|
||||
|
||||
private MultipartBody(String boundary, byte[] preamble, byte[] epilogue, Path file, long fileSize) {
|
||||
this.boundary = boundary;
|
||||
this.preamble = preamble;
|
||||
this.epilogue = epilogue;
|
||||
this.file = file;
|
||||
this.fileSize = fileSize;
|
||||
}
|
||||
|
||||
static Builder with(Path file, String fieldName, String fileName, String contentType) throws IOException {
|
||||
return new Builder(file, fieldName, fileName, contentType);
|
||||
}
|
||||
|
||||
String contentType() {
|
||||
return "multipart/form-data; boundary=" + boundary;
|
||||
}
|
||||
|
||||
long contentLength() {
|
||||
return preamble.length + fileSize + epilogue.length;
|
||||
}
|
||||
|
||||
HttpRequest.BodyPublisher publisher(TransferProgress progress) {
|
||||
long total = contentLength();
|
||||
return HttpRequest.BodyPublishers.fromPublisher(
|
||||
HttpRequest.BodyPublishers.ofInputStream(() -> open(progress, total)),
|
||||
total);
|
||||
}
|
||||
|
||||
private InputStream open(TransferProgress progress, long total) {
|
||||
try {
|
||||
InputStream body = new SequenceInputStream(Collections.enumeration(List.<InputStream>of(
|
||||
new ByteArrayInputStream(preamble),
|
||||
Files.newInputStream(file),
|
||||
new ByteArrayInputStream(epilogue))));
|
||||
return new CountingStream(body, progress, total);
|
||||
} catch (IOException e) {
|
||||
// The supplier cannot throw a checked exception; the HttpClient
|
||||
// unwraps this back into an IOException on send().
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts bytes as the HTTP client pulls them, and tells the queue.
|
||||
*
|
||||
* <p>The callback may throw -- that is how a cancelled upload unwinds -- so
|
||||
* it is invoked where the exception can propagate out of {@code read}.
|
||||
*/
|
||||
private static final class CountingStream extends FilterInputStream {
|
||||
|
||||
private final TransferProgress progress;
|
||||
private final long total;
|
||||
private long sent;
|
||||
private long lastReported;
|
||||
|
||||
CountingStream(InputStream delegate, TransferProgress progress, long total) {
|
||||
super(delegate);
|
||||
this.progress = progress;
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
int value = super.read();
|
||||
if (value >= 0) {
|
||||
advance(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws IOException {
|
||||
int count = super.read(buffer, offset, length);
|
||||
if (count > 0) {
|
||||
advance(count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void advance(int count) {
|
||||
sent += count;
|
||||
if (sent - lastReported >= PROGRESS_STRIDE_BYTES || sent == total) {
|
||||
lastReported = sent;
|
||||
progress.onProgress(sent, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final class Builder {
|
||||
|
||||
private final String boundary = "PhotoSync" + UUID.randomUUID().toString().replace("-", "");
|
||||
private final List<String> fields = new ArrayList<>();
|
||||
private final Path file;
|
||||
private final String fieldName;
|
||||
private final String fileName;
|
||||
private final String contentType;
|
||||
private final long fileSize;
|
||||
|
||||
private Builder(Path file, String fieldName, String fileName, String contentType) throws IOException {
|
||||
this.file = file;
|
||||
this.fieldName = fieldName;
|
||||
this.fileName = fileName;
|
||||
this.contentType = contentType;
|
||||
this.fileSize = Files.size(file);
|
||||
}
|
||||
|
||||
Builder field(String name, String value) {
|
||||
fields.add("--" + boundary + "\r\n"
|
||||
+ "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"
|
||||
+ value + "\r\n");
|
||||
return this;
|
||||
}
|
||||
|
||||
MultipartBody build() {
|
||||
StringBuilder head = new StringBuilder();
|
||||
fields.forEach(head::append);
|
||||
head.append("--").append(boundary).append("\r\n")
|
||||
.append("Content-Disposition: form-data; name=\"").append(fieldName)
|
||||
.append("\"; filename=\"").append(escape(fileName)).append("\"\r\n")
|
||||
.append("Content-Type: ").append(contentType).append("\r\n\r\n");
|
||||
|
||||
return new MultipartBody(
|
||||
boundary,
|
||||
head.toString().getBytes(StandardCharsets.UTF_8),
|
||||
("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8),
|
||||
file,
|
||||
fileSize);
|
||||
}
|
||||
|
||||
/** Quotes and backslashes in a file name would end the header early. */
|
||||
private static String escape(String raw) {
|
||||
return raw.replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "").replace("\n", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package dev.photosync.core.thumbnail;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Decoder for the ThumbHash placeholders Immich attaches to every asset.
|
||||
*
|
||||
* <p>A ThumbHash is around 25 bytes and already travels with the timeline
|
||||
* response, so the blurred stand-in for a tile costs no extra request -- the
|
||||
* grid can be fully painted the instant a month's metadata lands, and the real
|
||||
* thumbnails fade in behind it. That is what makes fast scrolling feel like the
|
||||
* web gallery this browser is imitating rather than a wall of grey boxes.
|
||||
*
|
||||
* <p>Only decoding lives here; the mod never produces hashes. The format is
|
||||
* Evan Wallace's, and the maths below deliberately mirrors the reference
|
||||
* implementation step for step so it can be diffed against it.
|
||||
*/
|
||||
public final class ThumbHash {
|
||||
|
||||
/** The reference decoder's output is normalised to fit in a 32x32 box. */
|
||||
private static final int MAX_EDGE = 32;
|
||||
|
||||
private ThumbHash() {
|
||||
}
|
||||
|
||||
/** Decodes the base64 form carried in the timeline response. */
|
||||
public static Optional<ThumbImage> decode(String base64) {
|
||||
if (base64 == null || base64.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return decode(Base64.getDecoder().decode(base64.trim()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the raw hash.
|
||||
*
|
||||
* <p>Returns empty rather than throwing for anything malformed: a bad
|
||||
* placeholder should cost the player a grey tile, never a crashed screen.
|
||||
*/
|
||||
public static Optional<ThumbImage> decode(byte[] hash) {
|
||||
if (hash == null || hash.length < 5) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(decodeChecked(hash));
|
||||
} catch (ArrayIndexOutOfBoundsException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static ThumbImage decodeChecked(byte[] hash) {
|
||||
int header24 = byteAt(hash, 0) | (byteAt(hash, 1) << 8) | (byteAt(hash, 2) << 16);
|
||||
int header16 = byteAt(hash, 3) | (byteAt(hash, 4) << 8);
|
||||
|
||||
float lDc = (header24 & 63) / 63f;
|
||||
float pDc = ((header24 >> 6) & 63) / 31.5f - 1f;
|
||||
float qDc = ((header24 >> 12) & 63) / 31.5f - 1f;
|
||||
float lScale = ((header24 >> 18) & 31) / 31f;
|
||||
boolean hasAlpha = (header24 >>> 23) != 0;
|
||||
float pScale = ((header16 >> 3) & 63) / 63f;
|
||||
float qScale = ((header16 >> 9) & 63) / 63f;
|
||||
boolean landscape = (header16 >>> 15) != 0;
|
||||
|
||||
int lx = Math.max(3, landscape ? (hasAlpha ? 5 : 7) : (header16 & 7));
|
||||
int ly = Math.max(3, landscape ? (header16 & 7) : (hasAlpha ? 5 : 7));
|
||||
|
||||
float aDc = hasAlpha ? (byteAt(hash, 5) & 15) / 15f : 1f;
|
||||
float aScale = hasAlpha ? (byteAt(hash, 5) >> 4) / 15f : 0f;
|
||||
|
||||
// Saturation is boosted by 1.25 on the chroma channels to undo the loss
|
||||
// from quantising them into four bits, exactly as the encoder expects.
|
||||
int[] cursor = {0};
|
||||
int acStart = hasAlpha ? 6 : 5;
|
||||
float[] lAc = readChannel(hash, acStart, cursor, lx, ly, lScale);
|
||||
float[] pAc = readChannel(hash, acStart, cursor, 3, 3, pScale * 1.25f);
|
||||
float[] qAc = readChannel(hash, acStart, cursor, 3, 3, qScale * 1.25f);
|
||||
float[] aAc = hasAlpha ? readChannel(hash, acStart, cursor, 5, 5, aScale) : new float[0];
|
||||
|
||||
// The aspect ratio comes from the unclamped counts, matching the encoder.
|
||||
float ratio = (float) (landscape ? (hasAlpha ? 5 : 7) : (header16 & 7))
|
||||
/ (landscape ? (header16 & 7) : (hasAlpha ? 5 : 7));
|
||||
int width = Math.max(1, Math.round(ratio > 1 ? MAX_EDGE : MAX_EDGE * ratio));
|
||||
int height = Math.max(1, Math.round(ratio > 1 ? MAX_EDGE / ratio : MAX_EDGE));
|
||||
|
||||
// The reference recomputes these cosines per pixel; hoisting them out
|
||||
// turns the inner loop into pure multiply-add.
|
||||
int fxCount = Math.max(lx, hasAlpha ? 5 : 3);
|
||||
int fyCount = Math.max(ly, hasAlpha ? 5 : 3);
|
||||
float[][] fxTable = cosineTable(width, fxCount);
|
||||
float[][] fyTable = cosineTable(height, fyCount);
|
||||
|
||||
int[] argb = new int[width * height];
|
||||
for (int y = 0; y < height; y++) {
|
||||
float[] fy = fyTable[y];
|
||||
for (int x = 0; x < width; x++) {
|
||||
float[] fx = fxTable[x];
|
||||
float l = lDc;
|
||||
float p = pDc;
|
||||
float q = qDc;
|
||||
float a = aDc;
|
||||
|
||||
for (int cy = 0, j = 0; cy < ly; cy++) {
|
||||
for (int cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++) {
|
||||
l += lAc[j] * fx[cx] * fy[cy] * 2f;
|
||||
}
|
||||
}
|
||||
for (int cy = 0, j = 0; cy < 3; cy++) {
|
||||
for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; cx++, j++) {
|
||||
float f = fx[cx] * fy[cy] * 2f;
|
||||
p += pAc[j] * f;
|
||||
q += qAc[j] * f;
|
||||
}
|
||||
}
|
||||
if (hasAlpha) {
|
||||
for (int cy = 0, j = 0; cy < 5; cy++) {
|
||||
for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; cx++, j++) {
|
||||
a += aAc[j] * fx[cx] * fy[cy] * 2f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float blue = l - 2f / 3f * p;
|
||||
float red = (3f * l - blue + q) / 2f;
|
||||
float green = red - q;
|
||||
argb[y * width + x] = (channel(a) << 24) | (channel(red) << 16)
|
||||
| (channel(green) << 8) | channel(blue);
|
||||
}
|
||||
}
|
||||
return new ThumbImage(width, height, argb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one channel's AC coefficients, four bits each, from a nibble stream
|
||||
* shared by all channels -- hence the caller-owned cursor.
|
||||
*/
|
||||
private static float[] readChannel(byte[] hash, int start, int[] cursor, int nx, int ny, float scale) {
|
||||
float[] ac = new float[nx * ny];
|
||||
int count = 0;
|
||||
for (int cy = 0; cy < ny; cy++) {
|
||||
for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++) {
|
||||
int index = cursor[0]++;
|
||||
int nibble = (byteAt(hash, start + (index >> 1)) >> ((index & 1) << 2)) & 15;
|
||||
ac[count++] = (nibble / 7.5f - 1f) * scale;
|
||||
}
|
||||
}
|
||||
return Arrays.copyOf(ac, count);
|
||||
}
|
||||
|
||||
private static float[][] cosineTable(int size, int terms) {
|
||||
float[][] table = new float[size][terms];
|
||||
for (int i = 0; i < size; i++) {
|
||||
for (int c = 0; c < terms; c++) {
|
||||
table[i][c] = (float) Math.cos(Math.PI / size * (i + 0.5) * c);
|
||||
}
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
private static int channel(float value) {
|
||||
return Math.max(0, Math.min(255, Math.round(value * 255f)));
|
||||
}
|
||||
|
||||
private static int byteAt(byte[] hash, int index) {
|
||||
return hash[index] & 0xFF;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.photosync.core.thumbnail;
|
||||
|
||||
/**
|
||||
* A tiny decoded bitmap -- at most 32x32 -- in 0xAARRGGBB order.
|
||||
*
|
||||
* <p>Deliberately not a Minecraft {@code NativeImage}: this module has no
|
||||
* graphics dependencies, and the version adapters are the ones that know how to
|
||||
* turn a pixel array into a texture on their Minecraft version.
|
||||
*/
|
||||
public record ThumbImage(int width, int height, int[] argb) {
|
||||
|
||||
public ThumbImage {
|
||||
argb = argb.clone();
|
||||
}
|
||||
|
||||
public int pixel(int x, int y) {
|
||||
return argb[y * width + x];
|
||||
}
|
||||
|
||||
/** A defensive copy, because callers hand this straight to native buffers. */
|
||||
@Override
|
||||
public int[] argb() {
|
||||
return argb.clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package dev.photosync.core.thumbnail;
|
||||
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.ProviderSession;
|
||||
import dev.photosync.core.provider.ThumbnailSize;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Fetches thumbnail bytes off the render thread, one request per asset no matter
|
||||
* how many tiles ask for it.
|
||||
*
|
||||
* <p>Decoding those bytes into a texture is deliberately <em>not</em> done here:
|
||||
* that has to happen on the render thread against a version-specific image type,
|
||||
* so the caller polls the returned future and uploads the result itself.
|
||||
*
|
||||
* <p>The loader refuses work once too many requests are outstanding. During a
|
||||
* fast scroll most tiles are on screen for a few frames and their thumbnails
|
||||
* would arrive long after the player has moved past them; dropping those
|
||||
* requests and letting the grid ask again when it settles keeps the queue short
|
||||
* and the visible tiles first in line.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ThumbnailLoader implements AutoCloseable {
|
||||
|
||||
/** Roughly two screens' worth of tiles. Past this, the queue is stale by the time it drains. */
|
||||
private static final int MAX_PENDING = 96;
|
||||
private static final int WORKERS = 3;
|
||||
|
||||
private final ProviderSession session;
|
||||
private final ExecutorService workers;
|
||||
private final Map<String, CompletableFuture<byte[]>> inFlight = new ConcurrentHashMap<>();
|
||||
|
||||
public ThumbnailLoader(ProviderSession session) {
|
||||
this.session = session;
|
||||
this.workers = Executors.newFixedThreadPool(WORKERS, daemonFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for one thumbnail.
|
||||
*
|
||||
* <p>Returns empty when the loader is saturated, which is a "not now" rather
|
||||
* than a failure -- the caller is expected to try again on a later frame.
|
||||
* Two callers asking for the same asset share one request and one future.
|
||||
*/
|
||||
public Optional<CompletableFuture<byte[]>> request(String assetId, ThumbnailSize size) {
|
||||
String key = assetId + '@' + size;
|
||||
CompletableFuture<byte[]> existing = inFlight.get(key);
|
||||
if (existing != null) {
|
||||
return Optional.of(existing);
|
||||
}
|
||||
if (inFlight.size() >= MAX_PENDING) {
|
||||
return Optional.empty();
|
||||
}
|
||||
CompletableFuture<byte[]> future = new CompletableFuture<>();
|
||||
CompletableFuture<byte[]> raced = inFlight.putIfAbsent(key, future);
|
||||
if (raced != null) {
|
||||
return Optional.of(raced);
|
||||
}
|
||||
try {
|
||||
workers.execute(() -> {
|
||||
try {
|
||||
future.complete(fetch(assetId, size));
|
||||
} catch (ProviderException e) {
|
||||
log.debug("Thumbnail {} failed: {}", assetId, e.getMessage());
|
||||
future.completeExceptionally(e);
|
||||
} catch (RuntimeException e) {
|
||||
future.completeExceptionally(e);
|
||||
} finally {
|
||||
inFlight.remove(key);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException e) {
|
||||
// Shutting down: the screen is closing and nobody will read this.
|
||||
inFlight.remove(key);
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
return Optional.of(future);
|
||||
}
|
||||
|
||||
public int pending() {
|
||||
return inFlight.size();
|
||||
}
|
||||
|
||||
private byte[] fetch(String assetId, ThumbnailSize size) throws ProviderException {
|
||||
PhotoProvider provider = session.provider().orElseThrow(() -> new ProviderException(
|
||||
ProviderException.Kind.AUTHENTICATION, "No provider is configured"));
|
||||
return provider.thumbnail(assetId, size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
workers.shutdownNow();
|
||||
inFlight.values().forEach(future -> future.cancel(false));
|
||||
inFlight.clear();
|
||||
try {
|
||||
workers.awaitTermination(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private static ThreadFactory daemonFactory() {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
return runnable -> {
|
||||
Thread thread = new Thread(runnable, "photosync-thumbnail-" + counter.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package dev.photosync.core.timeline;
|
||||
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.BucketPage;
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.ProviderSession;
|
||||
import dev.photosync.core.provider.RemoteAsset;
|
||||
import dev.photosync.core.provider.TimelineBucket;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* The lazily-filled model behind the album browser.
|
||||
*
|
||||
* <p>It fetches the month counts once, which is enough to lay the whole album
|
||||
* out at full height, and then fetches a month's assets only when the renderer
|
||||
* says that month is about to be on screen. Everything here runs off the render
|
||||
* thread; the renderer only ever reads {@link #sections()} and {@link #revision()},
|
||||
* both of which are non-blocking.
|
||||
*
|
||||
* <p>The renderer drives loading by calling {@link #request(TimelineBucket)} for
|
||||
* the months it is about to draw. Repeat calls are free -- already-loaded,
|
||||
* in-flight and previously-failed months are all ignored -- so it can call it
|
||||
* every frame without keeping any bookkeeping of its own.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class TimelineBrowser implements AutoCloseable {
|
||||
|
||||
private final ProviderSession session;
|
||||
private final ExecutorService loader;
|
||||
private final Object lock = new Object();
|
||||
private final Set<String> inFlight = ConcurrentHashMap.newKeySet();
|
||||
private final AtomicInteger revision = new AtomicInteger();
|
||||
|
||||
private final Map<String, List<TimelineSection.Day>> pages = new HashMap<>();
|
||||
private final Map<String, String> pageErrors = new HashMap<>();
|
||||
|
||||
private AlbumRef album = AlbumRef.library();
|
||||
private List<TimelineBucket> buckets = List.of();
|
||||
/** Bumped on every {@link #open} so results for the previous album are discarded. */
|
||||
private long generation;
|
||||
|
||||
private volatile List<TimelineSection> sections = List.of();
|
||||
private volatile TimelineState state = TimelineState.NOT_CONFIGURED;
|
||||
private volatile String error;
|
||||
|
||||
public TimelineBrowser(ProviderSession session) {
|
||||
this.session = session;
|
||||
this.loader = Executors.newFixedThreadPool(2, daemonFactory());
|
||||
}
|
||||
|
||||
/** Points the browser at an album (or the whole library) and starts loading. */
|
||||
public void open(AlbumRef target) {
|
||||
long token;
|
||||
synchronized (lock) {
|
||||
album = target;
|
||||
buckets = List.of();
|
||||
pages.clear();
|
||||
pageErrors.clear();
|
||||
sections = List.of();
|
||||
error = null;
|
||||
token = ++generation;
|
||||
}
|
||||
inFlight.clear();
|
||||
state = TimelineState.LOADING;
|
||||
revision.incrementAndGet();
|
||||
loader.execute(() -> loadBuckets(token));
|
||||
}
|
||||
|
||||
/** Re-fetches everything for the album already open -- the refresh button. */
|
||||
public void reload() {
|
||||
AlbumRef target;
|
||||
synchronized (lock) {
|
||||
target = album;
|
||||
}
|
||||
open(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* The current layout: one entry per calendar day for loaded months, one per
|
||||
* month for the rest. Cheap enough to call every frame -- it is a cached
|
||||
* immutable list, rebuilt only when something actually arrives.
|
||||
*/
|
||||
public List<TimelineSection> sections() {
|
||||
return sections;
|
||||
}
|
||||
|
||||
/** Changes whenever {@link #sections()} does, so the renderer knows to re-measure. */
|
||||
public int revision() {
|
||||
return revision.get();
|
||||
}
|
||||
|
||||
public TimelineState state() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public Optional<String> error() {
|
||||
return Optional.ofNullable(error);
|
||||
}
|
||||
|
||||
/** Total assets across the album, known before anything is fetched. */
|
||||
public int assetCount() {
|
||||
synchronized (lock) {
|
||||
return buckets.stream().mapToInt(TimelineBucket::assetCount).sum();
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks for a month's assets. Loaded, loading and failed months are no-ops. */
|
||||
public void request(TimelineBucket bucket) {
|
||||
long token;
|
||||
synchronized (lock) {
|
||||
if (pages.containsKey(bucket.key()) || pageErrors.containsKey(bucket.key())) {
|
||||
return;
|
||||
}
|
||||
token = generation;
|
||||
}
|
||||
if (!inFlight.add(bucket.key())) {
|
||||
return;
|
||||
}
|
||||
loader.execute(() -> loadPage(token, bucket));
|
||||
}
|
||||
|
||||
/** Why a month's tiles are still blank, if it failed rather than merely not being reached yet. */
|
||||
public Optional<String> pageError(TimelineBucket bucket) {
|
||||
synchronized (lock) {
|
||||
return Optional.ofNullable(pageErrors.get(bucket.key()));
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isLoading(TimelineBucket bucket) {
|
||||
return inFlight.contains(bucket.key());
|
||||
}
|
||||
|
||||
/** Clears a month's recorded failure so the next {@link #request} tries again. */
|
||||
public void retryPage(TimelineBucket bucket) {
|
||||
synchronized (lock) {
|
||||
pageErrors.remove(bucket.key());
|
||||
}
|
||||
request(bucket);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
loader.shutdownNow();
|
||||
try {
|
||||
loader.awaitTermination(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadBuckets(long token) {
|
||||
Optional<PhotoProvider> provider = session.provider();
|
||||
if (provider.isEmpty()) {
|
||||
state = TimelineState.NOT_CONFIGURED;
|
||||
revision.incrementAndGet();
|
||||
return;
|
||||
}
|
||||
AlbumRef target;
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
target = album;
|
||||
}
|
||||
try {
|
||||
List<TimelineBucket> fetched = new ArrayList<>(provider.get().timeline(target));
|
||||
fetched.sort(Comparator.naturalOrder());
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
buckets = List.copyOf(fetched);
|
||||
rebuild();
|
||||
}
|
||||
state = fetched.isEmpty() ? TimelineState.EMPTY : TimelineState.READY;
|
||||
} catch (ProviderException e) {
|
||||
log.warn("Could not load the timeline: {}", e.getMessage());
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
error = e.getMessage();
|
||||
}
|
||||
state = TimelineState.FAILED;
|
||||
} finally {
|
||||
revision.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadPage(long token, TimelineBucket bucket) {
|
||||
try {
|
||||
Optional<PhotoProvider> provider = session.provider();
|
||||
if (provider.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
AlbumRef target;
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
target = album;
|
||||
}
|
||||
BucketPage page = provider.get().page(target, bucket);
|
||||
List<TimelineSection.Day> days = groupByDay(page.assets());
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
pages.put(bucket.key(), days);
|
||||
rebuild();
|
||||
}
|
||||
} catch (ProviderException e) {
|
||||
log.warn("Could not load {}: {}", bucket.key(), e.getMessage());
|
||||
synchronized (lock) {
|
||||
if (token == generation) {
|
||||
pageErrors.put(bucket.key(), e.getMessage());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
inFlight.remove(bucket.key());
|
||||
revision.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a month into days, newest first.
|
||||
*
|
||||
* <p>Grouping is by the asset's <em>local</em> date -- the one the camera saw
|
||||
* -- so a photo taken at 23:00 stays on the day it was taken no matter which
|
||||
* timezone the player is browsing from.
|
||||
*/
|
||||
private static List<TimelineSection.Day> groupByDay(List<RemoteAsset> assets) {
|
||||
Map<LocalDate, List<RemoteAsset>> byDay = new LinkedHashMap<>();
|
||||
for (RemoteAsset asset : assets) {
|
||||
byDay.computeIfAbsent(asset.localDay(), day -> new ArrayList<>()).add(asset);
|
||||
}
|
||||
List<TimelineSection.Day> days = new ArrayList<>(byDay.size());
|
||||
byDay.forEach((day, members) -> days.add(new TimelineSection.Day(day, members)));
|
||||
days.sort(Comparator.comparing(TimelineSection.Day::date).reversed());
|
||||
return List.copyOf(days);
|
||||
}
|
||||
|
||||
/** Must hold {@link #lock}. */
|
||||
private void rebuild() {
|
||||
List<TimelineSection> built = new ArrayList<>();
|
||||
for (TimelineBucket bucket : buckets) {
|
||||
List<TimelineSection.Day> loaded = pages.get(bucket.key());
|
||||
if (loaded == null) {
|
||||
built.add(new TimelineSection.PendingMonth(bucket));
|
||||
} else {
|
||||
built.addAll(loaded);
|
||||
}
|
||||
}
|
||||
sections = List.copyOf(built);
|
||||
}
|
||||
|
||||
private static ThreadFactory daemonFactory() {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
return runnable -> {
|
||||
Thread thread = new Thread(runnable, "photosync-timeline-" + counter.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dev.photosync.core.timeline;
|
||||
|
||||
import dev.photosync.core.provider.RemoteAsset;
|
||||
import dev.photosync.core.provider.TimelineBucket;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One labelled run of tiles in the browser.
|
||||
*
|
||||
* <p>The backend groups by month; the player asked to browse by day. Those two
|
||||
* facts are reconciled here rather than in the renderer: a month that has not
|
||||
* been fetched yet contributes a single {@link PendingMonth} whose asset count
|
||||
* is known exactly, and the moment its page arrives it is replaced by one
|
||||
* {@link Day} per calendar day inside it.
|
||||
*
|
||||
* <p>Because the count is exact either way, the list has its true height before
|
||||
* anything is loaded -- so the scrollbar is honest from the first frame and the
|
||||
* browser only ever fetches the months the viewport actually reaches.
|
||||
*/
|
||||
public sealed interface TimelineSection {
|
||||
|
||||
/** The day, or the first of the month for a section that is still pending. */
|
||||
LocalDate date();
|
||||
|
||||
int assetCount();
|
||||
|
||||
/** Assets known so far. Empty while pending, which is what makes a tile a placeholder. */
|
||||
List<RemoteAsset> assets();
|
||||
|
||||
record PendingMonth(TimelineBucket bucket) implements TimelineSection {
|
||||
|
||||
@Override
|
||||
public LocalDate date() {
|
||||
return bucket.month();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int assetCount() {
|
||||
return bucket.assetCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RemoteAsset> assets() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
record Day(LocalDate date, List<RemoteAsset> assets) implements TimelineSection {
|
||||
|
||||
public Day {
|
||||
assets = List.copyOf(assets);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int assetCount() {
|
||||
return assets.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.photosync.core.timeline;
|
||||
|
||||
/** What the browser should be showing instead of, or alongside, the grid. */
|
||||
public enum TimelineState {
|
||||
/** No credentials yet. The settings screen is the thing to offer. */
|
||||
NOT_CONFIGURED,
|
||||
/** Fetching the bucket list. Nothing can be laid out yet. */
|
||||
LOADING,
|
||||
READY,
|
||||
/** The bucket list failed; {@code TimelineBrowser.error()} says why. */
|
||||
FAILED,
|
||||
/** The album really is empty. */
|
||||
EMPTY
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
/**
|
||||
* What the GUI draws for one row: the job, plus the byte counters that are not
|
||||
* worth persisting and would be stale a frame later anyway.
|
||||
*/
|
||||
public record QueuedUpload(UploadJob job, long bytesSent, long bytesTotal) {
|
||||
|
||||
public static QueuedUpload idle(UploadJob job) {
|
||||
return new QueuedUpload(job, 0L, job.sizeBytes());
|
||||
}
|
||||
|
||||
/** 0..1, and 0 rather than NaN when the total is not known yet. */
|
||||
public double fraction() {
|
||||
if (bytesTotal <= 0L) {
|
||||
return 0.0;
|
||||
}
|
||||
return Math.min(1.0, (double) bytesSent / (double) bytesTotal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
import dev.photosync.core.config.UploadSettings;
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.ProviderSession;
|
||||
import dev.photosync.core.provider.TransferCancelledException;
|
||||
import dev.photosync.core.provider.UploadReceipt;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Drains the {@link UploadQueue}.
|
||||
*
|
||||
* <p>A single pump thread wakes a few times a second, works out how many slots
|
||||
* the current settings allow, and hands that many due jobs to a small worker
|
||||
* pool. Nothing has to be reconfigured when the player changes the concurrency:
|
||||
* the next tick simply reads the new number. The same is true of credentials --
|
||||
* while none are set the pump finds no provider and the queue just waits, which
|
||||
* is exactly the behaviour a first-run player wants.
|
||||
*
|
||||
* <p>Threads are daemons. Correctness across a hard shutdown comes from the
|
||||
* queue being durable, not from holding the JVM open; blocking exit on a stuck
|
||||
* socket would be a much worse bug than re-uploading one file.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class UploadCoordinator implements AutoCloseable {
|
||||
|
||||
private static final Duration PUMP_INTERVAL = Duration.ofMillis(250);
|
||||
private static final Duration MAX_BACKOFF = Duration.ofMinutes(10);
|
||||
|
||||
private final UploadQueue queue;
|
||||
private final ProviderSession session;
|
||||
private final Supplier<UploadSettings> settings;
|
||||
|
||||
private final ScheduledExecutorService pump;
|
||||
private final ExecutorService workers;
|
||||
private final AtomicInteger inFlight = new AtomicInteger();
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
public UploadCoordinator(UploadQueue queue, ProviderSession session, Supplier<UploadSettings> settings) {
|
||||
this.queue = queue;
|
||||
this.session = session;
|
||||
this.settings = settings;
|
||||
this.pump = Executors.newSingleThreadScheduledExecutor(named("photosync-pump"));
|
||||
this.workers = Executors.newFixedThreadPool(UploadSettings.MAX_CONCURRENCY, named("photosync-upload"));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
running = true;
|
||||
pump.scheduleWithFixedDelay(this::tick, 0, PUMP_INTERVAL.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/** Nudges the pump so a just-taken screenshot does not sit for a quarter of a second. */
|
||||
public void wake() {
|
||||
if (running) {
|
||||
pump.execute(this::tick);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops taking new work and waits for the transfers already in flight.
|
||||
*
|
||||
* @return true if everything finished within the timeout
|
||||
*/
|
||||
public boolean shutdown(Duration timeout) {
|
||||
running = false;
|
||||
pump.shutdown();
|
||||
workers.shutdown();
|
||||
try {
|
||||
return workers.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!shutdown(Duration.ofSeconds(5))) {
|
||||
// Whatever is still running will be re-tried next launch: the queue
|
||||
// file already says these jobs were interrupted.
|
||||
workers.shutdownNow();
|
||||
pump.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void tick() {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
Optional<PhotoProvider> provider = session.provider();
|
||||
if (provider.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
UploadSettings current = settings.get();
|
||||
Instant now = Instant.now();
|
||||
while (running && inFlight.get() < current.concurrency()) {
|
||||
Optional<UploadJob> claimed = queue.claim(now);
|
||||
if (claimed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
inFlight.incrementAndGet();
|
||||
UploadJob job = claimed.get();
|
||||
try {
|
||||
workers.execute(() -> transfer(job, provider.get(), current));
|
||||
} catch (RuntimeException e) {
|
||||
// Pool already shutting down; put the job back for next launch.
|
||||
inFlight.decrementAndGet();
|
||||
queue.reschedule(job.id(), "Shutting down", now);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void transfer(UploadJob job, PhotoProvider provider, UploadSettings current) {
|
||||
try {
|
||||
UploadReceipt receipt = provider.upload(job.toRequest(), (sent, total) -> {
|
||||
if (queue.isCancelRequested(job.id())) {
|
||||
throw new TransferCancelledException(job.id());
|
||||
}
|
||||
queue.reportProgress(job.id(), sent, total);
|
||||
});
|
||||
queue.succeed(job.id(), receipt);
|
||||
if (current.deleteLocalAfterUpload()) {
|
||||
removeLocalCopy(job);
|
||||
}
|
||||
} catch (TransferCancelledException e) {
|
||||
log.debug("Upload of {} cancelled by the player", job.fileName());
|
||||
} catch (ProviderException e) {
|
||||
handleFailure(job, current, e);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("Unexpected error uploading {}", job.fileName(), e);
|
||||
queue.fail(job.id(), e.toString());
|
||||
} finally {
|
||||
inFlight.decrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFailure(UploadJob job, UploadSettings current, ProviderException failure) {
|
||||
String message = failure.getMessage() == null ? failure.kind().name() : failure.getMessage();
|
||||
boolean tryAgain = failure.isRetryable() && job.attempts() < current.maxAttempts();
|
||||
if (tryAgain) {
|
||||
Duration wait = backoff(job.attempts(), current);
|
||||
log.info("Upload of {} failed ({}); retrying in {}s", job.fileName(), message, wait.toSeconds());
|
||||
queue.reschedule(job.id(), message, Instant.now().plus(wait));
|
||||
} else {
|
||||
log.warn("Giving up on {}: {}", job.fileName(), message);
|
||||
queue.fail(job.id(), message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Exponential, so a server that is down for a while stops being hammered. */
|
||||
private static Duration backoff(int attempts, UploadSettings current) {
|
||||
int exponent = Math.min(attempts - 1, 16);
|
||||
long seconds = (long) current.retryBackoffSeconds() << exponent;
|
||||
return seconds >= MAX_BACKOFF.toSeconds() ? MAX_BACKOFF : Duration.ofSeconds(seconds);
|
||||
}
|
||||
|
||||
private static void removeLocalCopy(UploadJob job) {
|
||||
try {
|
||||
Files.deleteIfExists(job.path());
|
||||
} catch (IOException e) {
|
||||
log.warn("Uploaded {} but could not delete the local copy", job.fileName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static ThreadFactory named(String prefix) {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
return runnable -> {
|
||||
Thread thread = new Thread(runnable, prefix + "-" + counter.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
import dev.photosync.core.provider.UploadReceipt;
|
||||
|
||||
/**
|
||||
* The transitions worth telling someone about.
|
||||
*
|
||||
* <p>Byte-level progress is deliberately not an event. It changes hundreds of
|
||||
* times per upload, and the only thing that wants it -- the queue screen -- is
|
||||
* redrawing every frame anyway and can just read {@link UploadQueue#snapshot()}.
|
||||
* Emitting it would mean thousands of cross-thread hops for no gain.
|
||||
*/
|
||||
public sealed interface UploadEvent {
|
||||
|
||||
UploadJob job();
|
||||
|
||||
record Enqueued(UploadJob job) implements UploadEvent {
|
||||
}
|
||||
|
||||
record Started(UploadJob job) implements UploadEvent {
|
||||
}
|
||||
|
||||
record Completed(UploadJob job, UploadReceipt receipt) implements UploadEvent {
|
||||
}
|
||||
|
||||
/** A single attempt failed. {@code willRetry} says whether another one is coming. */
|
||||
record Failed(UploadJob job, String message, boolean willRetry) implements UploadEvent {
|
||||
}
|
||||
|
||||
record Removed(UploadJob job) implements UploadEvent {
|
||||
}
|
||||
|
||||
/** The last active job left the queue. The quit dialog waits for this. */
|
||||
record Drained(UploadJob job) implements UploadEvent {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
import dev.photosync.core.capture.CaptureOrigin;
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.UploadRequest;
|
||||
import lombok.Builder;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* One screenshot's journey to the backend, as an immutable value.
|
||||
*
|
||||
* <p>Every transition produces a new job rather than mutating one, so the queue
|
||||
* can hand a consistent snapshot to the GUI while workers are running, without
|
||||
* copying or locking on the render thread.
|
||||
*
|
||||
* <p>The file is held as a string rather than a {@link Path} because this record
|
||||
* is serialized straight to the queue file, and Gson has no idea what to do with
|
||||
* a filesystem-specific {@code Path} implementation.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record UploadJob(
|
||||
String id,
|
||||
String file,
|
||||
String fileName,
|
||||
long sizeBytes,
|
||||
Instant capturedAt,
|
||||
CaptureOrigin origin,
|
||||
String albumId,
|
||||
UploadState state,
|
||||
int attempts,
|
||||
Instant notBefore,
|
||||
String lastError,
|
||||
String assetId) {
|
||||
|
||||
public Path path() {
|
||||
return Path.of(file);
|
||||
}
|
||||
|
||||
public AlbumRef album() {
|
||||
return AlbumRef.of(albumId);
|
||||
}
|
||||
|
||||
public Optional<String> failureMessage() {
|
||||
return Optional.ofNullable(lastError).filter(message -> !message.isBlank());
|
||||
}
|
||||
|
||||
public UploadRequest toRequest() {
|
||||
return new UploadRequest(path(), fileName, capturedAt, capturedAt, album());
|
||||
}
|
||||
|
||||
/** True once the backoff has elapsed and a worker may pick this up. */
|
||||
public boolean isClaimable(Instant now) {
|
||||
return switch (state) {
|
||||
case PENDING -> true;
|
||||
case RETRYING -> notBefore == null || !notBefore.isAfter(now);
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import dev.photosync.core.capture.CapturedScreenshot;
|
||||
import dev.photosync.core.persistence.JsonFile;
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.UploadReceipt;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The durable list of screenshots on their way to the backend.
|
||||
*
|
||||
* <p>This is the answer to "what happens if the game dies mid-upload". Every
|
||||
* state change is written through to {@code photosync-queue.json} before it is
|
||||
* announced, so the worst a hard kill can cost is one in-flight request. On the
|
||||
* next launch anything left in {@link UploadState#UPLOADING} is demoted back to
|
||||
* {@link UploadState#PENDING} and simply tried again -- which is safe because
|
||||
* {@code PhotoProvider.upload} is required to answer
|
||||
* {@link UploadReceipt.Outcome#DUPLICATE} rather than store a second copy.
|
||||
*
|
||||
* <p>All mutation happens under one lock and all events are published outside
|
||||
* it, so a listener can call back into the queue without deadlocking.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class UploadQueue {
|
||||
|
||||
private final JsonFile file;
|
||||
private final int historyLimit;
|
||||
|
||||
private final Object lock = new Object();
|
||||
private final Map<String, UploadJob> jobs = new LinkedHashMap<>();
|
||||
|
||||
/** jobId to {sent, total}. Not persisted: meaningless across a restart. */
|
||||
private final Map<String, long[]> transfers = new ConcurrentHashMap<>();
|
||||
private final Set<String> cancelRequests = ConcurrentHashMap.newKeySet();
|
||||
private final List<Consumer<UploadEvent>> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
public UploadQueue(Path path) {
|
||||
this(path, 100);
|
||||
}
|
||||
|
||||
public UploadQueue(Path path, int historyLimit) {
|
||||
this.file = new JsonFile(path);
|
||||
this.historyLimit = historyLimit;
|
||||
restore();
|
||||
}
|
||||
|
||||
public void onEvent(Consumer<UploadEvent> listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reading
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Oldest first, which is also the order workers pick jobs up in. */
|
||||
public List<QueuedUpload> snapshot() {
|
||||
List<UploadJob> current;
|
||||
synchronized (lock) {
|
||||
current = List.copyOf(jobs.values());
|
||||
}
|
||||
List<QueuedUpload> view = new ArrayList<>(current.size());
|
||||
for (UploadJob job : current) {
|
||||
long[] transfer = transfers.get(job.id());
|
||||
view.add(transfer == null
|
||||
? QueuedUpload.idle(job)
|
||||
: new QueuedUpload(job, transfer[0], transfer[1]));
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
public Optional<UploadJob> find(String id) {
|
||||
synchronized (lock) {
|
||||
return Optional.ofNullable(jobs.get(id));
|
||||
}
|
||||
}
|
||||
|
||||
/** How much work the quit dialog is waiting for. */
|
||||
public int activeCount() {
|
||||
synchronized (lock) {
|
||||
return (int) jobs.values().stream().filter(job -> job.state().isActive()).count();
|
||||
}
|
||||
}
|
||||
|
||||
public int failedCount() {
|
||||
synchronized (lock) {
|
||||
return (int) jobs.values().stream().filter(job -> job.state() == UploadState.FAILED).count();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasActiveWork() {
|
||||
synchronized (lock) {
|
||||
return jobs.values().stream().anyMatch(job -> job.state().isActive());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCancelRequested(String jobId) {
|
||||
return cancelRequests.contains(jobId);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Writing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adds a screenshot, or returns the job already covering that file.
|
||||
*
|
||||
* <p>The de-duplication matters on the path where a capture is enqueued and
|
||||
* the player immediately quits and relaunches: the restored job and a fresh
|
||||
* rescan would otherwise both try to upload the same file.
|
||||
*/
|
||||
public UploadJob enqueue(CapturedScreenshot shot, AlbumRef album) {
|
||||
String absolute = shot.file().toAbsolutePath().toString();
|
||||
UploadJob created;
|
||||
synchronized (lock) {
|
||||
Optional<UploadJob> existing = jobs.values().stream()
|
||||
.filter(job -> job.file().equals(absolute))
|
||||
.filter(job -> job.state().isActive() || job.state() == UploadState.FAILED)
|
||||
.findFirst();
|
||||
if (existing.isPresent()) {
|
||||
return existing.get();
|
||||
}
|
||||
created = UploadJob.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.file(absolute)
|
||||
.fileName(shot.fileName())
|
||||
.sizeBytes(shot.sizeBytes())
|
||||
.capturedAt(shot.capturedAt())
|
||||
.origin(shot.origin())
|
||||
.albumId(album.id().orElse(""))
|
||||
.state(UploadState.PENDING)
|
||||
.attempts(0)
|
||||
.build();
|
||||
jobs.put(created.id(), created);
|
||||
persist();
|
||||
}
|
||||
publish(new UploadEvent.Enqueued(created));
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Takes the oldest job that is due, marking it in flight. */
|
||||
public Optional<UploadJob> claim(Instant now) {
|
||||
UploadJob claimed = null;
|
||||
synchronized (lock) {
|
||||
for (UploadJob job : jobs.values()) {
|
||||
if (job.isClaimable(now)) {
|
||||
claimed = job.toBuilder()
|
||||
.state(UploadState.UPLOADING)
|
||||
.attempts(job.attempts() + 1)
|
||||
.lastError(null)
|
||||
.build();
|
||||
jobs.put(claimed.id(), claimed);
|
||||
persist();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (claimed != null) {
|
||||
transfers.put(claimed.id(), new long[]{0L, claimed.sizeBytes()});
|
||||
publish(new UploadEvent.Started(claimed));
|
||||
}
|
||||
return Optional.ofNullable(claimed);
|
||||
}
|
||||
|
||||
public void reportProgress(String jobId, long sent, long total) {
|
||||
transfers.put(jobId, new long[]{sent, total});
|
||||
}
|
||||
|
||||
public void succeed(String jobId, UploadReceipt receipt) {
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.COMPLETED)
|
||||
.assetId(receipt.assetId())
|
||||
.lastError(null)
|
||||
.build(),
|
||||
job -> new UploadEvent.Completed(job, receipt));
|
||||
}
|
||||
|
||||
/** A retryable failure: the job goes back to sleep until {@code notBefore}. */
|
||||
public void reschedule(String jobId, String message, Instant notBefore) {
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.RETRYING)
|
||||
.notBefore(notBefore)
|
||||
.lastError(message)
|
||||
.build(),
|
||||
job -> new UploadEvent.Failed(job, message, true));
|
||||
}
|
||||
|
||||
/** A permanent failure, or the last attempt. Waits for the player now. */
|
||||
public void fail(String jobId, String message) {
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.FAILED)
|
||||
.notBefore(null)
|
||||
.lastError(message)
|
||||
.build(),
|
||||
job -> new UploadEvent.Failed(job, message, false));
|
||||
}
|
||||
|
||||
/** Player pressed retry: a clean slate rather than one more attempt. */
|
||||
public void retry(String jobId) {
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.PENDING)
|
||||
.attempts(0)
|
||||
.notBefore(null)
|
||||
.lastError(null)
|
||||
.build(),
|
||||
UploadEvent.Enqueued::new);
|
||||
}
|
||||
|
||||
public void retryAllFailed() {
|
||||
List<String> failed;
|
||||
synchronized (lock) {
|
||||
failed = jobs.values().stream()
|
||||
.filter(job -> job.state() == UploadState.FAILED)
|
||||
.map(UploadJob::id)
|
||||
.toList();
|
||||
}
|
||||
failed.forEach(this::retry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a job. An upload already in flight is asked to stop through
|
||||
* {@link #isCancelRequested(String)}, which its progress callback checks.
|
||||
*/
|
||||
public void cancel(String jobId) {
|
||||
cancelRequests.add(jobId);
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.CANCELLED)
|
||||
.lastError(null)
|
||||
.build(),
|
||||
UploadEvent.Removed::new);
|
||||
}
|
||||
|
||||
/** Drops a finished or failed job from the list entirely. */
|
||||
public void forget(String jobId) {
|
||||
UploadJob removed;
|
||||
synchronized (lock) {
|
||||
UploadJob job = jobs.get(jobId);
|
||||
if (job == null || job.state() == UploadState.UPLOADING) {
|
||||
return;
|
||||
}
|
||||
removed = jobs.remove(jobId);
|
||||
persist();
|
||||
}
|
||||
transfers.remove(jobId);
|
||||
cancelRequests.remove(jobId);
|
||||
publish(new UploadEvent.Removed(removed));
|
||||
}
|
||||
|
||||
public void clearFinished() {
|
||||
List<UploadJob> removed = new ArrayList<>();
|
||||
synchronized (lock) {
|
||||
jobs.values().removeIf(job -> {
|
||||
if (job.state().isFinished()) {
|
||||
removed.add(job);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!removed.isEmpty()) {
|
||||
persist();
|
||||
}
|
||||
}
|
||||
for (UploadJob job : removed) {
|
||||
transfers.remove(job.id());
|
||||
cancelRequests.remove(job.id());
|
||||
publish(new UploadEvent.Removed(job));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void transition(String jobId,
|
||||
java.util.function.UnaryOperator<UploadJob> change,
|
||||
java.util.function.Function<UploadJob, UploadEvent> event) {
|
||||
UploadJob updated;
|
||||
boolean drained;
|
||||
synchronized (lock) {
|
||||
UploadJob previous = jobs.get(jobId);
|
||||
if (previous == null) {
|
||||
return;
|
||||
}
|
||||
updated = change.apply(previous);
|
||||
jobs.put(jobId, updated);
|
||||
trimHistory();
|
||||
persist();
|
||||
drained = previous.state().isActive() && jobs.values().stream().noneMatch(job -> job.state().isActive());
|
||||
}
|
||||
if (!updated.state().isActive()) {
|
||||
transfers.remove(jobId);
|
||||
}
|
||||
publish(event.apply(updated));
|
||||
if (drained) {
|
||||
publish(new UploadEvent.Drained(updated));
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller holds the lock. Keeps the finished tail from growing without bound. */
|
||||
private void trimHistory() {
|
||||
List<String> finished = jobs.values().stream()
|
||||
.filter(job -> job.state().isFinished())
|
||||
.map(UploadJob::id)
|
||||
.toList();
|
||||
for (int i = 0; i < finished.size() - historyLimit; i++) {
|
||||
jobs.remove(finished.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller holds the lock. */
|
||||
private void persist() {
|
||||
List<UploadJob> durable = jobs.values().stream()
|
||||
.filter(job -> job.state().isPersistent())
|
||||
.toList();
|
||||
try {
|
||||
file.write(file.gson().toJsonTree(durable));
|
||||
} catch (IOException e) {
|
||||
log.error("Could not write the upload queue to {}", file.path(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void restore() {
|
||||
TypeToken<List<UploadJob>> listType = new TypeToken<>() {
|
||||
};
|
||||
List<UploadJob> stored = file.readTree()
|
||||
.<List<UploadJob>>map(tree -> file.gson().fromJson(tree, listType.getType()))
|
||||
.orElseGet(List::of);
|
||||
|
||||
int skipped = 0;
|
||||
for (UploadJob job : stored) {
|
||||
if (job == null || job.id() == null || job.file() == null || job.state() == null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!Files.isRegularFile(Path.of(job.file()))) {
|
||||
// The screenshot was deleted while we were away. Nothing to send.
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Anything that claimed to be uploading was interrupted by whatever
|
||||
// ended the last session; start it over.
|
||||
UploadJob resumed = job.state() == UploadState.UPLOADING
|
||||
? job.toBuilder().state(UploadState.PENDING).build()
|
||||
: job;
|
||||
jobs.put(resumed.id(), resumed);
|
||||
}
|
||||
if (!jobs.isEmpty() || skipped > 0) {
|
||||
log.info("Restored {} pending upload(s) from {} ({} dropped)", jobs.size(), file.path(), skipped);
|
||||
}
|
||||
}
|
||||
|
||||
private void publish(UploadEvent event) {
|
||||
for (Consumer<UploadEvent> listener : listeners) {
|
||||
try {
|
||||
listener.accept(event);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("An upload listener failed on {}", event.getClass().getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
/**
|
||||
* Where a job is in its life.
|
||||
*
|
||||
* <p>{@link #FAILED} is neither active nor finished on purpose: the job has run
|
||||
* out of automatic attempts and is now waiting for the player to press retry.
|
||||
* Treating it as finished would quietly lose screenshots; treating it as active
|
||||
* would keep the quit dialog open forever.
|
||||
*/
|
||||
public enum UploadState {
|
||||
|
||||
/** Waiting for a free worker. */
|
||||
PENDING,
|
||||
/** A worker is pushing bytes right now. */
|
||||
UPLOADING,
|
||||
/** A retryable error; will be picked up again after a backoff. */
|
||||
RETRYING,
|
||||
/** The backend has the file. */
|
||||
COMPLETED,
|
||||
/** Out of attempts, or a permanent error. Needs the player. */
|
||||
FAILED,
|
||||
/** The player removed it before it went up. */
|
||||
CANCELLED;
|
||||
|
||||
/** Counts as outstanding work: the quit dialog waits for these. */
|
||||
public boolean isActive() {
|
||||
return this == PENDING || this == UPLOADING || this == RETRYING;
|
||||
}
|
||||
|
||||
/** Nothing more will happen without the player asking. */
|
||||
public boolean isFinished() {
|
||||
return this == COMPLETED || this == CANCELLED;
|
||||
}
|
||||
|
||||
/** Worth writing to the queue file, because it still means something next launch. */
|
||||
public boolean isPersistent() {
|
||||
return isActive() || this == FAILED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package dev.photosync.core.thumbnail;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The decoder is a transcription of a bit-packed format, so these tests aim at
|
||||
* the things a transcription gets wrong: which bits a field lives in, how the
|
||||
* shared nibble stream is walked, and the colour-space conversion at the end.
|
||||
*
|
||||
* <p>Hashes are built here rather than captured from a server, because setting
|
||||
* the three AC scales to zero collapses the whole DCT to its DC term -- giving a
|
||||
* flat image whose exact colour can be worked out by hand.
|
||||
*/
|
||||
class ThumbHashTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("a hash with no AC energy decodes to its DC colour, everywhere")
|
||||
void flatImage() {
|
||||
// l_dc = 42/63, p_dc = 63 -> +1.0, q_dc = 10 -> -0.68254, l_scale = 0.
|
||||
// blue = 0.66667 - 2/3 * 1.0 = 0.0
|
||||
// red = (3*0.66667 - 0.0 + -0.68254)/2 = 0.65873 -> 168
|
||||
// green = red - q = 1.34127 -> 255 (clamped)
|
||||
byte[] hash = hash(42, 63, 10, 0, 7, 0, 0);
|
||||
|
||||
ThumbImage image = ThumbHash.decode(hash).orElseThrow();
|
||||
|
||||
assertEquals(32, image.width());
|
||||
assertEquals(32, image.height());
|
||||
for (int y = 0; y < image.height(); y++) {
|
||||
for (int x = 0; x < image.width(); x++) {
|
||||
int pixel = image.pixel(x, y);
|
||||
assertEquals(255, alpha(pixel), "opaque when the hash carries no alpha");
|
||||
assertNear(168, red(pixel), "red");
|
||||
assertNear(255, green(pixel), "green");
|
||||
assertNear(0, blue(pixel), "blue");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a landscape hash is decoded at its own aspect ratio")
|
||||
void aspectRatio() {
|
||||
// Landscape with no alpha pins lx to 7 and reads ly from the low nibble:
|
||||
// 7/3 -> 32 x round(32 / 2.3333) = 32 x 14.
|
||||
ThumbImage image = ThumbHash.decode(landscape(3)).orElseThrow();
|
||||
|
||||
assertEquals(32, image.width());
|
||||
assertEquals(14, image.height());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AC coefficients actually reach the pixels")
|
||||
void acCoefficientsVaryTheImage() {
|
||||
// Same DC as the flat case but with full luminance scale, so the AC
|
||||
// nibbles -- which are 0x5A filler -- must show up as variation.
|
||||
ThumbImage image = ThumbHash.decode(hash(42, 63, 10, 31, 7, 63, 63)).orElseThrow();
|
||||
|
||||
int first = image.pixel(0, 0);
|
||||
boolean varies = false;
|
||||
for (int y = 0; y < image.height() && !varies; y++) {
|
||||
for (int x = 0; x < image.width(); x++) {
|
||||
if (image.pixel(x, y) != first) {
|
||||
varies = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(varies, "a non-zero l_scale must produce a non-flat image");
|
||||
assertNotEquals(0, image.argb().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("malformed input yields no image rather than an exception")
|
||||
void rejectsGarbage() {
|
||||
assertEquals(Optional.empty(), ThumbHash.decode((String) null));
|
||||
assertEquals(Optional.empty(), ThumbHash.decode(""));
|
||||
assertEquals(Optional.empty(), ThumbHash.decode("not base64 at all!!"));
|
||||
assertEquals(Optional.empty(), ThumbHash.decode(new byte[] {1, 2, 3}));
|
||||
// Well-formed header, but the AC nibble stream is truncated.
|
||||
assertEquals(Optional.empty(), ThumbHash.decode(new byte[] {(byte) 0xEA, (byte) 0xAF, 0, 7, 0, 0x5A}));
|
||||
}
|
||||
|
||||
/** Builds a portrait, alpha-less hash from the raw field values. */
|
||||
private static byte[] hash(int lDc, int pDc, int qDc, int lScale, int lCount, int pScale, int qScale) {
|
||||
int header24 = (lDc & 63) | ((pDc & 63) << 6) | ((qDc & 63) << 12) | ((lScale & 31) << 18);
|
||||
int header16 = (lCount & 7) | ((pScale & 63) << 3) | ((qScale & 63) << 9);
|
||||
byte[] hash = new byte[24];
|
||||
hash[0] = (byte) header24;
|
||||
hash[1] = (byte) (header24 >> 8);
|
||||
hash[2] = (byte) (header24 >> 16);
|
||||
hash[3] = (byte) header16;
|
||||
hash[4] = (byte) (header16 >> 8);
|
||||
for (int i = 5; i < hash.length; i++) {
|
||||
hash[i] = 0x5A;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static byte[] landscape(int lyCount) {
|
||||
byte[] hash = hash(42, 63, 10, 0, lyCount, 0, 0);
|
||||
hash[4] |= (byte) 0x80;
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static void assertNear(int expected, int actual, String channel) {
|
||||
assertTrue(Math.abs(expected - actual) <= 2,
|
||||
() -> channel + " expected around " + expected + " but was " + actual);
|
||||
}
|
||||
|
||||
private static int alpha(int argb) {
|
||||
return (argb >>> 24) & 0xFF;
|
||||
}
|
||||
|
||||
private static int red(int argb) {
|
||||
return (argb >>> 16) & 0xFF;
|
||||
}
|
||||
|
||||
private static int green(int argb) {
|
||||
return (argb >>> 8) & 0xFF;
|
||||
}
|
||||
|
||||
private static int blue(int argb) {
|
||||
return argb & 0xFF;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user