From 477e8951a94250cb3fcccb2f9141e3de255aff43 Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Mon, 28 Apr 2025 19:07:12 +0200 Subject: [PATCH 01/11] feat: add hotone bool --- editor/public/edit/index.css | 55 ++++++++++++++++++++++++++++++++++++ editor/public/edit/index.js | 48 +++++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/editor/public/edit/index.css b/editor/public/edit/index.css index 27ee460..ccf53d9 100644 --- a/editor/public/edit/index.css +++ b/editor/public/edit/index.css @@ -10,4 +10,59 @@ &:not(.show) { display: none; } +} + +table { + border-collapse: collapse; + width: 100%; + + th, td { + padding: 0.4em 0.8em; + } + + tbody { + tr { + &:nth-child(even) { + background-color: #ececec; + } + + td { + text-align: center; + } + } + } +} + +input[type="checkbox"] { + --size: 0.6em; + --pad: 0.2em; + width: calc((var(--size) * 2 + var(--pad)) * 2); + height: calc((var(--size) + var(--pad)) * 2); + border-radius: calc(var(--size) + var(--pad)); + appearance: none; + background-color: #e1e1e1; + position: relative; + cursor: pointer; + + &::after { + content: ""; + width: calc(var(--size) * 2); + height: calc(var(--size) * 2); + border-radius: calc(var(--size)); + background-color: #9b9b9b; + position: absolute; + top: 50%; + left: var(--pad); + transform: translateY(-50%); + } + + &:checked { + background-color: #daf0d1; + + &::after { + left: auto; + right: var(--pad); + background-color: #6ee74a; + } + } } \ No newline at end of file diff --git a/editor/public/edit/index.js b/editor/public/edit/index.js index 04da087..7f20c28 100644 --- a/editor/public/edit/index.js +++ b/editor/public/edit/index.js @@ -23,13 +23,20 @@ class TracksTable { OPTIONS = { "language": ["fre", "eng"] } + CONSTRAINTS = { + "flags/default": { + type: "hotone" + } + } - constructor(table) { + constructor(table, callback) { this.table = table this.headers = this.table.querySelector("thead tr") this.body = this.table.querySelector("tbody") this.fields = [] this.tracks = [] + this.callback = callback + this.hotones = {} } showTracks(tracks) { @@ -45,7 +52,7 @@ class TracksTable { tr.dataset.i = i this.fields.forEach(field => { const td = tr.insertCell(-1) - const input = this.makeInput(field, track[field.key]) + const input = this.makeInput(field, track[field.key], i) td.appendChild(input) }) this.body.appendChild(tr) @@ -81,7 +88,7 @@ class TracksTable { }) } - makeInput(field, value) { + makeInput(field, value, trackIdx) { let input = document.createElement("input") switch (field.type) { case "num": @@ -97,6 +104,32 @@ class TracksTable { case "bool": input.type = "checkbox" input.checked = value + const hotone = this.CONSTRAINTS[field.key]?.type == "hotone" + + if (hotone) { + if (value) { + if (field.key in this.hotones) { + alert("Error in metadata file: field ${field.name} is hotone but multiple tracks are enabled ") + } + this.hotones[field.key] = input + } + input.addEventListener("click", e => { + console.log("HOTONE") + if (!input.checked) { + console.log("Already checked, preventing uncheck") + e.preventDefault() + } else { + if (field.key in this.hotones) { + this.hotones[field.key].checked = false + this.hotones[field.key].dispatchEvent(new Event("change")) + } + this.hotones[field.key] = input + } + }) + } + input.addEventListener("change", () => { + this.callback(trackIdx, field.key, input.checked) + }) break case "sel": @@ -113,6 +146,7 @@ class TracksTable { default: break } + input.dataset.key = field.key return input } } @@ -146,8 +180,12 @@ function displayData(data) { } window.addEventListener("load", () => { - audioTable = new TracksTable(document.getElementById("audio-tracks")) - subtitleTable = new TracksTable(document.getElementById("subtitle-tracks")) + audioTable = new TracksTable(document.getElementById("audio-tracks"), (i, key, value) => { + console.log(i, key, value) + }) + subtitleTable = new TracksTable(document.getElementById("subtitle-tracks"), (i, key, value) => { + console.log(i, key, value) + }) const params = new URLSearchParams(window.location.search) const file = params.get("f") -- 2.47.2 From d19ab90f38f80d489780d020064cf7ae1ca915ea Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Mon, 28 Apr 2025 19:07:39 +0200 Subject: [PATCH 02/11] feat: add saving --- editor/public/edit/index.js | 62 +++++++++++++++++++++++++++++-------- editor/server.py | 58 ++++++++++++++++++++++++++-------- 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/editor/public/edit/index.js b/editor/public/edit/index.js index 7f20c28..824f3d1 100644 --- a/editor/public/edit/index.js +++ b/editor/public/edit/index.js @@ -4,6 +4,8 @@ let audioTable /** @type TracksTable */ let subtitleTable +let data = {} + function flattenObj(obj) { const res = {} Object.entries(obj).forEach(([key, value]) => { @@ -152,39 +154,64 @@ class TracksTable { } function fetchData(filename) { - fetch("/api/file", { - method: "POST", - body: JSON.stringify({ - file: filename - }), - headers: { - "Content-Type": "application/json" - } - }).then(res => { + fetch(`/api/file/${filename}`).then(res => { if (res.ok) { return res.json() } return null }).then(res => { if (res !== null) { - displayData(res) + data = res + displayData() } }) } -function displayData(data) { +function displayData() { document.getElementById("title").value = data.title audioTable.showTracks(data.audio_tracks) subtitleTable.showTracks(data.subtitle_tracks) } +function setDirty() { + dirty = true + document.getElementById("unsaved").classList.add("show") +} + +function save(filename) { + fetch(`/api/file/${filename}`, { + method: "POST", + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json" + } + }).then(res => { + if (res.ok) { + dirty = false + document.getElementById("unsaved").classList.remove("show") + } else { + alert(`Error ${res.status}: ${res.statusText}`) + } + }) +} + +function editTrack(listKey, trackIdx, key, value) { + const keyParts = key.split("/") + let obj = data[listKey][trackIdx] + for (const part of keyParts.slice(0, -1)) { + obj = obj[part] + } + obj[keyParts[keyParts.length - 1]] = value + setDirty() +} + window.addEventListener("load", () => { audioTable = new TracksTable(document.getElementById("audio-tracks"), (i, key, value) => { - console.log(i, key, value) + editTrack("audio_tracks", i, key, value) }) subtitleTable = new TracksTable(document.getElementById("subtitle-tracks"), (i, key, value) => { - console.log(i, key, value) + editTrack("subtitle_tracks", i, key, value) }) const params = new URLSearchParams(window.location.search) @@ -192,4 +219,13 @@ window.addEventListener("load", () => { document.getElementById("filename").innerText = file fetchData(file) +}) + +window.addEventListener("keydown", e => { + if (e.key === "s" && e.ctrlKey) { + e.preventDefault() + const params = new URLSearchParams(window.location.search) + const file = params.get("f") + save(file) + } }) \ No newline at end of file diff --git a/editor/server.py b/editor/server.py index 1133468..5d67811 100644 --- a/editor/server.py +++ b/editor/server.py @@ -4,9 +4,10 @@ import json import os import socketserver from typing import Optional -from urllib.parse import urlparse, parse_qs +from urllib.parse import urlparse, parse_qs, unquote PORT = 8000 +MAX_SIZE = 10e6 class MyHandler(SimpleHTTPRequestHandler): DATA_DIR = "metadata" @@ -23,7 +24,12 @@ class MyHandler(SimpleHTTPRequestHandler): def read_body_data(self): self.log_message("Reading body data") try: - raw_data = self.rfile.read(int(self.headers["Content-Length"])) + size: int = int(self.headers["Content-Length"]) + if size > MAX_SIZE: + self.send_error(HTTPStatus.CONTENT_TOO_LARGE) + self.log_error(f"Payload is too big ({MAX_SIZE=}B)") + return False + raw_data = self.rfile.read(size) self.data = json.loads(raw_data) except: self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body") @@ -32,6 +38,7 @@ class MyHandler(SimpleHTTPRequestHandler): return True def do_GET(self): + self.path = unquote(self.path) self.query = parse_qs(urlparse(self.path).query) if self.path.startswith("/api/"): self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/")) @@ -39,6 +46,7 @@ class MyHandler(SimpleHTTPRequestHandler): super().do_GET() def do_POST(self): + self.path = unquote(self.path) self.query = parse_qs(urlparse(self.path).query) if self.path.startswith("/api/"): self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/")) @@ -50,21 +58,32 @@ class MyHandler(SimpleHTTPRequestHandler): if path == "files": files: list[str] = self.get_files() self.send_json(files) - return + elif path.startswith("file"): + filename: str = path.split("/", 1)[1] + data = self.read_file(filename) + if data is None: + self.send_error(HTTPStatus.NOT_FOUND) + self.log_message("File not found") + else: + self.log_message("Got file") + self.send_json(data) + else: + self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}") + self.end_headers() def handle_api_post(self, path: str): - if path == "file": + if path.startswith("file"): if self.read_body_data(): - data = self.get_file(self.data["file"]) - if data is None: - self.send_error(HTTPStatus.NOT_FOUND) - self.log_message("File not found") - else: - self.log_message("Got file") - self.send_json(data) + filename: str = path.split("/", 1)[1] + if self.write_file(filename, self.data): + self.send_response(HTTPStatus.OK) + self.end_headers() + else: + self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}") + self.end_headers() def send_json(self, data: dict|list): - self.send_response(200) + self.send_response(HTTPStatus.OK) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(data).encode("utf-8")) @@ -72,13 +91,26 @@ class MyHandler(SimpleHTTPRequestHandler): def get_files(self): return os.listdir(self.DATA_DIR) - def get_file(self, filename: str) -> Optional[dict|list]: + def read_file(self, filename: str) -> Optional[dict|list]: if filename not in self.get_files(): return None with open(os.path.join(self.DATA_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(): + self.send_error(HTTPStatus.NOT_FOUND) + return False + + try: + with open(os.path.join(self.DATA_DIR, filename), "w") as f: + json.dump(data, f, indent=2) + except: + self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR) + return False + return True + def main(): with socketserver.TCPServer(("", PORT), MyHandler) as httpd: -- 2.47.2 From 82d02cfe766af4f92c426c45f75bdd1b3d1530bd Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Tue, 29 Apr 2025 18:29:52 +0200 Subject: [PATCH 03/11] refactor: split editor in JS modules --- editor/public/edit/index.html | 19 +- editor/public/edit/index.js | 231 ------------------ editor/public/index.html | 3 +- editor/public/{ => static/css}/base.css | 0 .../{edit/index.css => static/css/edit.css} | 25 ++ editor/public/static/js/edit.mjs | 6 + editor/public/static/js/editor.mjs | 85 +++++++ editor/public/{ => static/js}/index.js | 0 editor/public/static/js/integrity_manager.mjs | 83 +++++++ editor/public/static/js/tracks_table.mjs | 172 +++++++++++++ editor/public/static/js/utils.mjs | 48 ++++ 11 files changed, 437 insertions(+), 235 deletions(-) delete mode 100644 editor/public/edit/index.js rename editor/public/{ => static/css}/base.css (100%) rename editor/public/{edit/index.css => static/css/edit.css} (75%) create mode 100644 editor/public/static/js/edit.mjs create mode 100644 editor/public/static/js/editor.mjs rename editor/public/{ => static/js}/index.js (100%) create mode 100644 editor/public/static/js/integrity_manager.mjs create mode 100644 editor/public/static/js/tracks_table.mjs create mode 100644 editor/public/static/js/utils.mjs diff --git a/editor/public/edit/index.html b/editor/public/edit/index.html index a394faa..3ede78f 100644 --- a/editor/public/edit/index.html +++ b/editor/public/edit/index.html @@ -4,9 +4,9 @@ Edit - - - + + +
@@ -44,5 +44,18 @@ + \ No newline at end of file diff --git a/editor/public/edit/index.js b/editor/public/edit/index.js deleted file mode 100644 index 824f3d1..0000000 --- a/editor/public/edit/index.js +++ /dev/null @@ -1,231 +0,0 @@ -/** @type TracksTable */ -let audioTable - -/** @type TracksTable */ -let subtitleTable - -let data = {} - -function flattenObj(obj) { - const res = {} - Object.entries(obj).forEach(([key, value]) => { - if (typeof value === "object") { - value = flattenObj(value) - Object.entries(value).forEach(([key2, value2]) => { - res[key + "/" + key2] = value2 - }) - } else { - res[key] = value - } - }) - return res -} - -class TracksTable { - OPTIONS = { - "language": ["fre", "eng"] - } - CONSTRAINTS = { - "flags/default": { - type: "hotone" - } - } - - constructor(table, callback) { - this.table = table - this.headers = this.table.querySelector("thead tr") - this.body = this.table.querySelector("tbody") - this.fields = [] - this.tracks = [] - this.callback = callback - this.hotones = {} - } - - showTracks(tracks) { - this.tracks = tracks.map(flattenObj) - this.clear() - if (tracks.length === 0) { - return - } - this.detectFields() - this.addHeaders() - this.tracks.forEach((track, i) => { - const tr = document.createElement("tr") - tr.dataset.i = i - this.fields.forEach(field => { - const td = tr.insertCell(-1) - const input = this.makeInput(field, track[field.key], i) - td.appendChild(input) - }) - this.body.appendChild(tr) - }) - } - - clear() { - this.headers.innerHTML = "" - this.body.innerHTML = "" - this.fields = [] - } - - detectFields() { - Object.entries(this.tracks[0]).forEach(([key, value]) => { - let type = { - boolean: "bool", - number: "num" - }[typeof value] ?? "str" - if (key === "language") { - type = "sel" - } - - const name = key.split("/").slice(-1)[0] - this.fields.push({name, type, key}) - }) - } - - addHeaders() { - this.fields.forEach(field => { - const th = document.createElement("th") - th.innerText = field.name - this.headers.appendChild(th) - }) - } - - makeInput(field, value, trackIdx) { - let input = document.createElement("input") - switch (field.type) { - case "num": - input.type = "number" - input.value = value - break - - case "str": - input.type = "text" - input.value = value - break - - case "bool": - input.type = "checkbox" - input.checked = value - const hotone = this.CONSTRAINTS[field.key]?.type == "hotone" - - if (hotone) { - if (value) { - if (field.key in this.hotones) { - alert("Error in metadata file: field ${field.name} is hotone but multiple tracks are enabled ") - } - this.hotones[field.key] = input - } - input.addEventListener("click", e => { - console.log("HOTONE") - if (!input.checked) { - console.log("Already checked, preventing uncheck") - e.preventDefault() - } else { - if (field.key in this.hotones) { - this.hotones[field.key].checked = false - this.hotones[field.key].dispatchEvent(new Event("change")) - } - this.hotones[field.key] = input - } - }) - } - input.addEventListener("change", () => { - this.callback(trackIdx, field.key, input.checked) - }) - break - - case "sel": - input = document.createElement("select") - const options = this.OPTIONS[field.name] - options.forEach(option => { - const opt = document.createElement("option") - opt.innerText = option - opt.value = option - input.appendChild(opt) - }) - input.value = value - - default: - break - } - input.dataset.key = field.key - return input - } -} - -function fetchData(filename) { - fetch(`/api/file/${filename}`).then(res => { - if (res.ok) { - return res.json() - } - return null - }).then(res => { - if (res !== null) { - data = res - displayData() - } - }) -} - -function displayData() { - document.getElementById("title").value = data.title - - audioTable.showTracks(data.audio_tracks) - subtitleTable.showTracks(data.subtitle_tracks) -} - -function setDirty() { - dirty = true - document.getElementById("unsaved").classList.add("show") -} - -function save(filename) { - fetch(`/api/file/${filename}`, { - method: "POST", - body: JSON.stringify(data), - headers: { - "Content-Type": "application/json" - } - }).then(res => { - if (res.ok) { - dirty = false - document.getElementById("unsaved").classList.remove("show") - } else { - alert(`Error ${res.status}: ${res.statusText}`) - } - }) -} - -function editTrack(listKey, trackIdx, key, value) { - const keyParts = key.split("/") - let obj = data[listKey][trackIdx] - for (const part of keyParts.slice(0, -1)) { - obj = obj[part] - } - obj[keyParts[keyParts.length - 1]] = value - setDirty() -} - -window.addEventListener("load", () => { - audioTable = new TracksTable(document.getElementById("audio-tracks"), (i, key, value) => { - editTrack("audio_tracks", i, key, value) - }) - subtitleTable = new TracksTable(document.getElementById("subtitle-tracks"), (i, key, value) => { - editTrack("subtitle_tracks", i, key, value) - }) - - const params = new URLSearchParams(window.location.search) - const file = params.get("f") - document.getElementById("filename").innerText = file - - fetchData(file) -}) - -window.addEventListener("keydown", e => { - if (e.key === "s" && e.ctrlKey) { - e.preventDefault() - const params = new URLSearchParams(window.location.search) - const file = params.get("f") - save(file) - } -}) \ No newline at end of file diff --git a/editor/public/index.html b/editor/public/index.html index cdf3ce5..02317a6 100644 --- a/editor/public/index.html +++ b/editor/public/index.html @@ -4,7 +4,8 @@ Metadata Editor - + +

Metadata Editor

diff --git a/editor/public/base.css b/editor/public/static/css/base.css similarity index 100% rename from editor/public/base.css rename to editor/public/static/css/base.css diff --git a/editor/public/edit/index.css b/editor/public/static/css/edit.css similarity index 75% rename from editor/public/edit/index.css rename to editor/public/static/css/edit.css index ccf53d9..7e72e24 100644 --- a/editor/public/edit/index.css +++ b/editor/public/static/css/edit.css @@ -65,4 +65,29 @@ input[type="checkbox"] { background-color: #6ee74a; } } +} + +.popup { + display: grid; + place-items: center; + position: fixed; + inset: 0; + background-color: #5c5c5c5c; + + &:not(.show) { + display: none; + } + + .container { + padding: 1.2em; + border-radius: 0.4em; + background-color: white; + display: flex; + flex-direction: column; + gap: 0.8em; + + .title { + text-align: center; + } + } } \ No newline at end of file diff --git a/editor/public/static/js/edit.mjs b/editor/public/static/js/edit.mjs new file mode 100644 index 0000000..1e1063c --- /dev/null +++ b/editor/public/static/js/edit.mjs @@ -0,0 +1,6 @@ +import Editor from "./editor.mjs"; + +window.addEventListener("load", () => { + const editor = new Editor() + window.editor = editor +}) diff --git a/editor/public/static/js/editor.mjs b/editor/public/static/js/editor.mjs new file mode 100644 index 0000000..b10a413 --- /dev/null +++ b/editor/public/static/js/editor.mjs @@ -0,0 +1,85 @@ +import TracksTable from "./tracks_table.mjs" +import IntegrityManager from "./integrity_manager.mjs" + +export default class Editor { + constructor() { + const params = new URLSearchParams(window.location.search) + this.filename = params.get("f") + window.addEventListener("keydown", e => { + if (e.key === "s" && e.ctrlKey) { + e.preventDefault() + this.save() + } + }) + + this.tables = { + audio: new TracksTable(this, "audio", "audio-tracks", "audio_tracks"), + subtitle: new TracksTable(this, "subtitle", "subtitle-tracks", "subtitle_tracks") + } + + this.data = {} + this.dirty = false + + this.integrity_mgr = new IntegrityManager(this) + + this.setup() + } + + setup() { + document.getElementById("filename").innerText = this.filename + this.fetchData() + } + + fetchData() { + fetch(`/api/file/${this.filename}`).then(res => { + if (res.ok) { + return res.json() + } + return null + }).then(res => { + if (res !== null) { + this.data = res + this.displayData() + } + }) + } + + displayData() { + document.getElementById("title").value = this.data.title + this.tables.audio.loadTracks(this.data.audio_tracks) + this.tables.subtitle.loadTracks(this.data.subtitle_tracks) + this.integrity_mgr.checkIntegrity() + } + + save() { + fetch(`/api/file/${this.filename}`, { + method: "POST", + body: JSON.stringify(this.data), + headers: { + "Content-Type": "application/json" + } + }).then(res => { + if (res.ok) { + this.dirty = false + document.getElementById("unsaved").classList.remove("show") + } else { + alert(`Error ${res.status}: ${res.statusText}`) + } + }) + } + + setDirty() { + this.dirty = true + document.getElementById("unsaved").classList.add("show") + } + + editTrack(listKey, trackIdx, key, value) { + const keyParts = key.split("/") + let obj = this.data[listKey][trackIdx] + for (const part of keyParts.slice(0, -1)) { + obj = obj[part] + } + obj[keyParts[keyParts.length - 1]] = value + this.setDirty() + } +} \ No newline at end of file diff --git a/editor/public/index.js b/editor/public/static/js/index.js similarity index 100% rename from editor/public/index.js rename to editor/public/static/js/index.js diff --git a/editor/public/static/js/integrity_manager.mjs b/editor/public/static/js/integrity_manager.mjs new file mode 100644 index 0000000..144fad3 --- /dev/null +++ b/editor/public/static/js/integrity_manager.mjs @@ -0,0 +1,83 @@ +import { Track } from "./tracks_table.mjs" +import { findLanguage, isLanguageAlias, flattenObj } from "./utils.mjs" + +class Mismatch { + constructor(track, key, value) { + this.track = track + this.key = key + this.value = value + } +} + +export default class IntegrityManager { + /** + * + * @param {import('./editor.mjs').default} editor + */ + constructor(editor) { + this.editor = editor + this.mismatches = [] + } + + checkIntegrity() { + for (const table of Object.values(this.editor.tables)) { + this.checkTableIntegrity(table) + } + console.log(this.mismatches) + } + + /** + * + * @param {import('./tracks_table.mjs').default} table + */ + checkTableIntegrity(table) { + for (const track of table.tracks) { + this.checkTrackIntegrity(track) + } + } + + /** + * + * @param {Track} track + */ + checkTrackIntegrity(track) { + let fields = this.parseName(track.table.type, track.fields["name"]) + fields = flattenObj(fields) + + Object.entries(fields).map(([key, value]) => { + let equal = track.fields[key] === value + if (key === "language") { + equal = isLanguageAlias(value, track.fields[key]) + } + if (!equal) { + this.addMismatchField(track, key, value) + console.error(`Mismatch for field ${key}:\n- name: ${value}\n- track: ${track.fields[key]}`) + } else { + track.fields[key] = value + } + }) + } + + parseName(trackType, name) { + const lower = name.toLowerCase() + const parts = lower.split(/\b/) + const fields = {flags: {}} + switch (trackType) { + case "subtitle": + let forced = parts.includes("forced") + let lang = findLanguage(lower) + if (forced) {fields.flags.forced = forced} + if (lang !== null) {fields.language = lang} + let ad = parts.includes("sdh") || parts.includes("ad") + if (ad) {fields.flags.hearing_impaired = ad} + let original = parts.includes("vo") + if (original) {fields.flags.original = original} + break + } + return fields + } + + addMismatchField(track, key, value) { + this.mismatches.push(new Mismatch(track, key, value)) + } +} \ No newline at end of file diff --git a/editor/public/static/js/tracks_table.mjs b/editor/public/static/js/tracks_table.mjs new file mode 100644 index 0000000..bee0e32 --- /dev/null +++ b/editor/public/static/js/tracks_table.mjs @@ -0,0 +1,172 @@ +import { flattenObj } from "./utils.mjs" + +export class Track { + constructor(table, idx, fields) { + this.table = table + this.idx = idx + this.fields = flattenObj(fields) + } + + makeRow() { + const tr = document.createElement("tr") + tr.dataset.i = this.idx + this.table.fields.forEach(field => { + const td = tr.insertCell(-1) + const input = this.makeInput(field, this.fields[field.key], this.idx) + td.appendChild(input) + }) + return tr + } + + makeInput(field, value) { + let input = document.createElement("input") + let getValue = () => input.value + switch (field.type) { + case "num": + input.type = "number" + input.value = value + getValue = () => +input.value + break + + case "str": + input.type = "text" + input.value = value + break + + case "bool": + input.type = "checkbox" + input.checked = value + getValue = () => input.checked + const hotone = this.table.CONSTRAINTS[field.key]?.type == "hotone" + + if (hotone) { + if (value) { + if (field.key in this.table.hotones) { + alert(`Error in metadata file: field ${field.name} is hotone but multiple tracks are enabled`) + } + this.table.hotones[field.key] = input + } + input.addEventListener("click", e => { + if (!input.checked) { + e.preventDefault() + } else { + if (field.key in this.table.hotones) { + this.table.hotones[field.key].checked = false + this.table.hotones[field.key].dispatchEvent(new Event("change")) + } + this.table.hotones[field.key] = input + } + }) + } + break + + case "sel": + input = document.createElement("select") + const options = this.table.OPTIONS[field.key] + options.forEach(option => { + const opt = document.createElement("option") + opt.innerText = option + opt.value = option + input.appendChild(opt) + }) + input.value = value + + default: + break + } + input.dataset.key = field.key + if (this.table.CONSTRAINTS[field.key]?.type === "readonly") { + input.disabled = true + } + input.addEventListener("change", () => { + this.editValue(field.key, getValue()) + }) + return input + } + + editValue(key, value) { + this.fields[key] = value + this.table.editTrack(this.idx, key, value) + } +} + +export default class TracksTable { + OPTIONS = { + "language": ["fre", "eng"] + } + CONSTRAINTS = { + "flags/default": { + type: "hotone" + }, + "index": { + type: "readonly" + }, + "channels": { + type: "readonly" + } + } + + /** + * @param {import('./editor.mjs').default} editor The parent editor + * @param {string} type The type of tracks. One of `['audio', 'subtitle']` + * @param {string} tableId The id of the table element + * @param {string} dataKey The key of the tracks list inside of the data object + */ + constructor(editor, type, tableId, dataKey) { + this.editor = editor + this.type = type + this.table = document.getElementById(tableId) + this.headers = this.table.querySelector("thead tr") + this.body = this.table.querySelector("tbody") + this.fields = [] + this.tracks = [] + this.dataKey = dataKey + this.hotones = {} + } + + loadTracks(tracks) { + this.tracks = tracks.map((t, i) => new Track(this, i, t)) + this.clear() + if (tracks.length === 0) { + return + } + this.detectFields() + this.addHeaders() + this.tracks.forEach(track => { + this.body.appendChild(track.makeRow()) + }) + } + + clear() { + this.headers.innerHTML = "" + this.body.innerHTML = "" + this.fields = [] + } + + detectFields() { + Object.entries(this.tracks[0].fields).forEach(([key, value]) => { + let type = { + boolean: "bool", + number: "num" + }[typeof value] ?? "str" + if (key === "language") { + type = "sel" + } + + const name = key.split("/").slice(-1)[0] + this.fields.push({name, type, key}) + }) + } + + addHeaders() { + this.fields.forEach(field => { + const th = document.createElement("th") + th.innerText = field.name + this.headers.appendChild(th) + }) + } + + editTrack(trackIdx, fieldKey, fieldValue) { + this.editor.editTrack(this.dataKey, trackIdx, fieldKey, fieldValue) + } +} diff --git a/editor/public/static/js/utils.mjs b/editor/public/static/js/utils.mjs new file mode 100644 index 0000000..d69e01b --- /dev/null +++ b/editor/public/static/js/utils.mjs @@ -0,0 +1,48 @@ +/** + * Flattens an object recursively. Nested keys are joined with slashes ('/') + * @param {object} obj The object to flatten + * @returns {object} The flattened object + */ +export function flattenObj(obj) { + const res = {} + Object.entries(obj).forEach(([key, value]) => { + if (typeof value === "object") { + value = flattenObj(value) + Object.entries(value).forEach(([key2, value2]) => { + res[key + "/" + key2] = value2 + }) + } else { + res[key] = value + } + }) + return res +} + +export const LANGUAGES = { + "fr": ["fre", "fra", "french", "francais", "français", "vf", "vff"], + "fr-ca": ["vfq", "quebec", "québec"], + "en": ["eng", "ang", "english", "anglais"], + "de": ["deu", "ger", "german", "allemand"], + "ko": ["kor", "cor", "korean", "coreen", "coréen"], + "ja": ["jap", "japanese", "japonais"] +} + +/** + * Tries to find a language name in the given string + * @param {string} value The string in which to search for a language + * @returns {?string} The language key if it could be determined, null otherwise + */ +export function findLanguage(value) { + for (const lang in LANGUAGES) { + const aliases = [lang].concat(LANGUAGES[lang]) + const matches = aliases.map(a => new RegExp("\\b" + a + "\\b").test(value)).some(v => v) + if (matches) { + return lang + } + } + return null +} + +export function isLanguageAlias(langKey, value) { + return [langKey].concat(LANGUAGES[langKey]).includes(value) +} \ No newline at end of file -- 2.47.2 From acf7b5047f30d88dff2ab66a3f4ab57f3e6fd3e4 Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Wed, 30 Apr 2025 00:22:43 +0200 Subject: [PATCH 04/11] feat: add basic integrity checks + corrections --- editor/public/edit/index.html | 54 +++- editor/public/static/css/edit.css | 159 ++++++++++- editor/public/static/images/arrow.svg | 82 ++++++ editor/public/static/js/edit.mjs | 2 + editor/public/static/js/editor.mjs | 8 +- editor/public/static/js/integrity_manager.mjs | 266 ++++++++++++++++-- editor/public/static/js/tracks_table.mjs | 55 +++- editor/public/static/js/utils.mjs | 86 +++++- 8 files changed, 656 insertions(+), 56 deletions(-) create mode 100644 editor/public/static/images/arrow.svg diff --git a/editor/public/edit/index.html b/editor/public/edit/index.html index 3ede78f..06808cb 100644 --- a/editor/public/edit/index.html +++ b/editor/public/edit/index.html @@ -44,18 +44,56 @@ - + + +
\ No newline at end of file diff --git a/editor/public/index.html b/editor/public/index.html index 02317a6..63b9471 100644 --- a/editor/public/index.html +++ b/editor/public/index.html @@ -8,7 +8,14 @@ -

Metadata Editor

- +
+

Metadata Editor

+
+
+
+ + +
+
\ No newline at end of file diff --git a/editor/public/static/css/base.css b/editor/public/static/css/base.css index e229348..3ba2c02 100644 --- a/editor/public/static/css/base.css +++ b/editor/public/static/css/base.css @@ -1,13 +1,107 @@ * { - /*padding: 0;*/ - /*margin: 0;*/ + margin: 0; box-sizing: border-box; } +html, body { + height: 100%; +} + body { font-family: Ubuntu; + margin: 0; + padding: 0; } .template { display: none !important; +} + +main { + padding: 1.2em; +} + +header { + background-color: #2b2b2b; + padding: 1.2em; + grid-area: header; + display: flex; + gap: 0.8em; + color: white; + + a, button { + padding: 0.4em 0.8em; + border: none; + color: black; + background-color: #e4e4e4; + font-size: inherit; + font-family: inherit; + text-decoration: none; + border-radius: 0.2em; + cursor: pointer; + + &:hover { + background-color: #dbdbdb; + } + } +} + +aside { + position: fixed; + right: 0; + top: 0; + bottom: 0; + background-color: white; + border-left: solid black 2px; +} + +.notif { + --bg: #f0f0f0; + --border: #727272; + --fg: black; + --col: #f0f0f0; + + &[data-type="success"] { + --bg: #e0ffe0; + --border: #727f72; + --fg: black; + --col: #8dff8d; + } + + &[data-type="error"] { + --bg: #ffe0e0; + --border: #7f7272; + --fg: black; + --col: #ff8d8d; + } + + &[data-type="warning"] { + --bg: #ffefe0; + --border: #7f7f72; + --fg: black; + --col: #ffc36a; + } +} + +#notifs { + position: fixed; + top: 0; + left: 0; + right: 0; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 0.4em; + padding-top: 0.4em; + width: max-content; + + .notif { + padding: 0.4em 0.8em; + border-radius: 0.6em; + background-color: var(--bg); + border: solid var(--border) 2px; + color: var(--fg); + max-width: 30em; + cursor: pointer; + } } \ No newline at end of file diff --git a/editor/public/static/css/edit.css b/editor/public/static/css/edit.css index b67e06d..0b80eba 100644 --- a/editor/public/static/css/edit.css +++ b/editor/public/static/css/edit.css @@ -1,3 +1,13 @@ +main { + display: flex; + flex-direction: column; + gap: 1.2em; +} + +#toggle-notifs { + margin-left: auto; +} + #filename { font-size: 80%; font-style: italic; @@ -117,6 +127,8 @@ button.improve { transform: translateY(-50%); margin-left: 0.4em; border-radius: 0.4em; + background: none; + &:hover{ background-color: #8d8d8d42; } @@ -318,4 +330,43 @@ button.improve { padding: 0.2em 0.4em; background-color: #fafafa; } +} + +#notifs-hist { + padding: 0.8em; + height: 100%; + display: flex; + flex-direction: column; + + &:not(.show) { + display: none; + } + + #close-notifs { + align-self: flex-end; + background: none; + border: none; + padding: 0.4em 0.8em; + border-radius: 0.2em; + font-family: inherit; + font-size: inherit; + cursor: pointer; + + &:hover { + background-color: #ebebeb; + } + } + + .list { + display: flex; + flex-direction: column; + gap: 0.2em; + overflow-y: auto; + margin-top: 0.6em; + + .notif { + border-left: solid var(--col) 4px; + padding: 0.4em; + } + } } \ No newline at end of file diff --git a/editor/public/static/js/editor.mjs b/editor/public/static/js/editor.mjs index 693b51d..ba62581 100644 --- a/editor/public/static/js/editor.mjs +++ b/editor/public/static/js/editor.mjs @@ -23,6 +23,13 @@ export default class Editor { this.integrity_mgr = new IntegrityManager(this) + document.getElementById("check-integrity").addEventListener("click", () => this.checkIntegrity()) + document.getElementById("improve-all").addEventListener("click", () => this.improveAllNames()) + document.getElementById("save").addEventListener("click", () => this.save()) + document.getElementById("reload").addEventListener("click", () => window.location.reload()) + document.getElementById("toggle-notifs").addEventListener("click", () => this.toggleNotifications()) + document.getElementById("close-notifs").addEventListener("click", () => this.closeNotifications()) + this.setup() } @@ -49,7 +56,6 @@ export default class Editor { document.getElementById("title").value = this.data.title this.tables.audio.loadTracks(this.data.audio_tracks) this.tables.subtitle.loadTracks(this.data.subtitle_tracks) - this.integrity_mgr.checkIntegrity() } save() { @@ -63,8 +69,9 @@ export default class Editor { if (res.ok) { this.dirty = false document.getElementById("unsaved").classList.remove("show") + this.notify("Saved successfully !", "success") } else { - alert(`Error ${res.status}: ${res.statusText}`) + this.notify(`Error ${res.status}: ${res.statusText}`, "error", 10000) } }) } @@ -78,4 +85,47 @@ export default class Editor { updateObjectFromJoinedKey(this.data[listKey][trackIdx], key, value) this.setDirty() } + + notify(text, type, duration=5000) { + const list = document.getElementById("notifs") + const hist = document.getElementById("notifs-hist").querySelector(".list") + const notif = document.createElement("div") + notif.classList.add("notif") + notif.dataset.type = type + notif.innerText = text + list.appendChild(notif) + setTimeout(() => notif.remove(), duration) + notif.addEventListener("click", () => notif.remove()) + hist.prepend(notif.cloneNode(true)) + } + + checkIntegrity() { + if (this.integrity_mgr.checkIntegrity()) { + this.notify("No integrity error detected !", "success") + } + } + + improveAllNames() { + this.integrity_mgr.improveAllNames() + this.notify("Improved all names !", "success") + } + + toggleNotifications() { + const hist = document.getElementById("notifs-hist") + if (hist.classList.contains("show")) { + this.closeNotifications() + } else { + this.openNotifications() + } + } + + openNotifications() { + const hist = document.getElementById("notifs-hist") + hist.classList.add("show") + } + + closeNotifications() { + const hist = document.getElementById("notifs-hist") + hist.classList.remove("show") + } } \ No newline at end of file diff --git a/editor/public/static/js/integrity_manager.mjs b/editor/public/static/js/integrity_manager.mjs index beb9046..cc568c0 100644 --- a/editor/public/static/js/integrity_manager.mjs +++ b/editor/public/static/js/integrity_manager.mjs @@ -78,7 +78,7 @@ class MismatchCorrection { export default class IntegrityManager { IGNORE_KEYS = [ - "type", "channels" + "type", "channels_details" ] /** @@ -136,11 +136,17 @@ export default class IntegrityManager { } checkIntegrity() { + this.ignoreList = [] + this.mismatches = [] for (const table of Object.values(this.editor.tables)) { this.checkTableIntegrity(table) } + if (this.mismatches.length === 0) { + return true + } this.nextError() + return false } /** @@ -198,7 +204,7 @@ export default class IntegrityManager { const channels = lower.match(/\d+\.\d+/) if (channels) { - fields.channels = channels[0] + fields.channels_details = channels[0] } break @@ -297,8 +303,8 @@ export default class IntegrityManager { if (fields.flags.visual_impaired) { name += " AD" } - if (fields.channels) { - name += " / " + fields.channels + if (fields.channels_details) { + name += " / " + fields.channels_details } break case "subtitle": @@ -345,6 +351,14 @@ export default class IntegrityManager { } return input } + + improveAllNames() { + for (const table of Object.values(this.editor.tables)) { + for (const track of table.tracks) { + this.improveName(track) + } + } + } /** * diff --git a/editor/public/static/js/tracks_table.mjs b/editor/public/static/js/tracks_table.mjs index 7caeec3..34474b2 100644 --- a/editor/public/static/js/tracks_table.mjs +++ b/editor/public/static/js/tracks_table.mjs @@ -1,4 +1,4 @@ -import { flattenObj, getLanguageOptions } from "./utils.mjs" +import { findLanguage, flattenObj, getLanguageOptions } from "./utils.mjs" export class Track { constructor(table, idx, fields) { @@ -54,29 +54,36 @@ export class Track { case "bool": input.type = "checkbox" - input.checked = value - getValue = () => input.checked - const hotone = this.table.CONSTRAINTS[field.key]?.type == "hotone" - if (listeners && hotone) { + getValue = () => input.checked + const onehot = this.table.CONSTRAINTS[field.key]?.type == "onehot" + + if (listeners && onehot) { if (value) { - if (field.key in this.table.hotones) { - alert(`Error in metadata file: field ${field.name} is hotone but multiple tracks are enabled`) + if (field.key in this.table.onehots) { + this.table.editor.notify( + `Error in metadata file: field '${field.name}' is onehot but multiple tracks are enabled. Only the first one will be enabled`, + "error", + 20000 + ) + value = false + } else { + this.table.onehots[field.key] = input } - this.table.hotones[field.key] = input } input.addEventListener("click", e => { if (!input.checked) { e.preventDefault() } else { - if (field.key in this.table.hotones) { - this.table.hotones[field.key].checked = false - this.table.hotones[field.key].dispatchEvent(new Event("change")) + if (field.key in this.table.onehots) { + this.table.onehots[field.key].checked = false + this.table.onehots[field.key].dispatchEvent(new Event("change")) } - this.table.hotones[field.key] = input + this.table.onehots[field.key] = input } }) } + input.checked = value break case "sel": @@ -91,11 +98,31 @@ export class Track { opt.value = option.value input.appendChild(opt) }) + + if (field.key === "language") { + const lang = findLanguage(value) + if (lang === null) { + this.table.editor.notify( + `Unknown language '${value}' for ${this.table.type} track ${this.idx}`, + "error", + 20000 + ) + } else if (lang !== value) { + this.table.editor.notify( + `Language of ${this.table.type} track ${this.idx} was corrected (${value} -> ${lang})`, + "warning" + ) + value = lang + } + } + + input.value = value default: break } + input.name = field.key + "[]" input.dataset.key = field.key if (this.table.CONSTRAINTS[field.key]?.type === "readonly") { input.disabled = true @@ -135,7 +162,7 @@ export default class TracksTable { } CONSTRAINTS = { "flags/default": { - type: "hotone" + type: "onehot" }, "index": { type: "readonly" @@ -160,7 +187,7 @@ export default class TracksTable { this.fields = [] this.tracks = [] this.dataKey = dataKey - this.hotones = {} + this.onehots = {} } getFieldProps(key) { diff --git a/editor/public/static/js/utils.mjs b/editor/public/static/js/utils.mjs index ea7a606..e771bbb 100644 --- a/editor/public/static/js/utils.mjs +++ b/editor/public/static/js/utils.mjs @@ -75,10 +75,10 @@ export function getLanguageAliases(langTag) { export function findLanguage(value) { for (const lang in LANGUAGES) { const aliases = getLanguageAliases(lang) - const matches = aliases.map(a => { + const matches = aliases.some(a => { return new RegExp("\\b" + a + "\\b").test(value) }) - if (matches.some(v => v)) { + if (matches) { return lang } } -- 2.47.2 From ed3c6d7cc77999d44442e901b4e3793991028986 Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Wed, 30 Apr 2025 21:16:34 +0200 Subject: [PATCH 08/11] feat: add support for series --- editor/public/edit/index.html | 6 ++ editor/public/static/css/edit.css | 41 ++++++++++++ editor/public/static/images/next.svg | 64 ++++++++++++++++++ editor/public/static/images/prev.svg | 64 ++++++++++++++++++ editor/public/static/js/editor.mjs | 51 ++++++++++++-- editor/public/static/js/index.js | 2 +- editor/public/static/js/media_file.mjs | 5 ++ editor/public/static/js/metadata.mjs | 85 ++++++++++++++++++++++++ editor/public/static/js/tracks_table.mjs | 32 +++++---- editor/public/static/js/utils.mjs | 7 +- editor/server.py | 9 +-- 11 files changed, 339 insertions(+), 27 deletions(-) create mode 100644 editor/public/static/images/next.svg create mode 100644 editor/public/static/images/prev.svg create mode 100644 editor/public/static/js/media_file.mjs create mode 100644 editor/public/static/js/metadata.mjs diff --git a/editor/public/edit/index.html b/editor/public/edit/index.html index 696fa3e..8fc6535 100644 --- a/editor/public/edit/index.html +++ b/editor/public/edit/index.html @@ -23,6 +23,12 @@

Editing - Unsaved

+
+ +
+ +
+
diff --git a/editor/public/static/css/edit.css b/editor/public/static/css/edit.css index 0b80eba..4d57952 100644 --- a/editor/public/static/css/edit.css +++ b/editor/public/static/css/edit.css @@ -337,6 +337,7 @@ button.improve { height: 100%; display: flex; flex-direction: column; + max-width: 30em; &:not(.show) { display: none; @@ -369,4 +370,44 @@ button.improve { padding: 0.4em; } } +} + +.sep { + border-bottom: solid black 1px; +} + +#series-toolbar { + display: flex; + gap: 0.4em; + padding: 0.4em; + align-items: center; + + &:not(.show) { + display: none; + } + + button { + background: var(--img); + width: 2.4em; + height: 2.4em; + background-color: transparent; + border: none; + background-size: 80%; + background-position: center; + background-repeat: no-repeat; + cursor: pointer; + border-radius: 0.4em; + + &:hover { + background-color: #f1f1f1; + } + + &#prev-episode { + --img: url("/static/images/prev.svg"); + } + + &#next-episode { + --img: url("/static/images/next.svg"); + } + } } \ No newline at end of file diff --git a/editor/public/static/images/next.svg b/editor/public/static/images/next.svg new file mode 100644 index 0000000..db48ec2 --- /dev/null +++ b/editor/public/static/images/next.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + diff --git a/editor/public/static/images/prev.svg b/editor/public/static/images/prev.svg new file mode 100644 index 0000000..4217564 --- /dev/null +++ b/editor/public/static/images/prev.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + diff --git a/editor/public/static/js/editor.mjs b/editor/public/static/js/editor.mjs index ba62581..1cfd1ea 100644 --- a/editor/public/static/js/editor.mjs +++ b/editor/public/static/js/editor.mjs @@ -1,6 +1,7 @@ import TracksTable from "./tracks_table.mjs" import IntegrityManager from "./integrity_manager.mjs" import { updateObjectFromJoinedKey } from "./utils.mjs" +import { loadMetadata, SeriesMetadata } from "./metadata.mjs" export default class Editor { constructor() { @@ -18,10 +19,11 @@ export default class Editor { subtitle: new TracksTable(this, "subtitle", "subtitle-tracks", "subtitle_tracks") } + this.metadata = null this.data = {} this.dirty = false - this.integrity_mgr = new IntegrityManager(this) + this.integrityMgr = new IntegrityManager(this) document.getElementById("check-integrity").addEventListener("click", () => this.checkIntegrity()) document.getElementById("improve-all").addEventListener("click", () => this.improveAllNames()) @@ -29,6 +31,14 @@ export default class Editor { document.getElementById("reload").addEventListener("click", () => window.location.reload()) document.getElementById("toggle-notifs").addEventListener("click", () => this.toggleNotifications()) document.getElementById("close-notifs").addEventListener("click", () => this.closeNotifications()) + document.getElementById("prev-episode").addEventListener("click", () => this.prevEpisode()) + document.getElementById("next-episode").addEventListener("click", () => this.nextEpisode()) + + this.titleInput = document.getElementById("title") + this.titleInput.addEventListener("change", () => { + this.data.title = this.titleInput.value + this.setDirty() + }) this.setup() } @@ -46,14 +56,27 @@ export default class Editor { return null }).then(res => { if (res !== null) { - this.data = res + this.metadata = loadMetadata(res) this.displayData() } }) } displayData() { - document.getElementById("title").value = this.data.title + const seriesToolbar = document.getElementById("series-toolbar") + if (this.metadata instanceof SeriesMetadata) { + seriesToolbar.classList.add("show") + const cur = this.metadata.episodeIdx + 1 + const tot = this.metadata.episodes.length + const epMeta = this.metadata.getCurrentEpisode() + const season = epMeta.season + const episode = epMeta.episode + seriesToolbar.querySelector("#cur-episode").innerText = `S${season}E${episode} (${cur} / ${tot})` + } else { + seriesToolbar.classList.remove("show") + } + this.data = this.metadata.getData() + this.titleInput.value = this.data.title this.tables.audio.loadTracks(this.data.audio_tracks) this.tables.subtitle.loadTracks(this.data.subtitle_tracks) } @@ -61,7 +84,7 @@ export default class Editor { save() { fetch(`/api/file/${this.filename}`, { method: "POST", - body: JSON.stringify(this.data), + body: JSON.stringify(this.metadata.data), headers: { "Content-Type": "application/json" } @@ -100,13 +123,13 @@ export default class Editor { } checkIntegrity() { - if (this.integrity_mgr.checkIntegrity()) { + if (this.integrityMgr.checkIntegrity()) { this.notify("No integrity error detected !", "success") } } improveAllNames() { - this.integrity_mgr.improveAllNames() + this.integrityMgr.improveAllNames() this.notify("Improved all names !", "success") } @@ -128,4 +151,20 @@ export default class Editor { const hist = document.getElementById("notifs-hist") hist.classList.remove("show") } + + prevEpisode() { + if (this.metadata instanceof SeriesMetadata) { + if (this.metadata.prev()) { + this.displayData() + } + } + } + + nextEpisode() { + if (this.metadata instanceof SeriesMetadata) { + if (this.metadata.next()) { + this.displayData() + } + } + } } \ No newline at end of file diff --git a/editor/public/static/js/index.js b/editor/public/static/js/index.js index c9c646f..71fd02d 100644 --- a/editor/public/static/js/index.js +++ b/editor/public/static/js/index.js @@ -5,7 +5,7 @@ function addOptions(files) { defaultOpt.innerText = "----- Select a file -----" defaultOpt.value = "" select.appendChild(defaultOpt) - files.forEach(file => { + files.sort().forEach(file => { const option = document.createElement("option") option.innerText = file option.value = file diff --git a/editor/public/static/js/media_file.mjs b/editor/public/static/js/media_file.mjs new file mode 100644 index 0000000..6568723 --- /dev/null +++ b/editor/public/static/js/media_file.mjs @@ -0,0 +1,5 @@ +export default class MediaFile { + constructor(data) { + + } +} \ No newline at end of file diff --git a/editor/public/static/js/metadata.mjs b/editor/public/static/js/metadata.mjs new file mode 100644 index 0000000..4eb48db --- /dev/null +++ b/editor/public/static/js/metadata.mjs @@ -0,0 +1,85 @@ +export default class Metadata { + constructor(data) { + this.data = data + } + + getData() { + return this.data + } +} + +export class MediaMetadata extends Metadata { + constructor(data) { + super(data) + } +} + +export class EpisodeMetadata extends MediaMetadata { + REGEXP = /s(?\d+)e(?\d+)/i + + /** + * + * @param {object} data + * @param {string} episodeKey + */ + constructor(data, episodeKey) { + super(data) + this.key = episodeKey + + let m = this.key.match(this.REGEXP) ?? this.data.filename.match(this.REGEXP) + this.season = "xx" + this.episode = "xx" + if (m) { + this.season = m.groups.season + this.episode = m.groups.episode + } + } +} + +export class SeriesMetadata extends Metadata { + constructor(data) { + super(data) + const episodeKeys = Object.keys(data).sort() + this.episodes = episodeKeys.map(key => { + return new EpisodeMetadata(data[key], key) + }) + + this.episodeIdx = 0 + } + + getCurrentEpisode() { + return this.episodes[this.episodeIdx] + } + + getData() { + return this.getCurrentEpisode().getData() + } + + prev() { + if (this.episodeIdx === 0) { + return false + } + this.episodeIdx -= 1 + return true + } + + next() { + if (this.episodeIdx === this.episodes.length - 1) { + return false + } + this.episodeIdx += 1 + return true + } +} + +/** + * + * @param {object} data + * @returns {Metadata} + */ +export function loadMetadata(data) { + if ("filename" in data) { + return new MediaMetadata(data) + } + return new SeriesMetadata(data) +} \ No newline at end of file diff --git a/editor/public/static/js/tracks_table.mjs b/editor/public/static/js/tracks_table.mjs index 34474b2..2069323 100644 --- a/editor/public/static/js/tracks_table.mjs +++ b/editor/public/static/js/tracks_table.mjs @@ -113,6 +113,7 @@ export class Track { "warning" ) value = lang + this.editValue(field.key, value) } } @@ -140,19 +141,21 @@ export class Track { this.table.editTrack(this.idx, key, value) const input = this.row.querySelector(`[data-key='${key}']`) - const fieldType = this.table.getFieldProps(key).type - switch (fieldType) { - case "bool": - input.checked = value - break - default: - input.value = value - break + if (input) { + const fieldType = this.table.getFieldProps(key).type + switch (fieldType) { + case "bool": + input.checked = value + break + default: + input.value = value + break + } } } improveName() { - this.table.editor.integrity_mgr.improveName(this) + this.table.editor.integrityMgr.improveName(this) } } @@ -184,9 +187,10 @@ export default class TracksTable { this.table = document.getElementById(tableId) this.headers = this.table.querySelector("thead tr") this.body = this.table.querySelector("tbody") - this.fields = [] - this.tracks = [] this.dataKey = dataKey + + this.tracks = [] + this.fields = [] this.onehots = {} } @@ -195,8 +199,8 @@ export default class TracksTable { } loadTracks(tracks) { - this.tracks = tracks.map((t, i) => new Track(this, i, t)) this.clear() + this.tracks = tracks.map((t, i) => new Track(this, i, t)) if (tracks.length === 0) { return } @@ -208,9 +212,11 @@ export default class TracksTable { } clear() { + this.tracks = [] + this.fields = [] + this.onehots = {} this.headers.innerHTML = "" this.body.innerHTML = "" - this.fields = [] } detectFields() { diff --git a/editor/public/static/js/utils.mjs b/editor/public/static/js/utils.mjs index e771bbb..3b711a3 100644 --- a/editor/public/static/js/utils.mjs +++ b/editor/public/static/js/utils.mjs @@ -41,6 +41,11 @@ export const LANGUAGES = { code: "de", aliases: ["de", "ger", "german", "allemand", "deutsch", "germany", "allemagne"] }, + "ita": { + display: "Italiano", + code: "it", + aliases: ["it", "ita", "italian", "italien", "italiano", "italy", "italie"] + }, "kor": { display: "Korean", code: "kr", @@ -64,7 +69,7 @@ export const LANGUAGES = { } export function getLanguageAliases(langTag) { - return (langTag === "und" ? [] : [langTag]).concat(LANGUAGES[langTag].aliases) + return [langTag].concat(LANGUAGES[langTag].aliases) } /** diff --git a/editor/server.py b/editor/server.py index 5d67811..2fbd994 100644 --- a/editor/server.py +++ b/editor/server.py @@ -22,7 +22,6 @@ class MyHandler(SimpleHTTPRequestHandler): self.data: Optional[dict|list] = None def read_body_data(self): - self.log_message("Reading body data") try: size: int = int(self.headers["Content-Length"]) if size > MAX_SIZE: @@ -54,7 +53,7 @@ class MyHandler(SimpleHTTPRequestHandler): self.send_error(HTTPStatus.NOT_FOUND) def handle_api_get(self, path: str): - print(f"API request at {path}") + self.log_message(f"API request at {path}") if path == "files": files: list[str] = self.get_files() self.send_json(files) @@ -63,9 +62,7 @@ class MyHandler(SimpleHTTPRequestHandler): data = self.read_file(filename) if data is None: self.send_error(HTTPStatus.NOT_FOUND) - self.log_message("File not found") else: - self.log_message("Got file") self.send_json(data) else: self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}") @@ -104,8 +101,8 @@ class MyHandler(SimpleHTTPRequestHandler): return False try: - with open(os.path.join(self.DATA_DIR, filename), "w") as f: - json.dump(data, f, indent=2) + with open(os.path.join(self.DATA_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 -- 2.47.2 From 02279b8c6fb40eb618dd1ada28daba04fe5e69d3 Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Wed, 30 Apr 2025 23:12:46 +0200 Subject: [PATCH 09/11] feat: rework home page file selector --- editor/public/index.html | 15 +- editor/public/static/css/index.css | 37 ++++ editor/public/static/images/film.svg | 148 ++++++++++++++ editor/public/static/images/series.svg | 188 ++++++++++++++++++ editor/public/static/js/index.js | 68 +++++-- editor/public/static/js/integrity_manager.mjs | 2 +- editor/server.py | 41 +++- 7 files changed, 473 insertions(+), 26 deletions(-) create mode 100644 editor/public/static/css/index.css create mode 100644 editor/public/static/images/film.svg create mode 100644 editor/public/static/images/series.svg diff --git a/editor/public/index.html b/editor/public/index.html index 63b9471..80bfce8 100644 --- a/editor/public/index.html +++ b/editor/public/index.html @@ -5,6 +5,7 @@ Metadata Editor + @@ -12,10 +13,16 @@

Metadata Editor

-
- - -
+ + +
+
+ + +
+
episode(s)
+
+
\ No newline at end of file diff --git a/editor/public/static/css/index.css b/editor/public/static/css/index.css new file mode 100644 index 0000000..0179dbd --- /dev/null +++ b/editor/public/static/css/index.css @@ -0,0 +1,37 @@ +#files { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15em, 1fr)); + grid-auto-rows: 15em; + gap: 0.8em; + place-items: center; + + .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; + + &:hover { + background-color: #f8f8f8; + } + + img { + width: 10em; + height: 10em; + } + + .title { + overflow-wrap: anywhere; + text-align: center; + font-weight: bold; + } + } +} \ No newline at end of file diff --git a/editor/public/static/images/film.svg b/editor/public/static/images/film.svg new file mode 100644 index 0000000..2c19dae --- /dev/null +++ b/editor/public/static/images/film.svg @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/public/static/images/series.svg b/editor/public/static/images/series.svg new file mode 100644 index 0000000..37603b6 --- /dev/null +++ b/editor/public/static/images/series.svg @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/public/static/js/index.js b/editor/public/static/js/index.js index 71fd02d..0ecd6dd 100644 --- a/editor/public/static/js/index.js +++ b/editor/public/static/js/index.js @@ -1,31 +1,59 @@ -function addOptions(files) { - const select = document.getElementById("file-sel") - select.innerHTML = "" - const defaultOpt = document.createElement("option") - defaultOpt.innerText = "----- Select a file -----" - defaultOpt.value = "" - select.appendChild(defaultOpt) - files.sort().forEach(file => { - const option = document.createElement("option") - option.innerText = file - option.value = file - select.appendChild(option) - }) +function makeFilm(meta) { + const file = document.getElementById("film-template").cloneNode(true) + file.querySelector(".title").innerText = meta.title + return file } -function selectFile(event) { - const file = event.target.value - if (file !== "") { - const url = new URL("/edit/", window.location.origin) - url.searchParams.set("f", file) - window.location.href = url.href +function makeSeries(meta) { + const file = document.getElementById("series-template").cloneNode(true) + file.querySelector(".title").innerText = meta.title + file.querySelector(".episodes .num").innerText = meta.episodes + return file +} + +function makeFile(meta) { + let file + switch (meta.type) { + case "film": + file = makeFilm(meta) + break + case "series": + file = makeSeries(meta) + break + default: + throw new Error(`Invalid file type '${meta.type}'`) } + + file.title = meta.filename + file.id = null + file.classList.remove("template") + const url = new URL("/edit/", window.location.origin) + url.searchParams.set("f", meta.filename) + file.href = url.href + return file +} + +/** + * + * @param {object[]} files + */ +function addFiles(files) { + const list = document.getElementById("files") + list.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) + list.appendChild(file) + }) } window.addEventListener("load", () => { fetch("/api/files").then(res => { return res.json() }).then(files => { - addOptions(files) + addFiles(files) }) }) \ No newline at end of file diff --git a/editor/public/static/js/integrity_manager.mjs b/editor/public/static/js/integrity_manager.mjs index cc568c0..4b9309c 100644 --- a/editor/public/static/js/integrity_manager.mjs +++ b/editor/public/static/js/integrity_manager.mjs @@ -316,7 +316,7 @@ export default class IntegrityManager { if (fields.flags.hearing_impaired) { name += " SDH" } - name += " : " + fields.type + name += " | " + fields.type break } return name diff --git a/editor/server.py b/editor/server.py index 2fbd994..d58da48 100644 --- a/editor/server.py +++ b/editor/server.py @@ -11,6 +11,7 @@ MAX_SIZE = 10e6 class MyHandler(SimpleHTTPRequestHandler): DATA_DIR = "metadata" + CACHE = {} def __init__(self, *args, **kwargs): super().__init__( @@ -55,7 +56,7 @@ class MyHandler(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() + files: list[str] = self.get_files_meta() self.send_json(files) elif path.startswith("file"): filename: str = path.split("/", 1)[1] @@ -108,6 +109,44 @@ class MyHandler(SimpleHTTPRequestHandler): return False return True + def get_files_meta(self): + files: list[str] = self.get_files() + files_meta: list[dict] = [] + + deleted = set(self.CACHE.keys()) - set(files) + for filename in deleted: + del self.CACHE[deleted] + + for filename in files: + path: str = os.path.join(self.DATA_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) + + files_meta.append(self.CACHE[filename]) + + return files_meta + + def update_file_meta(self, filename: str): + path: str = os.path.join(self.DATA_DIR, filename) + + meta = { + "filename": filename, + "ts": os.path.getmtime(path) + } + + with open(path, "r") as f: + data = json.load(f) + is_series = "filename" not in data + meta["type"] = "series" if is_series else "film" + if is_series: + meta["episodes"] = len(data) + meta["title"] = filename.split("_metadata")[0] + else: + meta["title"] = data["title"] + + self.CACHE[filename] = meta + def main(): with socketserver.TCPServer(("", PORT), MyHandler) as httpd: -- 2.47.2 From 79cd7a32ed14dc646a581c2ed3a99ceca22c6a2d Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Sat, 3 May 2025 11:18:40 +0200 Subject: [PATCH 10/11] feat: add sorting and filtering in file list --- editor/public/index.html | 25 +++++++++++ editor/public/static/css/edit.css | 4 +- editor/public/static/css/index.css | 59 ++++++++++++++++++++++++++ editor/public/static/js/index.js | 48 +++++++++++++++++++++ editor/public/static/js/media_file.mjs | 5 --- 5 files changed, 135 insertions(+), 6 deletions(-) delete mode 100644 editor/public/static/js/media_file.mjs diff --git a/editor/public/index.html b/editor/public/index.html index 80bfce8..0b4bc55 100644 --- a/editor/public/index.html +++ b/editor/public/index.html @@ -22,6 +22,31 @@
episode(s)
+
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/editor/public/static/css/edit.css b/editor/public/static/css/edit.css index 4d57952..d1ead42 100644 --- a/editor/public/static/css/edit.css +++ b/editor/public/static/css/edit.css @@ -338,9 +338,11 @@ button.improve { display: flex; flex-direction: column; max-width: 30em; + transform: translateX(0%); + transition: transform 0.5s cubic-bezier(0.22, 0.61, 0.36, 1); &:not(.show) { - display: none; + transform: translateX(100%); } #close-notifs { diff --git a/editor/public/static/css/index.css b/editor/public/static/css/index.css index 0179dbd..5117ddb 100644 --- a/editor/public/static/css/index.css +++ b/editor/public/static/css/index.css @@ -4,6 +4,7 @@ grid-auto-rows: 15em; gap: 0.8em; place-items: center; + padding: 0.8em 0; .file { display: flex; @@ -19,6 +20,10 @@ padding: 0.4em; border-radius: 1.2em; + &.hidden { + display: none; + } + &:hover { background-color: #f8f8f8; } @@ -34,4 +39,58 @@ font-weight: bold; } } +} + +.toolbar { + display: flex; + gap: 1.2em; + border-bottom: solid black 1px; + padding: 0.4em 0; + + .tool { + display: flex; + flex-direction: column; + gap: 0.2em; + + label[for] { + font-weight: bold; + } + + input, select { + font-family: inherit; + font-size: inherit; + height: 100%; + } + + .toggle { + height: 2em; + border-radius: 1em; + display: grid; + grid-template-columns: 1fr 1fr; + user-select: none; + cursor: pointer; + + input { + display: none; + + &:not(:checked) ~ .off, &:checked ~ .on { + background-color: #6ee74a; + } + } + + div { + padding: 0 0.4em; + display: grid; + place-items: center; + + &.off { + border-radius: 1em 0 0 1em; + } + + &.on { + border-radius: 0 1em 1em 0; + } + } + } + } } \ No newline at end of file diff --git a/editor/public/static/js/index.js b/editor/public/static/js/index.js index 0ecd6dd..1a0c566 100644 --- a/editor/public/static/js/index.js +++ b/editor/public/static/js/index.js @@ -1,3 +1,5 @@ +let fileNodes = [] + function makeFilm(meta) { const file = document.getElementById("film-template").cloneNode(true) file.querySelector(".title").innerText = meta.title @@ -47,13 +49,59 @@ function addFiles(files) { const meta = files[i] const file = makeFile(meta) list.appendChild(file) + fileNodes.push([meta, file]) }) } +function sortFiles() { + const sortBy = document.getElementById("sort-by").value + const sortDesc = document.getElementById("sort-desc").checked + const filter = document.getElementById("filter").value + + fileNodes.forEach(([meta, node]) => { + if (node.classList.contains(filter) || filter === "all") { + node.classList.remove("hidden") + } else { + node.classList.add("hidden") + } + }) + + let changed = false + do { + changed = false + for (let i = 0; i < fileNodes.length - 1; i++) { + /** @type {[object, HTMLElement]} */ + const pair1 = fileNodes[i] + /** @type {[object, HTMLElement]} */ + const pair2 = fileNodes[i + 1] + const [meta1, node1] = pair1 + const [meta2, node2] = pair2 + + let swap = false + if (sortDesc) { + swap = !(meta1[sortBy] >= meta2[sortBy]) + } else { + swap = !(meta2[sortBy] >= meta1[sortBy]) + } + if (swap) { + fileNodes[i] = pair2 + fileNodes[i + 1] = pair1 + node2.parentElement.insertBefore(node2, node1) + changed = true + } + } + } while (changed) +} + window.addEventListener("load", () => { fetch("/api/files").then(res => { return res.json() }).then(files => { addFiles(files) + sortFiles() }) + + document.getElementById("sort-by").addEventListener("change", () => sortFiles()) + document.getElementById("sort-desc").addEventListener("click", () => sortFiles()) + document.getElementById("filter").addEventListener("change", () => sortFiles()) }) \ No newline at end of file diff --git a/editor/public/static/js/media_file.mjs b/editor/public/static/js/media_file.mjs deleted file mode 100644 index 6568723..0000000 --- a/editor/public/static/js/media_file.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export default class MediaFile { - constructor(data) { - - } -} \ No newline at end of file -- 2.47.2 From 658addae5651b2386bc8023211d9a0f15855dfd4 Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Sat, 3 May 2025 15:24:15 +0200 Subject: [PATCH 11/11] feat: add CLI arguments + env variables support --- editor/server.py | 75 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 9 deletions(-) mode change 100644 => 100755 editor/server.py diff --git a/editor/server.py b/editor/server.py old mode 100644 new mode 100755 index d58da48..0b93716 --- a/editor/server.py +++ b/editor/server.py @@ -1,15 +1,38 @@ -from http import HTTPStatus -from http.server import SimpleHTTPRequestHandler +#!/usr/bin/env python3 + +import argparse import json import os import socketserver +from http import HTTPStatus +from http.server import SimpleHTTPRequestHandler from typing import Optional -from urllib.parse import urlparse, parse_qs, unquote +from urllib.parse import parse_qs, unquote, urlparse + + +# https://stackoverflow.com/a/10551190/11109181 +class EnvDefault(argparse.Action): + def __init__(self, envvar, required=True, default=None, help=None, **kwargs): + if envvar: + if envvar in os.environ: + default = os.environ[envvar] + if required and default is not None: + required = False + + if default is not None and help is not None: + help += f" (default: {default})" + + if envvar and help is not None: + help += f"\nCan also be specified through the {envvar} environment variable" + super(EnvDefault, self).__init__(default=default, required=required, help=help, + **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, values) -PORT = 8000 -MAX_SIZE = 10e6 class MyHandler(SimpleHTTPRequestHandler): + MAX_PAYLOAD_SIZE = 1e6 DATA_DIR = "metadata" CACHE = {} @@ -25,9 +48,9 @@ class MyHandler(SimpleHTTPRequestHandler): def read_body_data(self): try: size: int = int(self.headers["Content-Length"]) - if size > MAX_SIZE: + if size > self.MAX_PAYLOAD_SIZE: self.send_error(HTTPStatus.CONTENT_TOO_LARGE) - self.log_error(f"Payload is too big ({MAX_SIZE=}B)") + self.log_error(f"Payload is too big ({self.MAX_PAYLOAD_SIZE=}B)") return False raw_data = self.rfile.read(size) self.data = json.loads(raw_data) @@ -149,8 +172,42 @@ class MyHandler(SimpleHTTPRequestHandler): def main(): - with socketserver.TCPServer(("", PORT), MyHandler) as httpd: - print(f"Serving on port {PORT}") + parser = argparse.ArgumentParser( + description="Starts the Melies server", + formatter_class=argparse.RawTextHelpFormatter + ) + parser.add_argument( + "-p", "--port", + action=EnvDefault, + envvar="MELIES_PORT", + default=8000, + type=int, + help="Port on which the server listens" + ) + parser.add_argument( + "--max-payload-size", + action=EnvDefault, + envvar="MELIES_MAX_PAYLOAD_SIZE", + default=1e6, + type=int, + help="Maximum POST payload size in bytes that the server accepts" + ) + parser.add_argument( + "--metadata-dir", + action=EnvDefault, + envvar="MELIES_METADATA_DIR", + default="metadata", + help="Path to the directory containing metadata files" + ) + args = parser.parse_args() + + + port = args.port + MyHandler.MAX_PAYLOAD_SIZE = args.max_payload_size + MyHandler.DATA_DIR = args.metadata_dir + + with socketserver.TCPServer(("", port), MyHandler) as httpd: + print(f"Serving on port {port}") httpd.serve_forever() -- 2.47.2