From 460ae949256f6b9f4abd4fdd45fb5a26afa137e2 Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Sun, 4 May 2025 18:25:23 +0200 Subject: [PATCH] feat: add conversion page --- src/public/conversion/index.html | 67 ++++ src/public/metadata/edit/index.html | 1 + src/public/static/css/conversion.css | 265 ++++++++++++++ src/public/static/images/agent.svg | 498 +++++++++++++++++++++++++++ src/public/static/js/conversion.js | 231 +++++++++++++ src/public/static/js/metadata.js | 2 +- src/server.py | 109 ++++-- 7 files changed, 1147 insertions(+), 26 deletions(-) create mode 100644 src/public/conversion/index.html create mode 100644 src/public/static/css/conversion.css create mode 100644 src/public/static/images/agent.svg create mode 100644 src/public/static/js/conversion.js diff --git a/src/public/conversion/index.html b/src/public/conversion/index.html new file mode 100644 index 0000000..7f5b68a --- /dev/null +++ b/src/public/conversion/index.html @@ -0,0 +1,67 @@ + + + + + + Melies - Conversion + + + + + + +
+

Media Conversion

+
+
+ + +
+ +
+
+ +
+

To convert

+ +
Director is empty
+
+
+
+

Selected

+
+ + + +
+
+
+ +
+ + \ No newline at end of file diff --git a/src/public/metadata/edit/index.html b/src/public/metadata/edit/index.html index 7f90d6c..e5bf14c 100644 --- a/src/public/metadata/edit/index.html +++ b/src/public/metadata/edit/index.html @@ -6,6 +6,7 @@ Melies - Metadata Editor + diff --git a/src/public/static/css/conversion.css b/src/public/static/css/conversion.css new file mode 100644 index 0000000..1553a58 --- /dev/null +++ b/src/public/static/css/conversion.css @@ -0,0 +1,265 @@ +body { + display: flex; + flex-direction: column; +} + +main { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1em; + flex: 1; + overflow-y: hidden; +} + +.panel { + display: flex; + flex-direction: column; + padding: 0.8em; + border: solid black 2px; + overflow: hidden; + + &.hidden { + display: none; + } + + .title { + margin: 0.4em 0; + } +} + +button { + font-family: inherit; + font-size: inherit; + padding: 0.4em 0.8em; + border-radius: 0.4em; + background: none; + cursor: pointer; + border: solid #c5c5c5 2px; + text-align: center; + font-weight: bold; + + &:hover { + background-color: #f8f8f8; + } +} + +#up { + width: 100%; + padding: 0.8em 1.6em; + border-radius: 1.2em; + + &:not(.show) { + display: none; + } +} + +#files { + display: grid; + grid-template-columns: repeat(auto-fill, 12em); + grid-auto-rows: 12em; + gap: 0.8em; + place-items: center; + padding: 0.8em 0; + justify-content: space-evenly; + height: 100%; + overflow: auto; + + .file { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: 100%; + height: 100%; + text-decoration: none; + color: black; + font-family: inherit; + font-size: inherit; + padding: 0.4em; + border-radius: 1.2em; + background: none; + cursor: pointer; + gap: 0.2em; + border: solid transparent 2px; + + &.selected { + border-color: rgb(153, 226, 153); + } + + &:hover { + background-color: #f8f8f8; + } + + img { + width: 5em; + height: 5em; + } + + .name { + overflow-wrap: anywhere; + text-align: center; + font-weight: bold; + flex-shrink: 1; + overflow: hidden; + } + + .info, .children { + font-size: 80%; + color: rgb(75, 75, 75); + font-style: italic; + font-weight: normal; + } + } +} + +#selected { + display: flex; + flex-direction: column; + height: 100%; + overflow: auto; + + .file { + display: flex; + padding: 0.4em 0.8em; + gap: 0.4em; + align-items: center; + + &:nth-child(even) { + background-color: #fafafa; + } + + .deselect { + background-color: #ffd8c6; + + &:hover { + background-color: #ffc3a7; + } + } + + .name { + overflow-wrap: anywhere; + } + } +} + +.toolbar { + display: flex; + gap: 0.4em; + + button, button .enabled { + display: flex; + gap: 0.2em; + align-items: center; + + &.hidden { + display: none; + } + + img { + width: 1.4em; + height: 1.4em; + } + } +} + +.empty-msg { + color: #4d4d4d; + font-style: italic; + font-size: 120%; + text-align: center; + padding: 1.2em 0.4em; + + &:not(.show) { + display: none; + } +} + +#agents { + display: grid; + grid-template-columns: repeat(auto-fill, 12em); + grid-auto-rows: 12em; + gap: 0.8em; + place-items: center; + padding: 0.8em 0; + justify-content: space-evenly; + height: 100%; + overflow: auto; + + .agent { + input { + display: none; + } + + .container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: 100%; + height: 100%; + text-decoration: none; + color: black; + font-family: inherit; + font-size: inherit; + padding: 0.4em; + border-radius: 1.2em; + background: none; + cursor: pointer; + gap: 0.2em; + border: none; + + &.selected { + border-color: #99e299; + } + + &:hover { + background-color: #f8f8f8; + } + + .icon { + width: 8em; + height: 8em; + mask-image: url("/static/images/agent.svg"); + mask-size: contain; + background-color: black; + } + + .name { + overflow-wrap: anywhere; + text-align: center; + font-weight: bold; + font-size: 120%; + flex-shrink: 1; + overflow: hidden; + } + } + + input:checked + .container { + .icon { + background-color: #1bb11b; + } + + .name { + color: #1bb11b; + } + } + } +} + +#convert { + font-size: 120%; +} + +#continue, #convert { + &:not(:disabled) { + .disabled { + display: none; + } + } + + &:disabled { + .enabled { + display: none; + } + } +} \ No newline at end of file diff --git a/src/public/static/images/agent.svg b/src/public/static/images/agent.svg new file mode 100644 index 0000000..5d2f0c4 --- /dev/null +++ b/src/public/static/images/agent.svg @@ -0,0 +1,498 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/public/static/js/conversion.js b/src/public/static/js/conversion.js new file mode 100644 index 0000000..8eecfec --- /dev/null +++ b/src/public/static/js/conversion.js @@ -0,0 +1,231 @@ +let selected = [] +let currentPath = [] + +const SIZES = ["", "K", "M", "G", "T"] + +function formatSize(bytes) { + let order = Math.floor(Math.log10(bytes) / 3) + if (bytes > Math.pow(10, (order + 1) * 3) / 2) { + order += 1 + } + let size = bytes / Math.pow(10, order * 3) + size = Math.round(size * 10) / 10 + const prefix = SIZES[order] + return `${size}${prefix}B` +} + +function makeFolder(meta) { + const file = document.getElementById("folder-template").cloneNode(true) + file.querySelector(".name").innerText = meta.filename + file.querySelector(".children .num").innerText = meta.elements + file.addEventListener("dblclick", () => { + currentPath.push(meta.filename) + navigate() + }) + return file +} + +function makeMedia(meta) { + const file = document.getElementById("media-template").cloneNode(true) + file.querySelector(".name").innerText = meta.filename.split(".").slice(0, -1).join(".") + file.querySelector(".size").innerText = formatSize(meta.size) + file.querySelector(".ext").innerText = meta.filename.split(".").slice(-1)[0].toUpperCase() + return file +} + +function makeFile(meta) { + let file + switch (meta.type) { + case "folder": + file = makeFolder(meta) + break + case "media": + file = makeMedia(meta) + break + default: + throw new Error(`Invalid file type '${meta.type}'`) + } + + file.title = meta.filename + file.id = null + file.dataset.path = meta.path + file.classList.remove("template") + if (selected.includes(meta.path)) { + file.classList.add("selected") + } + + file.addEventListener("click", e => { + if (e.ctrlKey) { + e.preventDefault() + toggleSelectFile(meta) + } + }) + return file +} + +function makeAgent(meta) { + const agent = document.getElementById("agent-template").cloneNode(true) + agent.classList.remove("template") + agent.removeAttribute("id") + agent.dataset.uuid = meta.uuid + agent.querySelector(".name").innerText = meta.name + const input = agent.querySelector("input[type='radio']") + input.value = meta.uuid + input.addEventListener("change", () => updateConvertBtn()) + return agent +} + +function toggleSelectFile(meta) { + if (selected.includes(meta.path)) { + deselectFile(meta.path) + } else { + selectFile(meta) + } +} + +function selectFile(meta) { + selected.push(meta.path) + document.querySelector(`.file[data-path='${meta.path}']`)?.classList?.add("selected") + const list = document.getElementById("selected") + const line = document.getElementById("selected-template").cloneNode(true) + line.classList.remove("template") + line.id = null + line.querySelector(".name").innerText = meta.path + line.dataset.path = meta.path + line.querySelector(".deselect").addEventListener("click", () => { + deselectFile(meta.path) + }) + list.appendChild(line) + + const continueBtn = document.getElementById("continue") + continueBtn.disabled = false +} + +function deselectFile(path) { + selected = selected.filter(p => p !== path) + document.querySelector(`#files .file[data-path='${path}']`)?.classList?.remove("selected") + document.querySelector(`#selected .file[data-path='${path}']`)?.remove() + + const continueBtn = document.getElementById("continue") + continueBtn.disabled = selected.length === 0 +} + +function deselectAll() { + selected.forEach(path => { + deselectFile(path) + }) + showFiles() +} + +/** + * + * @param {object[]} files + */ +function addFiles(files) { + const emptyMsg = document.getElementById("no-file") + if (files.length === 0) { + emptyMsg.classList.add("show") + } else { + emptyMsg.classList.remove("show") + } + const list = document.getElementById("files") + const list2 = document.createElement("div") + list2.innerHTML = "" + const filenames = files.map(meta => meta.filename) + // Copy array because sort changes it in place + Array.from(filenames).sort().forEach(filename => { + const i = filenames.indexOf(filename) + const meta = files[i] + const file = makeFile(meta) + list2.appendChild(file) + }) + list.replaceChildren(...list2.children) + + const upBtn = document.getElementById("up") + if (currentPath.length === 0) { + upBtn.classList.remove("show") + } else { + upBtn.classList.add("show") + } +} + +function navigate() { + const url = new URL("/api/files/to_convert", window.location.origin) + url.searchParams.set("f", currentPath.join("/")) + + fetch(url.href).then(res => { + return res.json() + }).then(files => { + addFiles(files) + }) +} + +function showFiles() { + document.getElementById("show-files").classList.add("hidden") + document.getElementById("continue").classList.remove("hidden") + document.getElementById("agents-panel").classList.add("hidden") + document.getElementById("to-convert-panel").classList.remove("hidden") +} + +function showAgents() { + document.getElementById("continue").classList.add("hidden") + document.getElementById("show-files").classList.remove("hidden") + document.getElementById("to-convert-panel").classList.add("hidden") + document.getElementById("agents-panel").classList.remove("hidden") +} + +function updateConvertBtn() { + const agent = document.querySelector("#agents .agent input:checked") + const convertBtn = document.getElementById("convert") + if (agent) { + convertBtn.disabled = false + } else { + convertBtn.disabled = true + } +} + +function addAgents(agents) { + const emptyMsg = document.getElementById("no-agent") + if (agents.length === 0) { + emptyMsg.classList.add("show") + } else { + emptyMsg.classList.remove("show") + } + const selectedAgent = document.querySelector("#agents .agent input:checked")?.parentElement + + const list = document.getElementById("agents") + const list2 = document.createElement("div") + list2.innerHTML = "" + const agentNames = agents.map(meta => meta.name) + // Copy array because sort changes it in place + Array.from(agentNames).sort().forEach(name => { + const i = agentNames.indexOf(name) + const meta = agents[i] + const agent = makeAgent(meta) + if (selectedAgent?.dataset?.uuid === meta.uuid) { + agent.querySelector("input").checked = true + } + list2.appendChild(agent) + }) + list.replaceChildren(...list2.children) + updateConvertBtn() +} + +window.addEventListener("load", () => { + document.getElementById("up").addEventListener("dblclick", () => { + if (currentPath.length !== 0) { + currentPath = currentPath.slice(0, -1) + navigate() + } + }) + document.getElementById("show-files").addEventListener("click", () => { + showFiles() + }) + document.getElementById("deselect-all").addEventListener("click", () => { + deselectAll() + }) + document.getElementById("continue").addEventListener("click", () => { + showAgents() + }) + navigate() +}) \ No newline at end of file diff --git a/src/public/static/js/metadata.js b/src/public/static/js/metadata.js index b264ef0..dd9afba 100644 --- a/src/public/static/js/metadata.js +++ b/src/public/static/js/metadata.js @@ -94,7 +94,7 @@ function sortFiles() { } window.addEventListener("load", () => { - fetch("/api/files").then(res => { + fetch("/api/files/metadata").then(res => { return res.json() }).then(files => { addFiles(files) diff --git a/src/server.py b/src/server.py index c21b47a..62144cd 100755 --- a/src/server.py +++ b/src/server.py @@ -41,11 +41,14 @@ class EnvDefault(argparse.Action): class HTTPHandler(SimpleHTTPRequestHandler): SERVER: MeliesServer = None - CACHE = {} + METADATA_CACHE = {} + TO_CONVERT_CACHE = {} def __init__(self, *args, **kwargs): self.MAX_PAYLOAD_SIZE: int = self.SERVER.max_payload_size - self.DATA_DIR: str = self.SERVER.metadata_dir + self.TO_CONVERT_DIR: str = self.SERVER.to_convert_dir + self.CONVERTED_DIR: str = self.SERVER.converted_dir + self.METADATA_DIR: str = self.SERVER.metadata_dir super().__init__( *args, @@ -78,16 +81,20 @@ class HTTPHandler(SimpleHTTPRequestHandler): return True def do_GET(self): - self.path = unquote(self.path) - self.query = parse_qs(urlparse(self.path).query) + parsed = urlparse(unquote(self.path)) + self.path = parsed.path + self.query = parse_qs(parsed.query) + if self.path.startswith("/api/"): self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/")) return super().do_GET() def do_POST(self): - self.path = unquote(self.path) - self.query = parse_qs(urlparse(self.path).query) + parsed = urlparse(unquote(self.path)) + self.path = parsed.path + self.query = parse_qs(parsed.query) + if self.path.startswith("/api/"): self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/")) return @@ -95,9 +102,14 @@ class HTTPHandler(SimpleHTTPRequestHandler): def handle_api_get(self, path: str): self.log_message(f"API request at {path}") - if path == "files": - files: list[str] = self.get_files_meta() + if path == "files/to_convert": + files: list[str] = self.get_to_convert_files_meta(self.query.get("f", [""])[0]) self.send_json(files) + + elif path == "files/metadata": + files: list[str] = self.get_metadata_files_meta() + self.send_json(files) + elif path.startswith("file"): filename: str = path.split("/", 1)[1] data = self.read_file(filename) @@ -126,49 +138,78 @@ class HTTPHandler(SimpleHTTPRequestHandler): self.end_headers() self.wfile.write(json.dumps(data).encode("utf-8")) - def get_files(self): - return os.listdir(self.DATA_DIR) + def get_to_convert_files(self, base_path: str): + root_path: str = os.path.abspath(self.TO_CONVERT_DIR) + full_path: str = os.path.join(root_path, base_path) + full_path = os.path.abspath(full_path) + common_prefix: str = os.path.commonprefix([full_path, root_path]) + + if common_prefix != root_path: + return [] + + return os.listdir(full_path) + + def get_metadata_files(self): + return os.listdir(self.METADATA_DIR) def read_file(self, filename: str) -> Optional[dict|list]: - if filename not in self.get_files(): + if filename not in self.get_metadata_files(): return None - with open(os.path.join(self.DATA_DIR, filename), "r") as f: + with open(os.path.join(self.METADATA_DIR, filename), "r") as f: data = json.load(f) return data def write_file(self, filename: str, data: dict|list) -> bool: - if filename not in self.get_files(): + if filename not in self.get_metadata_files(): self.send_error(HTTPStatus.NOT_FOUND) return False try: - with open(os.path.join(self.DATA_DIR, filename), "w", encoding="utf-8") as f: + with open(os.path.join(self.METADATA_DIR, filename), "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) except: self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR) return False return True - def get_files_meta(self): - files: list[str] = self.get_files() + def get_to_convert_files_meta(self, base_path: str): + files: list[str] = self.get_to_convert_files(base_path) + files = [os.path.join(self.TO_CONVERT_DIR, base_path, f) for f in files] files_meta: list[dict] = [] - deleted = set(self.CACHE.keys()) - set(files) + deleted = set(self.TO_CONVERT_CACHE.keys()) - set(files) + for path in deleted: + del self.TO_CONVERT_CACHE[path] + + for path in files: + last_modified: float = os.path.getmtime(path) + if path not in self.TO_CONVERT_CACHE or self.TO_CONVERT_CACHE[path]["ts"] < last_modified: + self.update_to_convert_file_meta(path) + + files_meta.append(self.TO_CONVERT_CACHE[path]) + + return files_meta + + def get_metadata_files_meta(self): + files: list[str] = self.get_metadata_files() + files_meta: list[dict] = [] + + deleted = set(self.METADATA_CACHE.keys()) - set(files) for filename in deleted: - del self.CACHE[deleted] + del self.METADATA_CACHE[filename] for filename in files: - path: str = os.path.join(self.DATA_DIR, filename) + path: str = os.path.join(self.METADATA_DIR, filename) last_modified: float = os.path.getmtime(path) - if filename not in self.CACHE or self.CACHE[filename]["ts"] < last_modified: - self.update_file_meta(filename) + if filename not in self.METADATA_CACHE or self.METADATA_CACHE[filename]["ts"] < last_modified: + self.update_metadata_file_meta(filename) - files_meta.append(self.CACHE[filename]) + files_meta.append(self.METADATA_CACHE[filename]) return files_meta - def update_file_meta(self, filename: str): - path: str = os.path.join(self.DATA_DIR, filename) + def update_metadata_file_meta(self, filename: str): + path: str = os.path.join(self.METADATA_DIR, filename) meta = { "filename": filename, @@ -185,7 +226,25 @@ class HTTPHandler(SimpleHTTPRequestHandler): else: meta["title"] = data["title"] - self.CACHE[filename] = meta + self.METADATA_CACHE[filename] = meta + + def update_to_convert_file_meta(self, path: str): + filename: str = os.path.basename(path) + + is_dir: bool = os.path.isdir(path) + meta = { + "path": os.path.relpath(path, self.TO_CONVERT_DIR), + "filename": filename, + "ts": os.path.getmtime(path), + "size": os.path.getsize(path), + "type": "folder" if is_dir else "media" + } + if is_dir: + meta["elements"] = len(os.listdir(path)) + if not meta["path"].endswith("/"): + meta["path"] += "/" + + self.TO_CONVERT_CACHE[path] = meta class MeliesServer(FileSystemEventHandler):