This commit is contained in:
iceBear67
2026-08-07 18:10:38 +00:00
commit 014b18f1ba
232 changed files with 16847 additions and 0 deletions
@@ -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);
}
}
}
@@ -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;
}
}