upload client name and server address as description

This commit is contained in:
iceBear67
2026-08-08 13:05:57 +08:00
parent c3a9290477
commit 2c69d10361
25 changed files with 322 additions and 5 deletions
+5
View File
@@ -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.
+2
View File
@@ -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` | ✓ | ✓ | ✓ |
@@ -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<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -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<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -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<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -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<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -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<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -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<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -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<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -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<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -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<String> serverAddress() {
return Optional.ofNullable(Minecraft.getInstance().getCurrentServer())
.map(server -> server.ip);
}
@Override
public void quit() {
Minecraft.getInstance().stop();
@@ -0,0 +1,29 @@
package dev.photosync.platform;
/**
* The most recent window title, for the upload description's client name.
*
* <p>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;
}
}
@@ -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.
*
* <p>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.
*
* <p>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);
}
}
@@ -6,7 +6,8 @@
"client": [
"CaptureMixin",
"HudMixin",
"QuitMixin"
"QuitMixin",
"TitleMixin"
],
"injectors": {
"defaultRequire": 1
@@ -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.
*
* <p>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) {
@@ -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",
@@ -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",
@@ -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;
}
}
@@ -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> T upload(String path, MultipartBody body, String checksumBase64, TransferProgress progress, Type type)
throws ProviderException {
HttpRequest.Builder builder = request(path, Map.of(), UPLOAD_TIMEOUT)
@@ -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<String> ids) {
}
@@ -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.
*
* <p>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<String, String> scope(AlbumRef album) {
Map<String, String> query = new LinkedHashMap<>();
@@ -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. */
@@ -123,8 +123,12 @@ public final class UploadQueue {
* <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.
*
* @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();
@@ -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());
}
}
@@ -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<String> serverAddress();
/**
* Shuts the game down, having already opened {@code QuitGuard}'s gate.
*