feat(plugin): attach Steam Deck screenshots to performance submissions

The plugin can now attach up to 2 Steam Deck screenshots (Steam + R1)
to a performance entry, uploaded together in one Submit tap.

Backend:
- Screenshot upload/delete endpoints now accept API key auth via
  requireRoleWithApiKeyFallback (was session-only), so the plugin's
  x-api-key header works. The 2-screenshot limit was already enforced
  server-side (MAX_SCREENSHOTS_PER_ENTRY = 2).

Plugin Python (main.py):
- list_screenshots(): scans ~/Pictures/Screenshots/ + Steam Client/
  subfolder, skips the most_recent.jpg duplicate, returns recent shots
- upload_screenshots(): hard-caps at 2, builds multipart/form-data with
  urllib, posts to /api/performance/:id/screenshots

Plugin UI (session-form.tsx):
- New Screenshots section (max 2) with a picker listing recent Steam
  screenshots (time + size), selected shots as removable rows
- Submit flow: upload entry → get entry ID → upload selected screenshots
  → combined success message (partial-success if screenshots fail)
This commit is contained in:
2026-06-29 00:10:01 +08:00
parent 841cbfe0c6
commit b416f49e3d
4 changed files with 366 additions and 11 deletions
+19 -3
View File
@@ -2,20 +2,36 @@ import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index" import { db } from "@/lib/db/index"
import { performanceEntries, entryScreenshots, storageObjects } from "@/lib/db/schema" import { performanceEntries, entryScreenshots, storageObjects } from "@/lib/db/schema"
import { eq, sql } from "drizzle-orm" import { eq, sql } from "drizzle-orm"
import { requireRole } from "@/lib/auth/guard" import { requireAuthWithApiKeyFallback } from "@/lib/auth/api-key-guard"
import { uploadObject, deleteObject, getR2PublicUrl, isR2Configured } from "@/lib/storage" import { uploadObject, deleteObject, getR2PublicUrl, isR2Configured } from "@/lib/storage"
import { processScreenshot, isAllowedMimeType } from "@/lib/image-processing" import { processScreenshot, isAllowedMimeType } from "@/lib/image-processing"
const MAX_SCREENSHOTS_PER_ENTRY = 2 const MAX_SCREENSHOTS_PER_ENTRY = 2
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB raw const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB raw
/** Auth that accepts either a session cookie or an x-api-key header,
* then enforces a role. Used so the Decky plugin (API key) and the web
* app (session) can both upload screenshots. */
async function requireRoleWithApiKeyFallback(
headers: Headers,
roles: string[],
) {
const guard = await requireAuthWithApiKeyFallback(headers)
if (!guard.ok) return guard
const userRole = guard.user.role ?? "user"
if (!roles.includes(userRole)) {
return { ok: false as const, error: "Forbidden", status: 403 }
}
return guard
}
export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } }) export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
// ── Upload screenshots ───────────────────────────────────────── // ── Upload screenshots ─────────────────────────────────────────
.post( .post(
"/:id/screenshots", "/:id/screenshots",
async ({ params, request, set }) => { async ({ params, request, set }) => {
const guard = await requireRole(request.headers, ["user", "contributor", "admin"]) const guard = await requireRoleWithApiKeyFallback(request.headers, ["user", "contributor", "admin"])
if (!guard.ok) { if (!guard.ok) {
set.status = guard.status set.status = guard.status
return { error: guard.error } return { error: guard.error }
@@ -202,7 +218,7 @@ export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { t
.delete( .delete(
"/:id/screenshots/:sid", "/:id/screenshots/:sid",
async ({ params, request, set }) => { async ({ params, request, set }) => {
const guard = await requireRole(request.headers, ["user", "contributor", "admin"]) const guard = await requireRoleWithApiKeyFallback(request.headers, ["user", "contributor", "admin"])
if (!guard.ok) { if (!guard.ok) {
set.status = guard.status set.status = guard.status
return { error: guard.error } return { error: guard.error }
+109
View File
@@ -605,6 +605,115 @@ exec mangohud "$@"
except Exception as e: except Exception as e:
return {"success": False, "error": str(e), "status": 0} return {"success": False, "error": str(e), "status": 0}
async def list_screenshots(self, limit: int = 12) -> dict:
"""RPC: List recent Steam Deck screenshots from ~/Pictures/Screenshots/.
Returns {screenshots: [{path, name, mtime, size}], error?}.
Steam saves timestamped JPGs in a 'Steam Client' subfolder and keeps
a 'most_recent.jpg' symlink-like copy at the top level."""
import glob
import time
try:
home = os.path.expanduser("~")
base = os.path.join(home, "Pictures", "Screenshots")
patterns = [
os.path.join(base, "*.jpg"),
os.path.join(base, "*.png"),
os.path.join(base, "Steam Client", "*.jpg"),
os.path.join(base, "Steam Client", "*.png"),
]
seen = set()
files = []
for pat in patterns:
for f in glob.glob(pat):
if not os.path.isfile(f) or f in seen:
continue
# Skip the most_recent.jpg duplicate if a real timestamped
# copy exists — it's just a pointer to the latest one.
if os.path.basename(f) == "most_recent.jpg":
continue
seen.add(f)
try:
files.append({
"path": f,
"name": os.path.basename(f),
"mtime": os.path.getmtime(f),
"size": os.path.getsize(f),
})
except OSError:
continue
files.sort(key=lambda x: x["mtime"], reverse=True)
return {"screenshots": files[:limit]}
except Exception as e:
return {"screenshots": [], "error": str(e)}
async def upload_screenshots(self, entry_id: str, screenshot_paths: list, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict:
"""RPC: Upload up to 2 screenshots to a performance entry as multipart/form-data.
Returns {success: bool, uploaded: int, error?: str, status?: int}.
The server enforces the 2-screenshot limit per entry."""
import urllib.request
import urllib.error
import uuid
# Hard cap at 2 — matches the website limit
paths = [p for p in screenshot_paths if p][:2]
if not paths:
return {"success": False, "error": "No screenshots selected"}
try:
url = f"{base_url}/api/performance/{entry_id}/screenshots"
# Build a multipart/form-data body manually (urllib has no helper)
boundary = "----DeckyVaultBoundary" + uuid.uuid4().hex
encoded = b""
valid_paths = []
for p in paths:
if not os.path.exists(p):
continue
valid_paths.append(p)
with open(p, "rb") as fh:
file_bytes = fh.read()
fname = os.path.basename(p)
ext = os.path.splitext(fname)[1].lower()
mime = "image/png" if ext == ".png" else ("image/webp" if ext == ".webp" else "image/jpeg")
encoded += (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="screenshots"; filename="{fname}"\r\n'
f"Content-Type: {mime}\r\n\r\n"
).encode("utf-8")
encoded += file_bytes
encoded += b"\r\n"
encoded += f"--{boundary}--\r\n".encode("utf-8")
if not valid_paths:
return {"success": False, "error": "No readable screenshot files"}
req = urllib.request.Request(
url,
data=encoded,
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"x-api-key": api_key,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:136.0) Gecko/20100101 Firefox/136.0",
"Accept": "application/json",
},
method="POST",
)
context = _get_ssl_context()
with urllib.request.urlopen(req, timeout=30, context=context) as response:
result = json.loads(response.read().decode("utf-8"))
uploaded = len(result.get("data", [])) if isinstance(result.get("data"), list) else 0
return {"success": True, "uploaded": uploaded}
except urllib.error.HTTPError as e:
try:
err = json.loads(e.read().decode("utf-8"))
return {"success": False, "error": err.get("error", f"Server returned status {e.code}"), "status": e.code}
except Exception:
return {"success": False, "error": f"Server returned status {e.code}", "status": e.code}
except urllib.error.URLError as e:
return {"success": False, "error": f"Network error: {str(e.reason)}", "status": 0}
except Exception as e:
return {"success": False, "error": str(e), "status": 0}
async def test_api_key(self, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict: async def test_api_key(self, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict:
"""RPC: Test if an API key is valid by calling the dedicated verify endpoint. """RPC: Test if an API key is valid by calling the dedicated verify endpoint.
Returns {valid: bool, error: str?, userName?: str, userImage?: str}.""" Returns {valid: bool, error: str?, userName?: str, userImage?: str}."""
@@ -5,7 +5,6 @@ import {
PanelSectionRow, PanelSectionRow,
DropdownItem, DropdownItem,
TextField, TextField,
Field,
staticClasses, staticClasses,
} from "@decky/ui" } from "@decky/ui"
import { import {
@@ -13,11 +12,20 @@ import {
FaCloudUploadAlt, FaCloudUploadAlt,
FaCheck, FaCheck,
FaTimes, FaTimes,
FaImages,
FaPlus,
FaTrash,
FaSpinner,
} from "react-icons/fa" } from "react-icons/fa"
import type { SessionData } from "../lib/store" import type { SessionData } from "../lib/store"
import { buildImportPayload } from "../lib/store" import { buildImportPayload } from "../lib/store"
import type { PluginSettings } from "../lib/store" import type { PluginSettings } from "../lib/store"
import { exportToFile, uploadToDeckyvault } from "../lib/api" import {
exportToFile,
uploadToDeckyvault,
listScreenshots,
uploadScreenshots,
} from "../lib/api"
interface SessionFormProps { interface SessionFormProps {
session: SessionData session: SessionData
@@ -46,6 +54,29 @@ const FRAME_GEN_OPTIONS = [
{ label: "Other", data: "other" }, { label: "Other", data: "other" },
] ]
const MAX_SCREENSHOTS = 2
interface ScreenshotFile {
path: string
name: string
mtime: number
size: number
}
function formatShotTime(mtime: number): string {
const d = new Date(mtime * 1000)
const today = new Date()
const isToday = d.toDateString() === today.toDateString()
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
return isToday ? `Today ${time}` : `${d.toLocaleDateString()} ${time}`
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export default function SessionForm({ export default function SessionForm({
session, session,
error, error,
@@ -59,6 +90,41 @@ export default function SessionForm({
const [uploadStatus, setUploadStatus] = useState<"idle" | "loading" | "success" | "error">("idle") const [uploadStatus, setUploadStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
const [statusMessage, setStatusMessage] = useState("") const [statusMessage, setStatusMessage] = useState("")
// ── Screenshot picker state ──────────────────────────────
const [selectedShots, setSelectedShots] = useState<ScreenshotFile[]>([])
const [pickerOpen, setPickerOpen] = useState(false)
const [availableShots, setAvailableShots] = useState<ScreenshotFile[]>([])
const [shotsLoading, setShotsLoading] = useState(false)
const [shotsError, setShotsError] = useState("")
async function openPicker() {
if (selectedShots.length >= MAX_SCREENSHOTS) return
setPickerOpen(true)
setShotsLoading(true)
setShotsError("")
const result = await listScreenshots(12)
setShotsLoading(false)
if (result.error) {
setShotsError(result.error)
}
// Filter out already-selected paths
setAvailableShots(result.screenshots.filter((s) => !selectedShots.some((sel) => sel.path === s.path)))
}
function addShot(shot: ScreenshotFile) {
if (selectedShots.length >= MAX_SCREENSHOTS) return
setSelectedShots((prev) => [...prev, shot])
setAvailableShots((prev) => prev.filter((s) => s.path !== shot.path))
if (selectedShots.length + 1 >= MAX_SCREENSHOTS) {
setPickerOpen(false)
}
}
function removeShot(shot: ScreenshotFile) {
setSelectedShots((prev) => prev.filter((s) => s.path !== shot.path))
setAvailableShots((prev) => [...prev, shot].sort((a, b) => b.mtime - a.mtime))
}
async function handleExport() { async function handleExport() {
if (!settings) return if (!settings) return
setError("") setError("")
@@ -88,7 +154,7 @@ export default function SessionForm({
} }
setError("") setError("")
setUploadStatus("loading") setUploadStatus("loading")
setStatusMessage("") setStatusMessage("Uploading entry…")
const payload = buildImportPayload(session) const payload = buildImportPayload(session)
const result = await uploadToDeckyvault( const result = await uploadToDeckyvault(
@@ -97,17 +163,41 @@ export default function SessionForm({
settings.baseUrl, settings.baseUrl,
) )
if (result.success) { if (!result.success) {
setUploadStatus("success")
setStatusMessage(`Uploaded! Entry ID: ${result.data?.id}`)
onAddToRecent(session)
} else {
setUploadStatus("error") setUploadStatus("error")
setStatusMessage(result.error || "Upload failed") setStatusMessage(result.error || "Upload failed")
if (result.status === 404) { if (result.status === 404) {
setStatusMessage("This game isn't in DeckyVault yet. Submit it on the website first, or export to file.") setStatusMessage("This game isn't in DeckyVault yet. Submit it on the website first, or export to file.")
} }
return
} }
const entryId = result.data?.id
// ── Upload screenshots if any are selected ───────────────
if (selectedShots.length > 0 && entryId) {
setStatusMessage(`Entry uploaded. Adding ${selectedShots.length} screenshot${selectedShots.length > 1 ? "s" : ""}`)
const shotResult = await uploadScreenshots(
entryId,
selectedShots.map((s) => s.path),
settings.apiKey,
settings.baseUrl,
)
if (shotResult.success) {
setUploadStatus("success")
const n = shotResult.uploaded ?? selectedShots.length
setStatusMessage(`Uploaded! Entry + ${n} screenshot${n > 1 ? "s" : ""}`)
} else {
// Entry succeeded but screenshots failed — still a partial success
setUploadStatus("success")
setStatusMessage(`Entry uploaded (ID: ${entryId}). Screenshots failed: ${shotResult.error}`)
}
} else {
setUploadStatus("success")
setStatusMessage(`Uploaded! Entry ID: ${entryId}`)
}
onAddToRecent(session)
} }
return ( return (
@@ -195,6 +285,129 @@ export default function SessionForm({
</PanelSectionRow> </PanelSectionRow>
</PanelSection> </PanelSection>
{/* ── Screenshots (max 2) ────────────────────────────────── */}
<PanelSection title={`Screenshots${selectedShots.length > 0 ? ` (${selectedShots.length}/${MAX_SCREENSHOTS})` : ""}`}>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "2px 0", opacity: 0.6, lineHeight: 1.4 }}>
Attach up to {MAX_SCREENSHOTS} Steam Deck screenshots (Steam + R1). They upload with your entry.
</div>
</PanelSectionRow>
{/* Selected screenshots */}
{selectedShots.map((shot) => (
<PanelSectionRow key={shot.path}>
<div style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 10px",
borderRadius: "6px",
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.10)",
}}>
<FaImages style={{ opacity: 0.6, flexShrink: 0 }} />
<div className={staticClasses.Text} style={{ flex: 1, minWidth: 0, fontSize: "12px" }}>
<div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{shot.name}
</div>
<div style={{ opacity: 0.5, fontSize: "11px" }}>
{formatShotTime(shot.mtime)} · {formatSize(shot.size)}
</div>
</div>
<button
onClick={() => removeShot(shot)}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "#e74c3c",
padding: "4px",
flexShrink: 0,
}}
title="Remove"
>
<FaTrash />
</button>
</div>
</PanelSectionRow>
))}
{/* Add button (hidden when at max) */}
{selectedShots.length < MAX_SCREENSHOTS && !pickerOpen && (
<PanelSectionRow>
<ButtonItem layout="below" onClick={openPicker}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
<FaPlus />
Add Screenshot
</div>
</ButtonItem>
</PanelSectionRow>
)}
{/* Picker: list of recent Steam screenshots */}
{pickerOpen && (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.5, padding: "4px 0", textTransform: "uppercase", letterSpacing: "0.04em" }}>
Recent screenshots
</div>
</PanelSectionRow>
{shotsLoading && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "8px 0", textAlign: "center", fontSize: "13px", opacity: 0.7, display: "flex", alignItems: "center", justifyContent: "center", gap: "8px" }}>
<FaSpinner className="fa-spin" /> Loading
</div>
</PanelSectionRow>
)}
{shotsError && !shotsLoading && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", color: "#e74c3c", padding: "4px 0" }}>
{shotsError}
</div>
</PanelSectionRow>
)}
{!shotsLoading && !shotsError && availableShots.length === 0 && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.5, padding: "8px 0", textAlign: "center" }}>
No screenshots found. Take one with Steam + R1.
</div>
</PanelSectionRow>
)}
{!shotsLoading && availableShots.map((shot) => (
<PanelSectionRow key={shot.path}>
<ButtonItem layout="below" onClick={() => addShot(shot)}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaImages style={{ opacity: 0.6, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0, textAlign: "left" }}>
<div style={{ fontSize: "12px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{formatShotTime(shot.mtime)}
</div>
<div style={{ fontSize: "11px", opacity: 0.6 }}>
{formatSize(shot.size)}
</div>
</div>
<FaPlus style={{ opacity: 0.7, flexShrink: 0 }} />
</div>
</ButtonItem>
</PanelSectionRow>
))}
<PanelSectionRow>
<ButtonItem layout="below" onClick={() => setPickerOpen(false)}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
<FaTimes />
Cancel
</div>
</ButtonItem>
</PanelSectionRow>
</>
)}
</PanelSection>
{/* ── Error display ──────────────────────────────────────── */} {/* ── Error display ──────────────────────────────────────── */}
{error && ( {error && (
<PanelSectionRow> <PanelSectionRow>
+17
View File
@@ -81,6 +81,23 @@ export const uploadToDeckyvault = callable<[
status?: number status?: number
}>("upload_to_deckyvault") }>("upload_to_deckyvault")
export const listScreenshots = callable<[limit?: number], {
screenshots: Array<{ path: string; name: string; mtime: number; size: number }>
error?: string
}>("list_screenshots")
export const uploadScreenshots = callable<[
entryId: string,
screenshotPaths: string[],
apiKey: string,
baseUrl?: string
], {
success: boolean
uploaded?: number
error?: string
status?: number
}>("upload_screenshots")
export const testApiKey = callable<[apiKey: string, baseUrl?: string], { export const testApiKey = callable<[apiKey: string, baseUrl?: string], {
valid: boolean valid: boolean
error?: string error?: string