diff --git a/README.md b/README.md index 5877f3f..95b91e6 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ Then pick an **Album**. "Whole library" uploads without adding to any album; the picker also creates one if you type a name and press Create. The chosen album is both where uploads go and what the Browse tab shows. +Every upload carries a one-line description: the window title (which a modpack +replaces with its own name) and the server you were on, or "Singleplayer". It is +captured the moment the screenshot is taken, so an album mixed from several +clients or worlds stays tellable apart. + Settings save when you close the screen. **Revert** undoes everything since you opened it. diff --git a/docs/PORTING.md b/docs/PORTING.md index 00b53fb..536dfe2 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -346,6 +346,8 @@ deletes the placeholder. | set screen | `Minecraft.setScreen` | ✓ | ✓ | **`Minecraft.gui.setScreen`** | | render target | `Minecraft.getMainRenderTarget()` | ✓ | ✓ | **`gameRenderer.mainRenderTarget()`** | | `Minecraft.stop()`, `getWindow()` | ✓ | ✓ | ✓ | ✓ | +| current server | `Minecraft.getCurrentServer()` → `ServerData.ip` | ✓ | ✓ | ✓ | ✓ | +| window title | `Window.setTitle(String)`, recorded by `TitleMixin` | ✓ | ✓ | ✓ | ✓ | | `execute(Runnable)`, `isSameThread()` | inherited from `BlockableEventLoop`, unchanged | ✓ | ✓ | ✓ | | camera entity | `Minecraft.getCameraEntity()` → `world.entity.Entity` | ✓ | ✓ | ✓ | | camera pose | `Entity.getX/getY/getZ/getYRot/getXRot` | ✓ | ✓ | ✓ | diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bf52bf..1bb62e6 100644 --- a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.Util; @@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext { return minecraftVersion; } + @Override + public String clientName() { + String title = WindowTitle.last(); + return title == null || title.isBlank() ? "Minecraft" : title; + } + + @Override + public Optional serverAddress() { + return Optional.ofNullable(Minecraft.getInstance().getCurrentServer()) + .map(server -> server.ip); + } + @Override public void quit() { Minecraft.getInstance().stop(); diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bf52bf..1bb62e6 100644 --- a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.Util; @@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext { return minecraftVersion; } + @Override + public String clientName() { + String title = WindowTitle.last(); + return title == null || title.isBlank() ? "Minecraft" : title; + } + + @Override + public Optional serverAddress() { + return Optional.ofNullable(Minecraft.getInstance().getCurrentServer()) + .map(server -> server.ip); + } + @Override public void quit() { Minecraft.getInstance().stop(); diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bf52bf..1bb62e6 100644 --- a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.Util; @@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext { return minecraftVersion; } + @Override + public String clientName() { + String title = WindowTitle.last(); + return title == null || title.isBlank() ? "Minecraft" : title; + } + + @Override + public Optional serverAddress() { + return Optional.ofNullable(Minecraft.getInstance().getCurrentServer()) + .map(server -> server.ip); + } + @Override public void quit() { Minecraft.getInstance().stop(); diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bf52bf..1bb62e6 100644 --- a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.Util; @@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext { return minecraftVersion; } + @Override + public String clientName() { + String title = WindowTitle.last(); + return title == null || title.isBlank() ? "Minecraft" : title; + } + + @Override + public Optional serverAddress() { + return Optional.ofNullable(Minecraft.getInstance().getCurrentServer()) + .map(server -> server.ip); + } + @Override public void quit() { Minecraft.getInstance().stop(); diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java index c87a6d7..c07a9eb 100644 --- a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.util.Util; @@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext { return minecraftVersion; } + @Override + public String clientName() { + String title = WindowTitle.last(); + return title == null || title.isBlank() ? "Minecraft" : title; + } + + @Override + public Optional serverAddress() { + return Optional.ofNullable(Minecraft.getInstance().getCurrentServer()) + .map(server -> server.ip); + } + @Override public void quit() { Minecraft.getInstance().stop(); diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bf52bf..1bb62e6 100644 --- a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.Util; @@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext { return minecraftVersion; } + @Override + public String clientName() { + String title = WindowTitle.last(); + return title == null || title.isBlank() ? "Minecraft" : title; + } + + @Override + public Optional serverAddress() { + return Optional.ofNullable(Minecraft.getInstance().getCurrentServer()) + .map(server -> server.ip); + } + @Override public void quit() { Minecraft.getInstance().stop(); diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bf52bf..1bb62e6 100644 --- a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.Util; @@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext { return minecraftVersion; } + @Override + public String clientName() { + String title = WindowTitle.last(); + return title == null || title.isBlank() ? "Minecraft" : title; + } + + @Override + public Optional serverAddress() { + return Optional.ofNullable(Minecraft.getInstance().getCurrentServer()) + .map(server -> server.ip); + } + @Override public void quit() { Minecraft.getInstance().stop(); diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 1bf52bf..1bb62e6 100644 --- a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.Util; @@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext { return minecraftVersion; } + @Override + public String clientName() { + String title = WindowTitle.last(); + return title == null || title.isBlank() ? "Minecraft" : title; + } + + @Override + public Optional serverAddress() { + return Optional.ofNullable(Minecraft.getInstance().getCurrentServer()) + .map(server -> server.ip); + } + @Override public void quit() { Minecraft.getInstance().stop(); diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java index 78edc11..cea5480 100644 --- a/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -2,6 +2,7 @@ package dev.photosync.platform.impl; import dev.photosync.core.capture.CameraPose; import dev.photosync.mcapi.GameContext; +import dev.photosync.platform.WindowTitle; import lombok.extern.slf4j.Slf4j; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.util.Util; @@ -89,6 +90,18 @@ public final class GameAdapter implements GameContext { return minecraftVersion; } + @Override + public String clientName() { + String title = WindowTitle.last(); + return title == null || title.isBlank() ? "Minecraft" : title; + } + + @Override + public Optional serverAddress() { + return Optional.ofNullable(Minecraft.getInstance().getCurrentServer()) + .map(server -> server.ip); + } + @Override public void quit() { Minecraft.getInstance().stop(); diff --git a/platform/common/src/main/java/dev/photosync/platform/WindowTitle.java b/platform/common/src/main/java/dev/photosync/platform/WindowTitle.java new file mode 100644 index 0000000..fc6e162 --- /dev/null +++ b/platform/common/src/main/java/dev/photosync/platform/WindowTitle.java @@ -0,0 +1,29 @@ +package dev.photosync.platform; + +/** + * The most recent window title, for the upload description's client name. + * + *

Static for the same reason as {@code ScreenshotBus} and {@code QuitGuard}: + * the thing that notices the title changing is {@code TitleMixin}, woven into a + * Minecraft class that nobody constructs, so it has nowhere to be handed a + * collaborator. A static rendezvous point is the only option, and the mixin + * writes here and the {@code GameAdapter} reads from here, so the storage has + * exactly one owner. + */ +public final class WindowTitle { + + private static volatile String last = ""; + + private WindowTitle() { + } + + /** Called by {@code TitleMixin} whenever the window is given a title. */ + public static void record(String title) { + last = title; + } + + /** The current window title, or empty before the first {@code setTitle}. */ + public static String last() { + return last; + } +} diff --git a/platform/common/src/main/java/dev/photosync/platform/mixin/TitleMixin.java b/platform/common/src/main/java/dev/photosync/platform/mixin/TitleMixin.java new file mode 100644 index 0000000..c48990c --- /dev/null +++ b/platform/common/src/main/java/dev/photosync/platform/mixin/TitleMixin.java @@ -0,0 +1,30 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.WindowTitle; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Hooks the window title, for the upload description's client name. + * + *

There is no way to read the title back from GLFW before lwjgl 3.4 (the + * buckets below 26.x ship 3.3.x, which has no {@code glfwGetWindowTitle}), so + * the title is captured as every caller passes it through {@code setTitle} -- + * the one public way to change it, and the one every title mod uses. + * + *

Like {@code QuitMixin}, the hook only records into a static holder and + * holds no state of its own. {@code setTitle(String)} has not moved in the + * supported range; the day a bucket disagrees, this class moves down into all + * nine copies like any other common class. + */ +@Mixin(Window.class) +public class TitleMixin { + + @Inject(method = "setTitle", at = @At("HEAD")) + private void photosync$capture(String title, CallbackInfo callback) { + WindowTitle.record(title); + } +} diff --git a/platform/common/src/main/resources/photosync.mixins.json b/platform/common/src/main/resources/photosync.mixins.json index d3edfa7..1344568 100644 --- a/platform/common/src/main/resources/photosync.mixins.json +++ b/platform/common/src/main/resources/photosync.mixins.json @@ -6,7 +6,8 @@ "client": [ "CaptureMixin", "HudMixin", - "QuitMixin" + "QuitMixin", + "TitleMixin" ], "injectors": { "defaultRequire": 1 diff --git a/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java b/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java index 0e3215d..2d58662 100644 --- a/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java +++ b/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java @@ -6,6 +6,7 @@ import dev.photosync.core.config.NotificationKind; import dev.photosync.core.config.PhotoSyncConfig; import dev.photosync.core.upload.UploadEvent; import dev.photosync.mcapi.ClientBridge; +import dev.photosync.mcapi.GameContext; import dev.photosync.mcapi.capture.ScreenshotBus; import dev.photosync.mcapi.lifecycle.QuitGuard; import dev.photosync.mcapi.render.RenderBridge; @@ -114,7 +115,29 @@ public final class PhotoSyncClient implements AutoCloseable { log.debug("Not queuing {}: no provider configured", shot.fileName()); return; } - core.queue().enqueue(shot, config.album()); + core.queue().enqueue(shot, config.album(), captureDescription()); + } + + /** + * One line for the upload's description: the window title -- which a + * modpack replaces with its own name -- and the server being played on, or + * the word for single player. + * + *

Snapshot at capture time, because the queue outlives the session: a + * screenshot retried tomorrow should describe the moment it was taken, not + * whatever server the player is on when the retry fires. + */ + private String captureDescription() { + try { + GameContext game = bridge.game(); + String where = game.serverAddress() + .orElseGet(() -> chrome().translate("photosync.description.singleplayer")); + return game.clientName() + " · " + where; + } catch (RuntimeException e) { + // A description that cannot be captured must not cost the upload. + log.warn("Could not capture the screenshot context", e); + return ""; + } } private void uploadChanged(UploadEvent event) { diff --git a/shared/client/src/main/resources/assets/photosync/lang/en_us.json b/shared/client/src/main/resources/assets/photosync/lang/en_us.json index 32e02bf..b8ee6cf 100644 --- a/shared/client/src/main/resources/assets/photosync/lang/en_us.json +++ b/shared/client/src/main/resources/assets/photosync/lang/en_us.json @@ -114,6 +114,8 @@ "photosync.notify.uploaded": "Uploaded %s", "photosync.notify.failed": "Upload failed: %s", + "photosync.description.singleplayer": "Singleplayer", + "photosync.provider.immich.name": "Immich", "photosync.provider.immich.endpoint": "Server URL", "photosync.provider.immich.endpoint.hint": "https://photos.example.com", diff --git a/shared/client/src/main/resources/assets/photosync/lang/zh_cn.json b/shared/client/src/main/resources/assets/photosync/lang/zh_cn.json index e9660a6..87d49e2 100644 --- a/shared/client/src/main/resources/assets/photosync/lang/zh_cn.json +++ b/shared/client/src/main/resources/assets/photosync/lang/zh_cn.json @@ -114,6 +114,8 @@ "photosync.notify.uploaded": "已上传 %s", "photosync.notify.failed": "上传失败:%s", + "photosync.description.singleplayer": "单人", + "photosync.provider.immich.name": "Immich", "photosync.provider.immich.endpoint": "服务器地址", "photosync.provider.immich.endpoint.hint": "https://photos.example.com", diff --git a/shared/core/src/main/java/dev/photosync/core/provider/UploadRequest.java b/shared/core/src/main/java/dev/photosync/core/provider/UploadRequest.java index 38a24a9..2424774 100644 --- a/shared/core/src/main/java/dev/photosync/core/provider/UploadRequest.java +++ b/shared/core/src/main/java/dev/photosync/core/provider/UploadRequest.java @@ -14,10 +14,12 @@ public record UploadRequest( String fileName, Instant capturedAt, Instant modifiedAt, - AlbumRef album) { + AlbumRef album, + String description) { public UploadRequest { album = album == null ? AlbumRef.library() : album; modifiedAt = modifiedAt == null ? capturedAt : modifiedAt; + description = description == null ? "" : description; } } diff --git a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichApi.java b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichApi.java index 1da9404..f957fa9 100644 --- a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichApi.java +++ b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichApi.java @@ -106,6 +106,19 @@ final class ImmichApi { return decode(send(request), type); } + /** + * PATCHes JSON and discards the response body -- the only PATCH PhotoSync + * makes is the asset description update, whose reply is the whole asset we + * do not need. + */ + void patch(String path, Object body) throws ProviderException { + HttpRequest request = request(path, Map.of(), REQUEST_TIMEOUT) + .header("Content-Type", "application/json") + .method("PATCH", HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8)) + .build(); + send(request); + } + T upload(String path, MultipartBody body, String checksumBase64, TransferProgress progress, Type type) throws ProviderException { HttpRequest.Builder builder = request(path, Map.of(), UPLOAD_TIMEOUT) diff --git a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichDtos.java b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichDtos.java index a6e4dca..beead1d 100644 --- a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichDtos.java +++ b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichDtos.java @@ -43,6 +43,10 @@ final class ImmichDtos { record AssetMediaResponse(String id, String status) { } + /** Body for {@code PATCH /assets/:id} -- the only field PhotoSync writes. */ + record UpdateAsset(String description) { + } + record BulkIds(List ids) { } diff --git a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProvider.java b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProvider.java index 3b81617..1886fd2 100644 --- a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProvider.java +++ b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProvider.java @@ -161,6 +161,9 @@ public final class ImmichProvider implements PhotoProvider { if (album.isPresent()) { addToAlbum(album.get(), response.id()); } + if (!request.description().isBlank()) { + setDescription(response.id(), request.description()); + } return new UploadReceipt(response.id(), outcome); } @@ -327,6 +330,24 @@ public final class ImmichProvider implements PhotoProvider { } } + /** + * Writes the one-line description after the asset exists, even when the + * upload answered {@code duplicate} -- a retried upload whose first + * response was lost reaches the server twice, and the description must land + * on whichever attempt gets there. + * + *

Failures are warnings, not errors: the screenshot is the point, and the + * queue must not show a failed upload for an asset the server is holding. + */ + private void setDescription(String assetId, String description) { + try { + api.patch("/assets/" + assetId, new ImmichDtos.UpdateAsset(description)); + } catch (ProviderException e) { + log.warn("Uploaded {} but Immich would not set its description: {}", + assetId, e.getMessage()); + } + } + /** Album filter plus the flags that keep archived and trashed assets out of the library view. */ private static Map scope(AlbumRef album) { Map query = new LinkedHashMap<>(); diff --git a/shared/core/src/main/java/dev/photosync/core/upload/UploadJob.java b/shared/core/src/main/java/dev/photosync/core/upload/UploadJob.java index e2a54b5..02b9ff0 100644 --- a/shared/core/src/main/java/dev/photosync/core/upload/UploadJob.java +++ b/shared/core/src/main/java/dev/photosync/core/upload/UploadJob.java @@ -29,6 +29,7 @@ public record UploadJob( Instant capturedAt, CaptureOrigin origin, String albumId, + String description, UploadState state, int attempts, Instant notBefore, @@ -48,7 +49,7 @@ public record UploadJob( } public UploadRequest toRequest() { - return new UploadRequest(path(), fileName, capturedAt, capturedAt, album()); + return new UploadRequest(path(), fileName, capturedAt, capturedAt, album(), description); } /** True once the backoff has elapsed and a worker may pick this up. */ diff --git a/shared/core/src/main/java/dev/photosync/core/upload/UploadQueue.java b/shared/core/src/main/java/dev/photosync/core/upload/UploadQueue.java index 51aa169..05736a9 100644 --- a/shared/core/src/main/java/dev/photosync/core/upload/UploadQueue.java +++ b/shared/core/src/main/java/dev/photosync/core/upload/UploadQueue.java @@ -123,8 +123,12 @@ public final class UploadQueue { *

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. + * + * @param description one line of context captured with the screenshot, e.g. + * client and server; persisted with the job so a retry + * after a restart describes the same moment */ - public UploadJob enqueue(CapturedScreenshot shot, AlbumRef album) { + public UploadJob enqueue(CapturedScreenshot shot, AlbumRef album, String description) { String absolute = shot.file().toAbsolutePath().toString(); UploadJob created; synchronized (lock) { @@ -143,6 +147,7 @@ public final class UploadQueue { .capturedAt(shot.capturedAt()) .origin(shot.origin()) .albumId(album.id().orElse("")) + .description(description) .state(UploadState.PENDING) .attempts(0) .build(); diff --git a/shared/core/src/test/java/dev/photosync/core/upload/UploadJobTest.java b/shared/core/src/test/java/dev/photosync/core/upload/UploadJobTest.java new file mode 100644 index 0000000..9ec0311 --- /dev/null +++ b/shared/core/src/test/java/dev/photosync/core/upload/UploadJobTest.java @@ -0,0 +1,46 @@ +package dev.photosync.core.upload; + +import dev.photosync.core.provider.UploadRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The description is captured once, at the moment the screenshot is taken, and + * must survive the queue's persistence and retries unchanged -- it describes + * the moment the shot was taken, not the moment it was sent. + */ +class UploadJobTest { + + private static final Instant TAKEN = Instant.parse("2026-08-08T12:00:00Z"); + + @Test + @DisplayName("the context captured at enqueue time travels with the upload") + void descriptionSurvivesToRequest() { + UploadJob job = UploadJob.builder() + .id("job-1") + .file("/tmp/shot.png") + .fileName("shot.png") + .sizeBytes(42) + .capturedAt(TAKEN) + .description("My Pack · play.example.com") + .state(UploadState.PENDING) + .attempts(0) + .build(); + + assertEquals("My Pack · play.example.com", job.toRequest().description()); + } + + @Test + @DisplayName("a job restored from an old queue file has no description to send") + void nullDescriptionBecomesEmpty() { + UploadRequest request = new UploadRequest( + Path.of("/tmp/shot.png"), "shot.png", TAKEN, TAKEN, null, null); + + assertEquals("", request.description()); + } +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java index e78b991..1a5bd37 100644 --- a/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java @@ -50,6 +50,20 @@ public interface GameContext { /** The running Minecraft version, for logs and the settings screen's footer. */ String minecraftVersion(); + /** + * The window title, which modpacks replace with their own name. Recorded + * from {@code Window.setTitle} rather than reconstructed, because only the + * window knows what some other mod put there. + */ + String clientName(); + + /** + * The multiplayer server's address, or empty in single player and on the + * title screen. Both the value and its absence are meaningful to the upload + * description. + */ + Optional serverAddress(); + /** * Shuts the game down, having already opened {@code QuitGuard}'s gate. *