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.
326702f parent: 8effbe8 modified
backend/studio/modules/projects/api.py +15 -1 | @@ -16,7 +16,7 @@ from pydantic import BaseModel | ||
| 16 | 16 | from ...core import jobs, system |
| 17 | 17 | from ..animations import kit |
| 18 | 18 | 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 | 20 | service, storyboard, storyboard_chat, thumbs, timeline, transitions) |
| 21 | 21 | from . import beat_anim |
| 22 | 22 | |
| @@ -295,6 +295,20 @@ def storage(pid: str): | ||
| 295 | 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 | 312 | @router.delete("/{pid}") |
| 299 | 313 | def remove(pid: str): |
| 300 | 314 | _check(pid) |
| @@ -16,7 +16,7 @@ from pydantic import BaseModel | |||
| 16 | from ...core import jobs, system | 16 | from ...core import jobs, system |
| 17 | from ..animations import kit | 17 | from ..animations import kit |
| 18 | from ..youtube import service as youtube | 18 | 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_anim | 21 | 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]: | ||
| 305 | 305 | 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 | 313 | # --- Modifications depuis l'interface ------------------------------------------ |
| 309 | 314 | |
| 310 | 315 | # ce que l'interface peut remplacer tel quel |
| @@ -305,6 +305,11 @@ def storage(pid: str) -> dict[str, int]: | |||
| 305 | return sizes | 305 | 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 quel | 315 | # ce que l'interface peut remplacer tel quel |
modified
web/src/i18n/locales/en/overview.ts +14 -0 | @@ -13,6 +13,20 @@ export default { | ||
| 13 | 13 | media: "Media & thumbnails", |
| 14 | 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 | 30 | delete: "Delete project", |
| 17 | 31 | deleteDesc: "Deletes the project media ({size}) and its history.", |
| 18 | 32 | 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 { | ||
| 13 | 13 | media: "Médias & vignettes", |
| 14 | 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 | 30 | delete: "Supprimer le projet", |
| 17 | 31 | deleteDesc: "Supprime les médias du projet ({size}) et son historique.", |
| 18 | 32 | 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"; | ||
| 9 | 9 | import { stepGroups } from "./nav"; |
| 10 | 10 | import { bars, doneCount, firstCur, stepById, STEPS } from "./steps"; |
| 11 | 11 | import { open, removeProject, store } from "./store"; |
| 12 | -import type { ProjectStorage } from "./types"; | |
| 12 | +import type { ProjectStorage, UnusedStorage } from "./types"; | |
| 13 | 13 | |
| 14 | 14 | const { t, locale } = useI18n(); |
| 15 | 15 | const route = useRoute(); |
| @@ -17,18 +17,25 @@ const router = useRouter(); | ||
| 17 | 17 | const pid = computed(() => String(route.params.pid)); |
| 18 | 18 | const project = computed(() => (store.project?.id === pid.value ? store.project : null)); |
| 19 | 19 | const storage = ref<ProjectStorage | null>(null); |
| 20 | +const unused = ref<UnusedStorage | null>(null); | |
| 20 | 21 | |
| 21 | 22 | watch(pid, async (id) => { |
| 22 | 23 | storage.value = null; |
| 24 | + unused.value = null; | |
| 23 | 25 | try { |
| 24 | 26 | await open(id); |
| 25 | 27 | } catch { |
| 26 | 28 | router.replace("/"); |
| 27 | 29 | return; |
| 28 | 30 | } |
| 29 | - api<ProjectStorage>(`/api/projects/${id}/storage`).then((s) => { storage.value = s; }).catch(() => {}); | |
| 31 | + loadStorage(id); | |
| 30 | 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 | 39 | watch([project, locale], ([p]) => { |
| 33 | 40 | if (p) document.title = `${p.title} · Studio`; |
| 34 | 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 | 93 | const confirming = ref(false); |
| 66 | 94 | async function remove() { |
| 67 | 95 | const p = project.value; |
| @@ -134,6 +162,28 @@ async function remove() { | ||
| 134 | 162 | <span v-else class="mono">{{ t("common.loading") }}</span> |
| 135 | 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 | 187 | <div class="danger"> |
| 138 | 188 | <div class="danger-row"> |
| 139 | 189 | <div class="danger-text"> |
| @@ -200,6 +250,8 @@ section { display: flex; flex-direction: column; gap: 12px; } | ||
| 200 | 250 | display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 10px; align-items: center; |
| 201 | 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 | 255 | .swatch { width: 10px; height: 10px; } |
| 204 | 256 | .danger { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid #F0C8C9; border-radius: 4px; } |
| 205 | 257 | .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 { | ||
| 184 | 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 | 193 | /** Ce qui se modifie directement (PATCH). */ |
| 188 | 194 | export type ProjectPatch = Partial<Pick<Project, |
| 189 | 195 | "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">> & |