diff --git a/editor/public/base.css b/editor/public/base.css
new file mode 100644
index 0000000..77fd275
--- /dev/null
+++ b/editor/public/base.css
@@ -0,0 +1,9 @@
+* {
+ /*padding: 0;*/
+ /*margin: 0;*/
+ box-sizing: border-box;
+}
+
+body {
+ font-family: Ubuntu;
+}
\ No newline at end of file
diff --git a/editor/public/edit/index.css b/editor/public/edit/index.css
new file mode 100644
index 0000000..27ee460
--- /dev/null
+++ b/editor/public/edit/index.css
@@ -0,0 +1,13 @@
+#filename {
+ font-size: 80%;
+ font-style: italic;
+}
+
+#unsaved {
+ color: #9d4916;
+ font-size: 80%;
+
+ &:not(.show) {
+ display: none;
+ }
+}
\ No newline at end of file
diff --git a/editor/public/edit/index.html b/editor/public/edit/index.html
new file mode 100644
index 0000000..a394faa
--- /dev/null
+++ b/editor/public/edit/index.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+ Edit
+
+
+
+
+
+
+
+
+ Edit
- Unsaved
+
+
+
+
+
+
\ No newline at end of file
diff --git a/editor/public/edit/index.js b/editor/public/edit/index.js
new file mode 100644
index 0000000..04da087
--- /dev/null
+++ b/editor/public/edit/index.js
@@ -0,0 +1,157 @@
+/** @type TracksTable */
+let audioTable
+
+/** @type TracksTable */
+let subtitleTable
+
+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"]
+ }
+
+ constructor(table) {
+ this.table = table
+ this.headers = this.table.querySelector("thead tr")
+ this.body = this.table.querySelector("tbody")
+ this.fields = []
+ this.tracks = []
+ }
+
+ 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])
+ 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) {
+ 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
+ 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
+ }
+ return input
+ }
+}
+
+function fetchData(filename) {
+ fetch("/api/file", {
+ method: "POST",
+ body: JSON.stringify({
+ file: filename
+ }),
+ headers: {
+ "Content-Type": "application/json"
+ }
+ }).then(res => {
+ if (res.ok) {
+ return res.json()
+ }
+ return null
+ }).then(res => {
+ if (res !== null) {
+ displayData(res)
+ }
+ })
+}
+
+function displayData(data) {
+ document.getElementById("title").value = data.title
+
+ audioTable.showTracks(data.audio_tracks)
+ subtitleTable.showTracks(data.subtitle_tracks)
+}
+
+window.addEventListener("load", () => {
+ audioTable = new TracksTable(document.getElementById("audio-tracks"))
+ subtitleTable = new TracksTable(document.getElementById("subtitle-tracks"))
+
+ const params = new URLSearchParams(window.location.search)
+ const file = params.get("f")
+ document.getElementById("filename").innerText = file
+
+ fetchData(file)
+})
\ No newline at end of file
diff --git a/editor/public/index.html b/editor/public/index.html
new file mode 100644
index 0000000..cdf3ce5
--- /dev/null
+++ b/editor/public/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Metadata Editor
+
+
+
+ Metadata Editor
+
+
+
\ No newline at end of file
diff --git a/editor/public/index.js b/editor/public/index.js
new file mode 100644
index 0000000..c9c646f
--- /dev/null
+++ b/editor/public/index.js
@@ -0,0 +1,31 @@
+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.forEach(file => {
+ const option = document.createElement("option")
+ option.innerText = file
+ option.value = file
+ select.appendChild(option)
+ })
+}
+
+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
+ }
+}
+
+window.addEventListener("load", () => {
+ fetch("/api/files").then(res => {
+ return res.json()
+ }).then(files => {
+ addOptions(files)
+ })
+})
\ No newline at end of file
diff --git a/editor/server.py b/editor/server.py
new file mode 100644
index 0000000..1133468
--- /dev/null
+++ b/editor/server.py
@@ -0,0 +1,90 @@
+from http import HTTPStatus
+from http.server import SimpleHTTPRequestHandler
+import json
+import os
+import socketserver
+from typing import Optional
+from urllib.parse import urlparse, parse_qs
+
+PORT = 8000
+
+class MyHandler(SimpleHTTPRequestHandler):
+ DATA_DIR = "metadata"
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(
+ *args,
+ directory="public",
+ **kwargs
+ )
+ self.query: dict = {}
+ self.data: Optional[dict|list] = None
+
+ def read_body_data(self):
+ self.log_message("Reading body data")
+ try:
+ raw_data = self.rfile.read(int(self.headers["Content-Length"]))
+ self.data = json.loads(raw_data)
+ except:
+ self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body")
+ self.log_error("Malformed JSON body")
+ return False
+ return True
+
+ def do_GET(self):
+ self.query = parse_qs(urlparse(self.path).query)
+ if self.path.startswith("/api/"):
+ self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
+ return
+ super().do_GET()
+
+ def do_POST(self):
+ self.query = parse_qs(urlparse(self.path).query)
+ if self.path.startswith("/api/"):
+ self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
+ return
+ self.send_error(HTTPStatus.NOT_FOUND)
+
+ def handle_api_get(self, path: str):
+ print(f"API request at {path}")
+ if path == "files":
+ files: list[str] = self.get_files()
+ self.send_json(files)
+ return
+
+ def handle_api_post(self, path: str):
+ if path == "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)
+
+ def send_json(self, data: dict|list):
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(data).encode("utf-8"))
+
+ def get_files(self):
+ return os.listdir(self.DATA_DIR)
+
+ def get_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 main():
+ with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
+ print(f"Serving on port {PORT}")
+ httpd.serve_forever()
+
+
+if __name__ == "__main__":
+ main()