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
@@ -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.
*