hlassiege/darylpublic Fork
⑂ 326702f
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(overview): show and purge a project's unused files

List what no longer serves in the project folder (assets off the timeline,
renders of removed motions, orphan motion media, derush exports and caches,
removed rushes and sources) and delete it from the overview page.
hlassiege committed 2026-10-24T17:51:39+02:00 Browse files
326702f parent: 8effbe8
modified backend/studio/modules/projects/api.py +15 -1
@@ -16,7 +16,7 @@ from pydantic import BaseModel
1616 from ...core import jobs, system
1717 from ..animations import kit
1818 from ..youtube import service as youtube
19-from . import (asset_tasks, assets, autoderush, derush, kit_adapt, library, migrate, montage, publish, render, roast, runtime, script,
19+from . import (asset_tasks, assets, autoderush, cleanup, derush, kit_adapt, library, migrate, montage, publish, render, roast, runtime, script,
2020 service, storyboard, storyboard_chat, thumbs, timeline, transitions)
2121 from . import beat_anim
2222
@@ -295,6 +295,20 @@ def storage(pid: str):
295295 "categories": [{"key": k, "bytes": v} for k, v in sizes.items()]}
296296
297297
298+@router.get("/{pid}/storage/unused")
299+def unused_storage(pid: str):
300+ """Ce qui ne sert plus dans le dossier du projet (rendus de motions retirés, rushes retirés…), par catégorie."""
301+ _check(pid)
302+ return cleanup.summary(pid)
303+
304+
305+@router.post("/{pid}/storage/purge")
306+def purge_storage(pid: str):
307+ _check(pid)
308+ freed = cleanup.purge(pid)
309+ return {"freed": freed, "unused": cleanup.summary(pid)}
310+
311+
298312 @router.delete("/{pid}")
299313 def remove(pid: str):
300314 _check(pid)
@@ -16,7 +16,7 @@ from pydantic import BaseModel
16 from ...core import jobs, system16 from ...core import jobs, system
17 from ..animations import kit17 from ..animations import kit
18 from ..youtube import service as youtube18 from ..youtube import service as youtube
19-from . import (asset_tasks, assets, autoderush, derush, kit_adapt, library, migrate, montage, publish, render, roast, runtime, script,19+from . import (asset_tasks, assets, autoderush, cleanup, derush, kit_adapt, library, migrate, montage, publish, render, roast, runtime, script,
20 service, storyboard, storyboard_chat, thumbs, timeline, transitions)20 service, storyboard, storyboard_chat, thumbs, timeline, transitions)
21 from . import beat_anim21 from . import beat_anim
22 22
@@ -295,6 +295,20 @@ def storage(pid: str):
295 "categories": [{"key": k, "bytes": v} for k, v in sizes.items()]}295 "categories": [{"key": k, "bytes": v} for k, v in sizes.items()]}
296 296
297 297
298+@router.get("/{pid}/storage/unused")
299+def unused_storage(pid: str):
300+ """Ce qui ne sert plus dans le dossier du projet (rendus de motions retirés, rushes retirés…), par catégorie."""
301+ _check(pid)
302+ return cleanup.summary(pid)
303+
304+
305+@router.post("/{pid}/storage/purge")
306+def purge_storage(pid: str):
307+ _check(pid)
308+ freed = cleanup.purge(pid)
309+ return {"freed": freed, "unused": cleanup.summary(pid)}
310+
311+
298 @router.delete("/{pid}")312 @router.delete("/{pid}")
299 def remove(pid: str):313 def remove(pid: str):
300 _check(pid)314 _check(pid)
added backend/studio/modules/projects/cleanup.py +171 -0
new file mode 100644
@@ -0,0 +1,171 @@
1+"""
2+Ce qui ne sert plus dans le dossier d'un projet, et sa purge :
3+
4+ assets/ un B-roll, une image, une musique, un son que ni la timeline ni une composition n'utilise,
5+ sauf s'il est prévu sur un beat du storyboard ou capturé depuis l'onglet Médias ; un clip
6+ de l'historique qui le posait ne revient plus en annulant
7+ montage/motion/renders/ le rendu d'un motion retiré du montage (ou d'une version de sa composition qui n'est plus
8+ la sienne) ; refait de lui-même si le motion revient (annuler)
9+ montage/motion/media/ un média qu'aucune composition ne pose plus, ni dans l'historique ni chez Daryl
10+ derush/ l'export d'une timeline supprimée, les niveaux et la vignette d'un rush retiré
11+ rushes/, source/ une vidéo que le projet n'utilise plus (rush retiré, source remplacée)
12+
13+Par prudence, hors assets/, rien de ce que nomment project.json, la timeline, le storyboard, l'historique ou les copies
14+de travail de Daryl n'est compté, ni un fichier qu'une copie en cours remplit.
15+"""
16+import html
17+import json
18+import re
19+from pathlib import Path
20+from urllib.parse import unquote
21+
22+from ...core import jobs
23+from . import assets, montage, service
24+
25+ORDER = ("assets", "motion_renders", "motion_media", "derush", "videos")
26+# les caches du dérush d'un rush : levels-<rush>-<clé>.json, peaks-…, thumb-<rush>-<clé>.jpg
27+_RUSH_CACHE = re.compile(r"(?:levels|peaks|thumb)-([^-]+)-.+")
28+_TIMELINE_EXPORT = re.compile(r"derush-(.+)\.mp4")
29+
30+
31+# un média posé par une composition : src="media/…", data-from='media/…', url(media/…) ; un nom peut avoir des
32+# apostrophes ou des parenthèses (« Icône FilmCraft (équivalent de Premiere) - icône d'appli.webp »)
33+_MEDIA_REF = re.compile(rb""""media/([^"<>]+)"|'media/([^'<>]+)'|\(\s*media/([^"'()<>]+)\)""")
34+# ce qu'on a déjà lu d'un fichier, tant qu'il ne change pas : l'historique ne bouge plus une fois écrit
35+_read_cache: dict[tuple[Path, str], tuple[tuple[int, int], object]] = {}
36+
37+
38+def _cached(path: Path, parse):
39+ try:
40+ st = path.stat()
41+ key = (st.st_mtime_ns, st.st_size)
42+ hit = _read_cache.get((path, parse.__name__))
43+ if hit and hit[0] == key:
44+ return hit[1]
45+ value = parse(path.read_bytes())
46+ except (OSError, ValueError):
47+ return None
48+ _read_cache[path, parse.__name__] = (key, value)
49+ return value
50+
51+
52+def _state_text(raw: bytes) -> str:
53+ # les chemins Windows (« C:\\Dev\\… ») s'écrivent comme les autres, avec des /
54+ return json.dumps(json.loads(raw), ensure_ascii=False).replace("\\\\", "/")
55+
56+
57+def _media_names(raw: bytes) -> set[str]:
58+ return {unquote(html.unescape(m.decode("utf-8", "replace")))
59+ for groups in _MEDIA_REF.findall(raw) for m in groups if m}
60+
61+
62+def _page_text(raw: bytes) -> str:
63+ return unquote(html.unescape(raw.decode("utf-8", "replace")))
64+
65+
66+def _texts(paths, parse) -> str:
67+ return "\n".join(t for p in paths if (t := _cached(p, parse)))
68+
69+
70+def _references(pid: str) -> tuple[str, str, set[str]]:
71+ """Tout ce que nomment l'état du projet (ses .json, en texte, historique compris) ; ce qui garde un média de
72+ assets/ (l'état sans l'historique, et les compositions) ; les médias que posent les compositions (historique
73+ compris)."""
74+ directory = service.project_dir(pid)
75+ work = montage.root(pid) / montage.WORK_DIR
76+ history = montage.root(pid) / montage.HISTORY_DIR
77+ current = [directory / service.PROJECT_FILE, montage.root(pid) / montage.TIMELINE_FILE,
78+ montage.root(pid) / "storyboard.json", *work.rglob("*.json")]
79+ current_text = _texts(current, _state_text)
80+ text = current_text + "\n" + _texts(history.glob(f"*/{montage.TIMELINE_FILE}"), _state_text)
81+ pages = [*montage.motion_dir(pid).glob("*.html"), *work.rglob("*.html")]
82+ keep_assets = current_text + "\n" + _texts(pages, _page_text)
83+ media: set[str] = set()
84+ for path in [*pages, *history.glob(f"*/{montage.MOTION_DIR}/*.html")]:
85+ media |= _cached(path, _media_names) or set()
86+ return text, keep_assets, media
87+
88+
89+def _copying(project: dict) -> set[Path]:
90+ """Les fichiers que remplit une copie pas encore terminée."""
91+ out = set()
92+ for job_id in project.get("copies", []):
93+ job = jobs.get(job_id)
94+ if job and not job.finished:
95+ out |= {Path(f["dest"]).resolve() for f in job.state.get("files", [])}
96+ return out
97+
98+
99+def _files(folder: Path) -> list[Path]:
100+ return [p for p in folder.iterdir() if p.is_file()] if folder.is_dir() else []
101+
102+
103+def unused(pid: str) -> dict[str, list[Path]]:
104+ """Les fichiers qui ne servent plus, par catégorie (dans l'ordre de ORDER)."""
105+ directory = service.project_dir(pid)
106+ project = service.load(pid)
107+ state, keep_assets, media = _references(pid)
108+ copying = _copying(project)
109+ out: dict[str, list[Path]] = {k: [] for k in ORDER}
110+
111+ def named(path: Path) -> bool:
112+ return path.relative_to(directory).as_posix() in state or path.resolve() in copying
113+
114+ folder = directory / service.ASSETS_DIR
115+ for path in folder.rglob("*") if folder.is_dir() else []:
116+ # les images proposées attendent qu'on choisisse
117+ if path.is_file() and assets.CANDIDATES_DIR not in path.parts and path.resolve() not in copying \
118+ and path.relative_to(directory).as_posix() not in keep_assets:
119+ out["assets"].append(path)
120+
121+ timeline = montage.load(pid)
122+ renders = {montage.motion_render_file(pid, m["id"], sig).name
123+ for m in timeline["motion"] if (sig := montage.motion_signature(pid, m))}
124+ for path in _files(montage.motion_dir(pid) / montage.RENDERS_DIR):
125+ if path.suffix == ".mov" and path.name not in renders and not named(path):
126+ out["motion_renders"].append(path)
127+
128+ for path in _files(montage.motion_dir(pid) / montage.MEDIA_DIR):
129+ if path.name not in media and path.name not in state:
130+ out["motion_media"].append(path)
131+
132+ rushes = {r["id"] for r in project["rushes"]}
133+ timelines = {t["id"] for t in project["derush"]["timelines"]}
134+ for path in _files(directory / service.DERUSH_DIR):
135+ cache, export = _RUSH_CACHE.fullmatch(path.name), _TIMELINE_EXPORT.fullmatch(path.name)
136+ if (cache and cache.group(1) not in rushes or export and export.group(1) not in timelines) and not named(path):
137+ out["derush"].append(path)
138+
139+ for folder in (service.RUSHES_DIR, service.SOURCE_DIR):
140+ for path in _files(directory / folder):
141+ if not named(path):
142+ out["videos"].append(path)
143+ return out
144+
145+
146+def _size(path: Path) -> int:
147+ try:
148+ return path.stat().st_size
149+ except OSError:
150+ return 0
151+
152+
153+def summary(pid: str) -> dict:
154+ found = unused(pid)
155+ categories = [{"key": k, "count": len(found[k]), "bytes": sum(_size(p) for p in found[k])} for k in ORDER]
156+ return {"total": sum(c["bytes"] for c in categories), "categories": categories}
157+
158+
159+def purge(pid: str) -> int:
160+ """Supprime ce qui ne sert plus ; rend les octets libérés (un fichier encore ouvert reste, sans erreur)."""
161+ freed = 0
162+ for paths in unused(pid).values():
163+ for path in paths:
164+ size = _size(path)
165+ try:
166+ path.unlink()
167+ except OSError:
168+ continue
169+ freed += size
170+ service.forget_storage(pid)
171+ return freed
new file mode 100644
@@ -0,0 +1,171 @@
1+"""
2+Ce qui ne sert plus dans le dossier d'un projet, et sa purge :
3+
4+ assets/ un B-roll, une image, une musique, un son que ni la timeline ni une composition n'utilise,
5+ sauf s'il est prévu sur un beat du storyboard ou capturé depuis l'onglet Médias ; un clip
6+ de l'historique qui le posait ne revient plus en annulant
7+ montage/motion/renders/ le rendu d'un motion retiré du montage (ou d'une version de sa composition qui n'est plus
8+ la sienne) ; refait de lui-même si le motion revient (annuler)
9+ montage/motion/media/ un média qu'aucune composition ne pose plus, ni dans l'historique ni chez Daryl
10+ derush/ l'export d'une timeline supprimée, les niveaux et la vignette d'un rush retiré
11+ rushes/, source/ une vidéo que le projet n'utilise plus (rush retiré, source remplacée)
12+
13+Par prudence, hors assets/, rien de ce que nomment project.json, la timeline, le storyboard, l'historique ou les copies
14+de travail de Daryl n'est compté, ni un fichier qu'une copie en cours remplit.
15+"""
16+import html
17+import json
18+import re
19+from pathlib import Path
20+from urllib.parse import unquote
21+
22+from ...core import jobs
23+from . import assets, montage, service
24+
25+ORDER = ("assets", "motion_renders", "motion_media", "derush", "videos")
26+# les caches du dérush d'un rush : levels-<rush>-<clé>.json, peaks-…, thumb-<rush>-<clé>.jpg
27+_RUSH_CACHE = re.compile(r"(?:levels|peaks|thumb)-([^-]+)-.+")
28+_TIMELINE_EXPORT = re.compile(r"derush-(.+)\.mp4")
29+
30+
31+# un média posé par une composition : src="media/…", data-from='media/…', url(media/…) ; un nom peut avoir des
32+# apostrophes ou des parenthèses (« Icône FilmCraft (équivalent de Premiere) - icône d'appli.webp »)
33+_MEDIA_REF = re.compile(rb""""media/([^"<>]+)"|'media/([^'<>]+)'|\(\s*media/([^"'()<>]+)\)""")
34+# ce qu'on a déjà lu d'un fichier, tant qu'il ne change pas : l'historique ne bouge plus une fois écrit
35+_read_cache: dict[tuple[Path, str], tuple[tuple[int, int], object]] = {}
36+
37+
38+def _cached(path: Path, parse):
39+ try:
40+ st = path.stat()
41+ key = (st.st_mtime_ns, st.st_size)
42+ hit = _read_cache.get((path, parse.__name__))
43+ if hit and hit[0] == key:
44+ return hit[1]
45+ value = parse(path.read_bytes())
46+ except (OSError, ValueError):
47+ return None
48+ _read_cache[path, parse.__name__] = (key, value)
49+ return value
50+
51+
52+def _state_text(raw: bytes) -> str:
53+ # les chemins Windows (« C:\\Dev\\… ») s'écrivent comme les autres, avec des /
54+ return json.dumps(json.loads(raw), ensure_ascii=False).replace("\\\\", "/")
55+
56+
57+def _media_names(raw: bytes) -> set[str]:
58+ return {unquote(html.unescape(m.decode("utf-8", "replace")))
59+ for groups in _MEDIA_REF.findall(raw) for m in groups if m}
60+
61+
62+def _page_text(raw: bytes) -> str:
63+ return unquote(html.unescape(raw.decode("utf-8", "replace")))
64+
65+
66+def _texts(paths, parse) -> str:
67+ return "\n".join(t for p in paths if (t := _cached(p, parse)))
68+
69+
70+def _references(pid: str) -> tuple[str, str, set[str]]:
71+ """Tout ce que nomment l'état du projet (ses .json, en texte, historique compris) ; ce qui garde un média de
72+ assets/ (l'état sans l'historique, et les compositions) ; les médias que posent les compositions (historique
73+ compris)."""
74+ directory = service.project_dir(pid)
75+ work = montage.root(pid) / montage.WORK_DIR
76+ history = montage.root(pid) / montage.HISTORY_DIR
77+ current = [directory / service.PROJECT_FILE, montage.root(pid) / montage.TIMELINE_FILE,
78+ montage.root(pid) / "storyboard.json", *work.rglob("*.json")]
79+ current_text = _texts(current, _state_text)
80+ text = current_text + "\n" + _texts(history.glob(f"*/{montage.TIMELINE_FILE}"), _state_text)
81+ pages = [*montage.motion_dir(pid).glob("*.html"), *work.rglob("*.html")]
82+ keep_assets = current_text + "\n" + _texts(pages, _page_text)
83+ media: set[str] = set()
84+ for path in [*pages, *history.glob(f"*/{montage.MOTION_DIR}/*.html")]:
85+ media |= _cached(path, _media_names) or set()
86+ return text, keep_assets, media
87+
88+
89+def _copying(project: dict) -> set[Path]:
90+ """Les fichiers que remplit une copie pas encore terminée."""
91+ out = set()
92+ for job_id in project.get("copies", []):
93+ job = jobs.get(job_id)
94+ if job and not job.finished:
95+ out |= {Path(f["dest"]).resolve() for f in job.state.get("files", [])}
96+ return out
97+
98+
99+def _files(folder: Path) -> list[Path]:
100+ return [p for p in folder.iterdir() if p.is_file()] if folder.is_dir() else []
101+
102+
103+def unused(pid: str) -> dict[str, list[Path]]:
104+ """Les fichiers qui ne servent plus, par catégorie (dans l'ordre de ORDER)."""
105+ directory = service.project_dir(pid)
106+ project = service.load(pid)
107+ state, keep_assets, media = _references(pid)
108+ copying = _copying(project)
109+ out: dict[str, list[Path]] = {k: [] for k in ORDER}
110+
111+ def named(path: Path) -> bool:
112+ return path.relative_to(directory).as_posix() in state or path.resolve() in copying
113+
114+ folder = directory / service.ASSETS_DIR
115+ for path in folder.rglob("*") if folder.is_dir() else []:
116+ # les images proposées attendent qu'on choisisse
117+ if path.is_file() and assets.CANDIDATES_DIR not in path.parts and path.resolve() not in copying \
118+ and path.relative_to(directory).as_posix() not in keep_assets:
119+ out["assets"].append(path)
120+
121+ timeline = montage.load(pid)
122+ renders = {montage.motion_render_file(pid, m["id"], sig).name
123+ for m in timeline["motion"] if (sig := montage.motion_signature(pid, m))}
124+ for path in _files(montage.motion_dir(pid) / montage.RENDERS_DIR):
125+ if path.suffix == ".mov" and path.name not in renders and not named(path):
126+ out["motion_renders"].append(path)
127+
128+ for path in _files(montage.motion_dir(pid) / montage.MEDIA_DIR):
129+ if path.name not in media and path.name not in state:
130+ out["motion_media"].append(path)
131+
132+ rushes = {r["id"] for r in project["rushes"]}
133+ timelines = {t["id"] for t in project["derush"]["timelines"]}
134+ for path in _files(directory / service.DERUSH_DIR):
135+ cache, export = _RUSH_CACHE.fullmatch(path.name), _TIMELINE_EXPORT.fullmatch(path.name)
136+ if (cache and cache.group(1) not in rushes or export and export.group(1) not in timelines) and not named(path):
137+ out["derush"].append(path)
138+
139+ for folder in (service.RUSHES_DIR, service.SOURCE_DIR):
140+ for path in _files(directory / folder):
141+ if not named(path):
142+ out["videos"].append(path)
143+ return out
144+
145+
146+def _size(path: Path) -> int:
147+ try:
148+ return path.stat().st_size
149+ except OSError:
150+ return 0
151+
152+
153+def summary(pid: str) -> dict:
154+ found = unused(pid)
155+ categories = [{"key": k, "count": len(found[k]), "bytes": sum(_size(p) for p in found[k])} for k in ORDER]
156+ return {"total": sum(c["bytes"] for c in categories), "categories": categories}
157+
158+
159+def purge(pid: str) -> int:
160+ """Supprime ce qui ne sert plus ; rend les octets libérés (un fichier encore ouvert reste, sans erreur)."""
161+ freed = 0
162+ for paths in unused(pid).values():
163+ for path in paths:
164+ size = _size(path)
165+ try:
166+ path.unlink()
167+ except OSError:
168+ continue
169+ freed += size
170+ service.forget_storage(pid)
171+ return freed
modified backend/studio/modules/projects/service.py +5 -0
@@ -305,6 +305,11 @@ def storage(pid: str) -> dict[str, int]:
305305 return sizes
306306
307307
308+def forget_storage(pid: str) -> None:
309+ """Après une suppression : la prochaine lecture de la place occupée refait le compte."""
310+ _storage_cache.pop(pid, None)
311+
312+
308313 # --- Modifications depuis l'interface ------------------------------------------
309314
310315 # ce que l'interface peut remplacer tel quel
@@ -305,6 +305,11 @@ def storage(pid: str) -> dict[str, int]:
305 return sizes305 return sizes
306 306
307 307
308+def forget_storage(pid: str) -> None:
309+ """Après une suppression : la prochaine lecture de la place occupée refait le compte."""
310+ _storage_cache.pop(pid, None)
311+
312+
308 # --- Modifications depuis l'interface ------------------------------------------313 # --- Modifications depuis l'interface ------------------------------------------
309 314
310 # ce que l'interface peut remplacer tel quel315 # ce que l'interface peut remplacer tel quel
modified web/src/i18n/locales/en/overview.ts +14 -0
@@ -13,6 +13,20 @@ export default {
1313 media: "Media & thumbnails",
1414 other: "Scripts, editing & cache",
1515 },
16+ unused: "Unused",
17+ unusedNone: "Nothing unused",
18+ unusedCategories: {
19+ assets: "Media not on the timeline or in motions",
20+ motion_renders: "Renders of removed motions",
21+ motion_media: "Media no motion uses anymore",
22+ derush: "Orphaned rough-cut exports & caches",
23+ videos: "Removed footage & sources",
24+ },
25+ purgeBtn: "Purge {size}",
26+ purgeConfirm: "Permanently delete these files ({size})?",
27+ purge: "Purge",
28+ purged: "{size} freed",
29+ purgeFailed: "Could not purge: {error}",
1630 delete: "Delete project",
1731 deleteDesc: "Deletes the project media ({size}) and its history.",
1832 deleteBtn: "Delete",
@@ -13,6 +13,20 @@ export default {
13 media: "Media & thumbnails",13 media: "Media & thumbnails",
14 other: "Scripts, editing & cache",14 other: "Scripts, editing & cache",
15 },15 },
16+ unused: "Unused",
17+ unusedNone: "Nothing unused",
18+ unusedCategories: {
19+ assets: "Media not on the timeline or in motions",
20+ motion_renders: "Renders of removed motions",
21+ motion_media: "Media no motion uses anymore",
22+ derush: "Orphaned rough-cut exports & caches",
23+ videos: "Removed footage & sources",
24+ },
25+ purgeBtn: "Purge {size}",
26+ purgeConfirm: "Permanently delete these files ({size})?",
27+ purge: "Purge",
28+ purged: "{size} freed",
29+ purgeFailed: "Could not purge: {error}",
16 delete: "Delete project",30 delete: "Delete project",
17 deleteDesc: "Deletes the project media ({size}) and its history.",31 deleteDesc: "Deletes the project media ({size}) and its history.",
18 deleteBtn: "Delete",32 deleteBtn: "Delete",
modified web/src/i18n/locales/fr/overview.ts +14 -0
@@ -13,6 +13,20 @@ export default {
1313 media: "Médias & vignettes",
1414 other: "Scripts, montage & cache",
1515 },
16+ unused: "Inutilisé",
17+ unusedNone: "Rien d'inutilisé",
18+ unusedCategories: {
19+ assets: "Médias absents de la timeline et des motions",
20+ motion_renders: "Rendus de motions retirés",
21+ motion_media: "Médias que plus aucun motion ne pose",
22+ derush: "Exports et caches du dérush orphelins",
23+ videos: "Rushes et sources retirés",
24+ },
25+ purgeBtn: "Purger {size}",
26+ purgeConfirm: "Supprimer définitivement ces fichiers ({size}) ?",
27+ purge: "Purger",
28+ purged: "{size} libérés",
29+ purgeFailed: "Purge impossible : {error}",
1630 delete: "Supprimer le projet",
1731 deleteDesc: "Supprime les médias du projet ({size}) et son historique.",
1832 deleteBtn: "Supprimer",
@@ -13,6 +13,20 @@ export default {
13 media: "Médias & vignettes",13 media: "Médias & vignettes",
14 other: "Scripts, montage & cache",14 other: "Scripts, montage & cache",
15 },15 },
16+ unused: "Inutilisé",
17+ unusedNone: "Rien d'inutilisé",
18+ unusedCategories: {
19+ assets: "Médias absents de la timeline et des motions",
20+ motion_renders: "Rendus de motions retirés",
21+ motion_media: "Médias que plus aucun motion ne pose",
22+ derush: "Exports et caches du dérush orphelins",
23+ videos: "Rushes et sources retirés",
24+ },
25+ purgeBtn: "Purger {size}",
26+ purgeConfirm: "Supprimer définitivement ces fichiers ({size}) ?",
27+ purge: "Purger",
28+ purged: "{size} libérés",
29+ purgeFailed: "Purge impossible : {error}",
16 delete: "Supprimer le projet",30 delete: "Supprimer le projet",
17 deleteDesc: "Supprime les médias du projet ({size}) et son historique.",31 deleteDesc: "Supprime les médias du projet ({size}) et son historique.",
18 deleteBtn: "Supprimer",32 deleteBtn: "Supprimer",
modified web/src/project/OverviewPage.vue +54 -2
@@ -9,7 +9,7 @@ import { toast } from "../lib/toast";
99 import { stepGroups } from "./nav";
1010 import { bars, doneCount, firstCur, stepById, STEPS } from "./steps";
1111 import { open, removeProject, store } from "./store";
12-import type { ProjectStorage } from "./types";
12+import type { ProjectStorage, UnusedStorage } from "./types";
1313
1414 const { t, locale } = useI18n();
1515 const route = useRoute();
@@ -17,18 +17,25 @@ const router = useRouter();
1717 const pid = computed(() => String(route.params.pid));
1818 const project = computed(() => (store.project?.id === pid.value ? store.project : null));
1919 const storage = ref<ProjectStorage | null>(null);
20+const unused = ref<UnusedStorage | null>(null);
2021
2122 watch(pid, async (id) => {
2223 storage.value = null;
24+ unused.value = null;
2325 try {
2426 await open(id);
2527 } catch {
2628 router.replace("/");
2729 return;
2830 }
29- api<ProjectStorage>(`/api/projects/${id}/storage`).then((s) => { storage.value = s; }).catch(() => {});
31+ loadStorage(id);
3032 }, { immediate: true });
3133
34+function loadStorage(id: string) {
35+ api<ProjectStorage>(`/api/projects/${id}/storage`).then((s) => { storage.value = s; }).catch(() => {});
36+ api<UnusedStorage>(`/api/projects/${id}/storage/unused`).then((u) => { unused.value = u; }).catch(() => {});
37+}
38+
3239 watch([project, locale], ([p]) => {
3340 if (p) document.title = `${p.title} · Studio`;
3441 }, { immediate: true });
@@ -62,6 +69,27 @@ const categories = computed(() => {
6269 }));
6370 });
6471
72+const unusedCategories = computed(() => (unused.value?.categories ?? []).filter((c) => c.bytes > 0).map((c) => ({
73+ ...c, label: t(`overview.unusedCategories.${c.key}`), size: bytes(c.bytes),
74+})));
75+
76+const purging = ref(false);
77+const confirmingPurge = ref(false);
78+async function purge() {
79+ const id = pid.value;
80+ purging.value = true;
81+ try {
82+ const r = await api<{ freed: number; unused: UnusedStorage }>(`/api/projects/${id}/storage/purge`, { method: "POST" });
83+ toast(t("overview.purged", { size: bytes(r.freed) }));
84+ confirmingPurge.value = false;
85+ loadStorage(id);
86+ } catch (e) {
87+ toast(t("overview.purgeFailed", { error: String(e) }), true);
88+ } finally {
89+ purging.value = false;
90+ }
91+}
92+
6593 const confirming = ref(false);
6694 async function remove() {
6795 const p = project.value;
@@ -134,6 +162,28 @@ async function remove() {
134162 <span v-else class="mono">{{ t("common.loading") }}</span>
135163 </section>
136164
165+ <section v-if="unused">
166+ <div class="row-between">
167+ <span class="eyebrow">{{ t("overview.unused") }}</span>
168+ <span class="mono">{{ unused.total ? bytes(unused.total) : t("overview.unusedNone") }}</span>
169+ </div>
170+ <template v-if="unused.total">
171+ <div class="cats">
172+ <div v-for="c in unusedCategories" :key="c.key" class="cat unused">
173+ <span>{{ c.label }}</span><span class="mono">{{ c.count }} · {{ c.size }}</span>
174+ </div>
175+ </div>
176+ <button v-if="!confirmingPurge" class="btn sm" @click="confirmingPurge = true">
177+ {{ t("overview.purgeBtn", { size: bytes(unused.total) }) }}
178+ </button>
179+ <div v-else class="confirm">
180+ <span>{{ t("overview.purgeConfirm", { size: bytes(unused.total) }) }}</span>
181+ <button class="btn sm" :disabled="purging" @click="confirmingPurge = false">{{ t("overview.cancel") }}</button>
182+ <button class="btn sm dark" :disabled="purging" @click="purge">{{ t("overview.purge") }}</button>
183+ </div>
184+ </template>
185+ </section>
186+
137187 <div class="danger">
138188 <div class="danger-row">
139189 <div class="danger-text">
@@ -200,6 +250,8 @@ section { display: flex; flex-direction: column; gap: 12px; }
200250 display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 10px; align-items: center;
201251 padding: 7px 0; border-bottom: 1px solid var(--line-soft); font-size: 13.5px;
202252 }
253+.cat.unused { grid-template-columns: minmax(0, 1fr) auto; }
254+.side section > .btn { align-self: flex-start; }
203255 .swatch { width: 10px; height: 10px; }
204256 .danger { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid #F0C8C9; border-radius: 4px; }
205257 .danger-row { display: flex; gap: 12px; align-items: center; }
@@ -9,7 +9,7 @@ import { toast } from "../lib/toast";
9 import { stepGroups } from "./nav";9 import { stepGroups } from "./nav";
10 import { bars, doneCount, firstCur, stepById, STEPS } from "./steps";10 import { bars, doneCount, firstCur, stepById, STEPS } from "./steps";
11 import { open, removeProject, store } from "./store";11 import { open, removeProject, store } from "./store";
12-import type { ProjectStorage } from "./types";12+import type { ProjectStorage, UnusedStorage } from "./types";
13 13
14 const { t, locale } = useI18n();14 const { t, locale } = useI18n();
15 const route = useRoute();15 const route = useRoute();
@@ -17,18 +17,25 @@ const router = useRouter();
17 const pid = computed(() => String(route.params.pid));17 const pid = computed(() => String(route.params.pid));
18 const project = computed(() => (store.project?.id === pid.value ? store.project : null));18 const project = computed(() => (store.project?.id === pid.value ? store.project : null));
19 const storage = ref<ProjectStorage | null>(null);19 const storage = ref<ProjectStorage | null>(null);
20+const unused = ref<UnusedStorage | null>(null);
20 21
21 watch(pid, async (id) => {22 watch(pid, async (id) => {
22 storage.value = null;23 storage.value = null;
24+ unused.value = null;
23 try {25 try {
24 await open(id);26 await open(id);
25 } catch {27 } catch {
26 router.replace("/");28 router.replace("/");
27 return;29 return;
28 }30 }
29- api<ProjectStorage>(`/api/projects/${id}/storage`).then((s) => { storage.value = s; }).catch(() => {});31+ loadStorage(id);
30 }, { immediate: true });32 }, { immediate: true });
31 33
34+function loadStorage(id: string) {
35+ api<ProjectStorage>(`/api/projects/${id}/storage`).then((s) => { storage.value = s; }).catch(() => {});
36+ api<UnusedStorage>(`/api/projects/${id}/storage/unused`).then((u) => { unused.value = u; }).catch(() => {});
37+}
38+
32 watch([project, locale], ([p]) => {39 watch([project, locale], ([p]) => {
33 if (p) document.title = `${p.title} · Studio`;40 if (p) document.title = `${p.title} · Studio`;
34 }, { immediate: true });41 }, { immediate: true });
@@ -62,6 +69,27 @@ const categories = computed(() => {
62 }));69 }));
63 });70 });
64 71
72+const unusedCategories = computed(() => (unused.value?.categories ?? []).filter((c) => c.bytes > 0).map((c) => ({
73+ ...c, label: t(`overview.unusedCategories.${c.key}`), size: bytes(c.bytes),
74+})));
75+
76+const purging = ref(false);
77+const confirmingPurge = ref(false);
78+async function purge() {
79+ const id = pid.value;
80+ purging.value = true;
81+ try {
82+ const r = await api<{ freed: number; unused: UnusedStorage }>(`/api/projects/${id}/storage/purge`, { method: "POST" });
83+ toast(t("overview.purged", { size: bytes(r.freed) }));
84+ confirmingPurge.value = false;
85+ loadStorage(id);
86+ } catch (e) {
87+ toast(t("overview.purgeFailed", { error: String(e) }), true);
88+ } finally {
89+ purging.value = false;
90+ }
91+}
92+
65 const confirming = ref(false);93 const confirming = ref(false);
66 async function remove() {94 async function remove() {
67 const p = project.value;95 const p = project.value;
@@ -134,6 +162,28 @@ async function remove() {
134 <span v-else class="mono">{{ t("common.loading") }}</span>162 <span v-else class="mono">{{ t("common.loading") }}</span>
135 </section>163 </section>
136 164
165+ <section v-if="unused">
166+ <div class="row-between">
167+ <span class="eyebrow">{{ t("overview.unused") }}</span>
168+ <span class="mono">{{ unused.total ? bytes(unused.total) : t("overview.unusedNone") }}</span>
169+ </div>
170+ <template v-if="unused.total">
171+ <div class="cats">
172+ <div v-for="c in unusedCategories" :key="c.key" class="cat unused">
173+ <span>{{ c.label }}</span><span class="mono">{{ c.count }} · {{ c.size }}</span>
174+ </div>
175+ </div>
176+ <button v-if="!confirmingPurge" class="btn sm" @click="confirmingPurge = true">
177+ {{ t("overview.purgeBtn", { size: bytes(unused.total) }) }}
178+ </button>
179+ <div v-else class="confirm">
180+ <span>{{ t("overview.purgeConfirm", { size: bytes(unused.total) }) }}</span>
181+ <button class="btn sm" :disabled="purging" @click="confirmingPurge = false">{{ t("overview.cancel") }}</button>
182+ <button class="btn sm dark" :disabled="purging" @click="purge">{{ t("overview.purge") }}</button>
183+ </div>
184+ </template>
185+ </section>
186+
137 <div class="danger">187 <div class="danger">
138 <div class="danger-row">188 <div class="danger-row">
139 <div class="danger-text">189 <div class="danger-text">
@@ -200,6 +250,8 @@ section { display: flex; flex-direction: column; gap: 12px; }
200 display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 10px; align-items: center;250 display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 10px; align-items: center;
201 padding: 7px 0; border-bottom: 1px solid var(--line-soft); font-size: 13.5px;251 padding: 7px 0; border-bottom: 1px solid var(--line-soft); font-size: 13.5px;
202 }252 }
253+.cat.unused { grid-template-columns: minmax(0, 1fr) auto; }
254+.side section > .btn { align-self: flex-start; }
203 .swatch { width: 10px; height: 10px; }255 .swatch { width: 10px; height: 10px; }
204 .danger { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid #F0C8C9; border-radius: 4px; }256 .danger { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid #F0C8C9; border-radius: 4px; }
205 .danger-row { display: flex; gap: 12px; align-items: center; }257 .danger-row { display: flex; gap: 12px; align-items: center; }
modified web/src/project/types.ts +6 -0
@@ -184,6 +184,12 @@ export interface ProjectStorage {
184184 categories: { key: "rushes" | "source" | "derush" | "renders" | "media" | "other"; bytes: number }[];
185185 }
186186
187+/** Ce qui ne sert plus dans le dossier du projet, que la purge supprime (GET /storage/unused). */
188+export interface UnusedStorage {
189+ total: number;
190+ categories: { key: "assets" | "motion_renders" | "motion_media" | "derush" | "videos"; count: number; bytes: number }[];
191+}
192+
187193 /** Ce qui se modifie directement (PATCH). */
188194 export type ProjectPatch = Partial<Pick<Project,
189195 "title" | "status" | "brief" | "angle_sel" | "titles" | "thumbs" | "thumb_sel" | "research" | "shots" | "rush_sort" | "publish">> &
@@ -184,6 +184,12 @@ export interface ProjectStorage {
184 categories: { key: "rushes" | "source" | "derush" | "renders" | "media" | "other"; bytes: number }[];184 categories: { key: "rushes" | "source" | "derush" | "renders" | "media" | "other"; bytes: number }[];
185 }185 }
186 186
187+/** Ce qui ne sert plus dans le dossier du projet, que la purge supprime (GET /storage/unused). */
188+export interface UnusedStorage {
189+ total: number;
190+ categories: { key: "assets" | "motion_renders" | "motion_media" | "derush" | "videos"; count: number; bytes: number }[];
191+}
192+
187 /** Ce qui se modifie directement (PATCH). */193 /** Ce qui se modifie directement (PATCH). */
188 export type ProjectPatch = Partial<Pick<Project,194 export type ProjectPatch = Partial<Pick<Project,
189 "title" | "status" | "brief" | "angle_sel" | "titles" | "thumbs" | "thumb_sel" | "research" | "shots" | "rush_sort" | "publish">> &195 "title" | "status" | "brief" | "angle_sel" | "titles" | "thumbs" | "thumb_sel" | "research" | "shots" | "rush_sort" | "publish">> &