upload client name and server address as description
This commit is contained in:
@@ -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
|
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.
|
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
|
Settings save when you close the screen. **Revert** undoes everything since you
|
||||||
opened it.
|
opened it.
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,8 @@ deletes the placeholder.
|
|||||||
| set screen | `Minecraft.setScreen` | ✓ | ✓ | **`Minecraft.gui.setScreen`** |
|
| set screen | `Minecraft.setScreen` | ✓ | ✓ | **`Minecraft.gui.setScreen`** |
|
||||||
| render target | `Minecraft.getMainRenderTarget()` | ✓ | ✓ | **`gameRenderer.mainRenderTarget()`** |
|
| render target | `Minecraft.getMainRenderTarget()` | ✓ | ✓ | **`gameRenderer.mainRenderTarget()`** |
|
||||||
| `Minecraft.stop()`, `getWindow()` | ✓ | ✓ | ✓ | ✓ |
|
| `Minecraft.stop()`, `getWindow()` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| current server | `Minecraft.getCurrentServer()` → `ServerData.ip` | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| window title | `Window.setTitle(String)`, recorded by `TitleMixin` | ✓ | ✓ | ✓ | ✓ |
|
||||||
| `execute(Runnable)`, `isSameThread()` | inherited from `BlockableEventLoop`, unchanged | ✓ | ✓ | ✓ |
|
| `execute(Runnable)`, `isSameThread()` | inherited from `BlockableEventLoop`, unchanged | ✓ | ✓ | ✓ |
|
||||||
| camera entity | `Minecraft.getCameraEntity()` → `world.entity.Entity` | ✓ | ✓ | ✓ |
|
| camera entity | `Minecraft.getCameraEntity()` → `world.entity.Entity` | ✓ | ✓ | ✓ |
|
||||||
| camera pose | `Entity.getX/getY/getZ/getYRot/getXRot` | ✓ | ✓ | ✓ |
|
| 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.core.capture.CameraPose;
|
||||||
import dev.photosync.mcapi.GameContext;
|
import dev.photosync.mcapi.GameContext;
|
||||||
|
import dev.photosync.platform.WindowTitle;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.Util;
|
import net.minecraft.Util;
|
||||||
@@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext {
|
|||||||
return minecraftVersion;
|
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
|
@Override
|
||||||
public void quit() {
|
public void quit() {
|
||||||
Minecraft.getInstance().stop();
|
Minecraft.getInstance().stop();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
|
|||||||
|
|
||||||
import dev.photosync.core.capture.CameraPose;
|
import dev.photosync.core.capture.CameraPose;
|
||||||
import dev.photosync.mcapi.GameContext;
|
import dev.photosync.mcapi.GameContext;
|
||||||
|
import dev.photosync.platform.WindowTitle;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.Util;
|
import net.minecraft.Util;
|
||||||
@@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext {
|
|||||||
return minecraftVersion;
|
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
|
@Override
|
||||||
public void quit() {
|
public void quit() {
|
||||||
Minecraft.getInstance().stop();
|
Minecraft.getInstance().stop();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
|
|||||||
|
|
||||||
import dev.photosync.core.capture.CameraPose;
|
import dev.photosync.core.capture.CameraPose;
|
||||||
import dev.photosync.mcapi.GameContext;
|
import dev.photosync.mcapi.GameContext;
|
||||||
|
import dev.photosync.platform.WindowTitle;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.Util;
|
import net.minecraft.Util;
|
||||||
@@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext {
|
|||||||
return minecraftVersion;
|
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
|
@Override
|
||||||
public void quit() {
|
public void quit() {
|
||||||
Minecraft.getInstance().stop();
|
Minecraft.getInstance().stop();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
|
|||||||
|
|
||||||
import dev.photosync.core.capture.CameraPose;
|
import dev.photosync.core.capture.CameraPose;
|
||||||
import dev.photosync.mcapi.GameContext;
|
import dev.photosync.mcapi.GameContext;
|
||||||
|
import dev.photosync.platform.WindowTitle;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.Util;
|
import net.minecraft.Util;
|
||||||
@@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext {
|
|||||||
return minecraftVersion;
|
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
|
@Override
|
||||||
public void quit() {
|
public void quit() {
|
||||||
Minecraft.getInstance().stop();
|
Minecraft.getInstance().stop();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
|
|||||||
|
|
||||||
import dev.photosync.core.capture.CameraPose;
|
import dev.photosync.core.capture.CameraPose;
|
||||||
import dev.photosync.mcapi.GameContext;
|
import dev.photosync.mcapi.GameContext;
|
||||||
|
import dev.photosync.platform.WindowTitle;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.util.Util;
|
import net.minecraft.util.Util;
|
||||||
@@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext {
|
|||||||
return minecraftVersion;
|
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
|
@Override
|
||||||
public void quit() {
|
public void quit() {
|
||||||
Minecraft.getInstance().stop();
|
Minecraft.getInstance().stop();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
|
|||||||
|
|
||||||
import dev.photosync.core.capture.CameraPose;
|
import dev.photosync.core.capture.CameraPose;
|
||||||
import dev.photosync.mcapi.GameContext;
|
import dev.photosync.mcapi.GameContext;
|
||||||
|
import dev.photosync.platform.WindowTitle;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.Util;
|
import net.minecraft.Util;
|
||||||
@@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext {
|
|||||||
return minecraftVersion;
|
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
|
@Override
|
||||||
public void quit() {
|
public void quit() {
|
||||||
Minecraft.getInstance().stop();
|
Minecraft.getInstance().stop();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
|
|||||||
|
|
||||||
import dev.photosync.core.capture.CameraPose;
|
import dev.photosync.core.capture.CameraPose;
|
||||||
import dev.photosync.mcapi.GameContext;
|
import dev.photosync.mcapi.GameContext;
|
||||||
|
import dev.photosync.platform.WindowTitle;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.Util;
|
import net.minecraft.Util;
|
||||||
@@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext {
|
|||||||
return minecraftVersion;
|
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
|
@Override
|
||||||
public void quit() {
|
public void quit() {
|
||||||
Minecraft.getInstance().stop();
|
Minecraft.getInstance().stop();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
|
|||||||
|
|
||||||
import dev.photosync.core.capture.CameraPose;
|
import dev.photosync.core.capture.CameraPose;
|
||||||
import dev.photosync.mcapi.GameContext;
|
import dev.photosync.mcapi.GameContext;
|
||||||
|
import dev.photosync.platform.WindowTitle;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.Util;
|
import net.minecraft.Util;
|
||||||
@@ -88,6 +89,18 @@ public final class GameAdapter implements GameContext {
|
|||||||
return minecraftVersion;
|
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
|
@Override
|
||||||
public void quit() {
|
public void quit() {
|
||||||
Minecraft.getInstance().stop();
|
Minecraft.getInstance().stop();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package dev.photosync.platform.impl;
|
|||||||
|
|
||||||
import dev.photosync.core.capture.CameraPose;
|
import dev.photosync.core.capture.CameraPose;
|
||||||
import dev.photosync.mcapi.GameContext;
|
import dev.photosync.mcapi.GameContext;
|
||||||
|
import dev.photosync.platform.WindowTitle;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.fabricmc.loader.api.FabricLoader;
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
import net.minecraft.util.Util;
|
import net.minecraft.util.Util;
|
||||||
@@ -89,6 +90,18 @@ public final class GameAdapter implements GameContext {
|
|||||||
return minecraftVersion;
|
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
|
@Override
|
||||||
public void quit() {
|
public void quit() {
|
||||||
Minecraft.getInstance().stop();
|
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": [
|
"client": [
|
||||||
"CaptureMixin",
|
"CaptureMixin",
|
||||||
"HudMixin",
|
"HudMixin",
|
||||||
"QuitMixin"
|
"QuitMixin",
|
||||||
|
"TitleMixin"
|
||||||
],
|
],
|
||||||
"injectors": {
|
"injectors": {
|
||||||
"defaultRequire": 1
|
"defaultRequire": 1
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dev.photosync.core.config.NotificationKind;
|
|||||||
import dev.photosync.core.config.PhotoSyncConfig;
|
import dev.photosync.core.config.PhotoSyncConfig;
|
||||||
import dev.photosync.core.upload.UploadEvent;
|
import dev.photosync.core.upload.UploadEvent;
|
||||||
import dev.photosync.mcapi.ClientBridge;
|
import dev.photosync.mcapi.ClientBridge;
|
||||||
|
import dev.photosync.mcapi.GameContext;
|
||||||
import dev.photosync.mcapi.capture.ScreenshotBus;
|
import dev.photosync.mcapi.capture.ScreenshotBus;
|
||||||
import dev.photosync.mcapi.lifecycle.QuitGuard;
|
import dev.photosync.mcapi.lifecycle.QuitGuard;
|
||||||
import dev.photosync.mcapi.render.RenderBridge;
|
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());
|
log.debug("Not queuing {}: no provider configured", shot.fileName());
|
||||||
return;
|
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) {
|
private void uploadChanged(UploadEvent event) {
|
||||||
|
|||||||
@@ -114,6 +114,8 @@
|
|||||||
"photosync.notify.uploaded": "Uploaded %s",
|
"photosync.notify.uploaded": "Uploaded %s",
|
||||||
"photosync.notify.failed": "Upload failed: %s",
|
"photosync.notify.failed": "Upload failed: %s",
|
||||||
|
|
||||||
|
"photosync.description.singleplayer": "Singleplayer",
|
||||||
|
|
||||||
"photosync.provider.immich.name": "Immich",
|
"photosync.provider.immich.name": "Immich",
|
||||||
"photosync.provider.immich.endpoint": "Server URL",
|
"photosync.provider.immich.endpoint": "Server URL",
|
||||||
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
|
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
|
||||||
|
|||||||
@@ -114,6 +114,8 @@
|
|||||||
"photosync.notify.uploaded": "已上传 %s",
|
"photosync.notify.uploaded": "已上传 %s",
|
||||||
"photosync.notify.failed": "上传失败:%s",
|
"photosync.notify.failed": "上传失败:%s",
|
||||||
|
|
||||||
|
"photosync.description.singleplayer": "单人",
|
||||||
|
|
||||||
"photosync.provider.immich.name": "Immich",
|
"photosync.provider.immich.name": "Immich",
|
||||||
"photosync.provider.immich.endpoint": "服务器地址",
|
"photosync.provider.immich.endpoint": "服务器地址",
|
||||||
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
|
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ public record UploadRequest(
|
|||||||
String fileName,
|
String fileName,
|
||||||
Instant capturedAt,
|
Instant capturedAt,
|
||||||
Instant modifiedAt,
|
Instant modifiedAt,
|
||||||
AlbumRef album) {
|
AlbumRef album,
|
||||||
|
String description) {
|
||||||
|
|
||||||
public UploadRequest {
|
public UploadRequest {
|
||||||
album = album == null ? AlbumRef.library() : album;
|
album = album == null ? AlbumRef.library() : album;
|
||||||
modifiedAt = modifiedAt == null ? capturedAt : modifiedAt;
|
modifiedAt = modifiedAt == null ? capturedAt : modifiedAt;
|
||||||
|
description = description == null ? "" : description;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,19 @@ final class ImmichApi {
|
|||||||
return decode(send(request), type);
|
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)
|
<T> T upload(String path, MultipartBody body, String checksumBase64, TransferProgress progress, Type type)
|
||||||
throws ProviderException {
|
throws ProviderException {
|
||||||
HttpRequest.Builder builder = request(path, Map.of(), UPLOAD_TIMEOUT)
|
HttpRequest.Builder builder = request(path, Map.of(), UPLOAD_TIMEOUT)
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ final class ImmichDtos {
|
|||||||
record AssetMediaResponse(String id, String status) {
|
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) {
|
record BulkIds(List<String> ids) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ public final class ImmichProvider implements PhotoProvider {
|
|||||||
if (album.isPresent()) {
|
if (album.isPresent()) {
|
||||||
addToAlbum(album.get(), response.id());
|
addToAlbum(album.get(), response.id());
|
||||||
}
|
}
|
||||||
|
if (!request.description().isBlank()) {
|
||||||
|
setDescription(response.id(), request.description());
|
||||||
|
}
|
||||||
return new UploadReceipt(response.id(), outcome);
|
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. */
|
/** Album filter plus the flags that keep archived and trashed assets out of the library view. */
|
||||||
private static Map<String, String> scope(AlbumRef album) {
|
private static Map<String, String> scope(AlbumRef album) {
|
||||||
Map<String, String> query = new LinkedHashMap<>();
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public record UploadJob(
|
|||||||
Instant capturedAt,
|
Instant capturedAt,
|
||||||
CaptureOrigin origin,
|
CaptureOrigin origin,
|
||||||
String albumId,
|
String albumId,
|
||||||
|
String description,
|
||||||
UploadState state,
|
UploadState state,
|
||||||
int attempts,
|
int attempts,
|
||||||
Instant notBefore,
|
Instant notBefore,
|
||||||
@@ -48,7 +49,7 @@ public record UploadJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public UploadRequest toRequest() {
|
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. */
|
/** 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
|
* <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
|
* the player immediately quits and relaunches: the restored job and a fresh
|
||||||
* rescan would otherwise both try to upload the same file.
|
* 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();
|
String absolute = shot.file().toAbsolutePath().toString();
|
||||||
UploadJob created;
|
UploadJob created;
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
@@ -143,6 +147,7 @@ public final class UploadQueue {
|
|||||||
.capturedAt(shot.capturedAt())
|
.capturedAt(shot.capturedAt())
|
||||||
.origin(shot.origin())
|
.origin(shot.origin())
|
||||||
.albumId(album.id().orElse(""))
|
.albumId(album.id().orElse(""))
|
||||||
|
.description(description)
|
||||||
.state(UploadState.PENDING)
|
.state(UploadState.PENDING)
|
||||||
.attempts(0)
|
.attempts(0)
|
||||||
.build();
|
.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. */
|
/** The running Minecraft version, for logs and the settings screen's footer. */
|
||||||
String minecraftVersion();
|
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.
|
* Shuts the game down, having already opened {@code QuitGuard}'s gate.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user