From 1e3fad9a8ff8f43bc4d9fb34ce93d8f5bae75ece Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 4 Jul 2026 05:21:50 +0200 Subject: [PATCH] feat(downloads): edit + remove actions on shared-with-me items (no re-share) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared-with-me item only had a download button. Add two actions (Share is intentionally NOT offered — no chain re-sharing of someone else's file): - Edit: the editor now accepts an accessible (owned OR shared) source, so editing a shared video produces the editor's OWN clip in their library (counts against their quota, fully theirs including share); the source file is only read. Route uses _accessible_job. - Remove from my list: DELETE /api/downloads/shared/{job_id} deletes only the recipient's share grant — the owner's job and physical file are untouched (per-user dismissal). i18n en/hu/de. Verified in a real browser (edit a shared 70-min video → own 2:31 clip; remove confirm shows 'won't delete the owner's file'). --- backend/app/routes/downloads.py | 26 +++++++++++++++++++-- frontend/src/components/DownloadCenter.tsx | 24 +++++++++++++++++++ frontend/src/i18n/locales/de/downloads.json | 7 ++++-- frontend/src/i18n/locales/en/downloads.json | 7 ++++-- frontend/src/i18n/locales/hu/downloads.json | 7 ++++-- frontend/src/lib/api.ts | 3 +++ 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index ee669e0..783eddb 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -258,8 +258,12 @@ def enqueue_download( def enqueue_edit( payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: - """Queue a phase-2 editor derivative (trim/crop) of one of the user's finished downloads.""" - source_job = _own_job(db, user, int(payload.get("source_job_id") or 0)) + """Queue a phase-2 editor derivative (trim/crop) of a download this user can access. + + The source may be the user's own download OR one shared with them — either way the resulting + clip is a NEW per-user derivative in the editor's own library (counts against their quota); the + source file is only read, never modified, so editing a shared video is safe.""" + source_job = _accessible_job(db, user, int(payload.get("source_job_id") or 0)) if source_job.status != "done" or source_job.asset_id is None: raise HTTPException(status_code=409, detail="You can only edit a finished download.") spec = payload.get("edit_spec") @@ -619,6 +623,24 @@ def unshare_download( return {"ok": True} +@router.delete("/shared/{job_id}") +def remove_shared_with_me( + job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) +) -> dict: + """Remove a download that was shared WITH this user from their 'Shared with me' list. Deletes + only the recipient's share grant — the owner's job and the physical file are untouched (this is + a per-user dismissal, not a delete).""" + row = db.execute( + select(DownloadShare).where( + DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id + ) + ).scalar_one_or_none() + if row is not None: + db.delete(row) + db.commit() + return {"removed": job_id} + + # --- recipients (for the internal "share with a user" picker) ------------------------------ @router.get("/recipients") diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 074fff0..bc29a71 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -428,6 +428,12 @@ export default function DownloadCenter({ me }: { me: Me }) { onSuccess: invalidate, }); + // Remove a shared-with-me item from your list (only your grant; the owner's file is untouched). + const removeShared = useMutation({ + mutationFn: (id: number) => api.removeSharedDownload(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["downloads-shared"] }), + }); + const confirmThen = async (title: string, body: string, fn: () => void) => { if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn(); }; @@ -545,6 +551,24 @@ export default function DownloadCenter({ me }: { me: Me }) { {(sharedQ.data ?? []).map((job) => ( {job.can_download && saveBtn(job)} + {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( + setEditJob(job)} title={t("downloads.actions.edit")}> + + + )} + + confirmThen( + t("downloads.confirm.removeSharedTitle"), + t("downloads.confirm.removeSharedBody"), + () => removeShared.mutate(job.id) + ) + } + title={t("downloads.actions.removeShared")} + danger + > + + ))} {sharedQ.data && sharedQ.data.length === 0 && ( diff --git a/frontend/src/i18n/locales/de/downloads.json b/frontend/src/i18n/locales/de/downloads.json index 13cc467..0054699 100644 --- a/frontend/src/i18n/locales/de/downloads.json +++ b/frontend/src/i18n/locales/de/downloads.json @@ -57,7 +57,8 @@ "download": "Auf mein Gerät speichern", "rename": "Umbenennen", "share": "Teilen", - "edit": "Bearbeiten / schneiden" + "edit": "Bearbeiten / schneiden", + "removeShared": "Aus meiner Liste entfernen" }, "empty": { "queue": "Die Warteschlange ist leer.", @@ -110,7 +111,9 @@ "cancelTitle": "Download abbrechen?", "cancelBody": "Dies stoppt den Download und entfernt ihn aus deiner Warteschlange.", "deleteTitle": "Download entfernen?", - "deleteBody": "Dies entfernt ihn aus deiner Liste. Die Datei kann bis zum Ablauf für andere Nutzer im Cache bleiben." + "deleteBody": "Dies entfernt ihn aus deiner Liste. Die Datei kann bis zum Ablauf für andere Nutzer im Cache bleiben.", + "removeSharedTitle": "Aus deiner Liste entfernen?", + "removeSharedBody": "Entfernt es nur aus deiner geteilten Liste — die Datei des Besitzers wird nicht gelöscht." }, "profiles": { "manage": "Formate verwalten", diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index f96cd82..06db1f6 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -57,7 +57,8 @@ "download": "Save to my device", "rename": "Rename", "share": "Share", - "edit": "Edit / trim" + "edit": "Edit / trim", + "removeShared": "Remove from my list" }, "empty": { "queue": "Nothing in the queue.", @@ -110,7 +111,9 @@ "cancelTitle": "Cancel download?", "cancelBody": "This stops the download and removes it from your queue.", "deleteTitle": "Remove download?", - "deleteBody": "This removes it from your list. The file may stay cached for other users until it expires." + "deleteBody": "This removes it from your list. The file may stay cached for other users until it expires.", + "removeSharedTitle": "Remove from your list?", + "removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file." }, "profiles": { "manage": "Manage formats", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 1a097ea..4091429 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -57,7 +57,8 @@ "download": "Mentés a gépemre", "rename": "Átnevezés", "share": "Megosztás", - "edit": "Szerkesztés / vágás" + "edit": "Szerkesztés / vágás", + "removeShared": "Eltávolítás a listámból" }, "empty": { "queue": "A sor üres.", @@ -110,7 +111,9 @@ "cancelTitle": "Megszakítod a letöltést?", "cancelBody": "Ez leállítja a letöltést és eltávolítja a sorból.", "deleteTitle": "Eltávolítod a letöltést?", - "deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára." + "deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára.", + "removeSharedTitle": "Eltávolítod a listádból?", + "removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli." }, "profiles": { "manage": "Formátumok kezelése", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e7ae4bd..0e60943 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1075,6 +1075,9 @@ export const api = { req(`/api/downloads/${id}/storyboard`), downloads: (): Promise => req("/api/downloads"), downloadsShared: (): Promise => req("/api/downloads/shared"), + // Remove a shared-with-me item from your list (deletes only your share grant, not the file). + removeSharedDownload: (jobId: number): Promise<{ removed: number }> => + req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }), downloadUsage: (): Promise => req("/api/downloads/usage"), downloadIndex: (): Promise> => req("/api/downloads/index"), pauseDownload: (id: number): Promise =>