diff --git a/editor/.gitignore b/editor/.gitignore new file mode 100644 index 0000000..cbd148e --- /dev/null +++ b/editor/.gitignore @@ -0,0 +1 @@ +metadata/ \ No newline at end of file diff --git a/editor/public/base.css b/editor/public/base.css deleted file mode 100644 index 77fd275..0000000 --- a/editor/public/base.css +++ /dev/null @@ -1,9 +0,0 @@ -* { - /*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 deleted file mode 100644 index 27ee460..0000000 --- a/editor/public/edit/index.css +++ /dev/null @@ -1,13 +0,0 @@ -#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 index a394faa..8fc6535 100644 --- a/editor/public/edit/index.html +++ b/editor/public/edit/index.html @@ -4,25 +4,35 @@ Edit - - - + + + -
- + +
+ Home + + + + +
-
-

Edit - Unsaved

+

Editing - Unsaved

+
+ +
+ +
+
- +
@@ -44,5 +54,63 @@
+ + + +
\ 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 04da087..0000000 --- a/editor/public/edit/index.js +++ /dev/null @@ -1,157 +0,0 @@ -/** @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 index cdf3ce5..0b4bc55 100644 --- a/editor/public/index.html +++ b/editor/public/index.html @@ -4,10 +4,50 @@ Metadata Editor - + + + -

Metadata Editor

- +
+

Metadata Editor

+
+
+ + +
+
+ + +
+
episode(s)
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
\ No newline at end of file diff --git a/editor/public/index.js b/editor/public/index.js deleted file mode 100644 index c9c646f..0000000 --- a/editor/public/index.js +++ /dev/null @@ -1,31 +0,0 @@ -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/public/static/css/base.css b/editor/public/static/css/base.css new file mode 100644 index 0000000..3ba2c02 --- /dev/null +++ b/editor/public/static/css/base.css @@ -0,0 +1,107 @@ +* { + 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 new file mode 100644 index 0000000..d1ead42 --- /dev/null +++ b/editor/public/static/css/edit.css @@ -0,0 +1,415 @@ +main { + display: flex; + flex-direction: column; + gap: 1.2em; +} + +#toggle-notifs { + margin-left: auto; +} + +#filename { + font-size: 80%; + font-style: italic; +} + +#unsaved { + color: #9d4916; + font-size: 80%; + + &: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; + position: relative; + } + } + } +} + +input[type="text"], select { + font-size: inherit; + font-family: inherit; +} + +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; + } + } +} + +input[type="radio"] { + --size: 0.5em; + --pad: 0.3em; + width: calc((var(--size) + 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; + + &:checked { + background-color: #daf0d1; + + &::after { + content: ""; + width: calc(var(--size) * 2); + height: calc(var(--size) * 2); + border-radius: calc(var(--size)); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: #6ee74a; + } + } +} + +label { + cursor: pointer; +} + +button.improve { + width: 2em; + height: 2em; + border: none; + margin: 0; + padding: 0; + cursor: pointer; + position: absolute; + top: 50%; + transform: translateY(-50%); + margin-left: 0.4em; + border-radius: 0.4em; + background: none; + + &:hover{ + background-color: #8d8d8d42; + } + + img { + position: absolute; + inset: 0; + width: inherit; + height: inherit; + object-fit: contain; + } + + .clicked { + opacity: 0; + transition: opacity 0.2s; + } + + &.clicked { + .clicked { + opacity: 1; + } + } + /*background: url("/static/images/improve.svg"); + background-size: contain; + width: 2em; + height: 2em; + border: none; + margin: 0; + padding: 0; + cursor: pointer; + position: absolute; + top: 50%; + transform: translateY(-50%); + margin-left: 0.4em; + border-radius: 0.4em; + &:hover{ + background-color: #8d8d8d42; + } + &::after { + content: ""; + position: absolute; + background: url("/static/images/improve_clicked.svg"); + background-size: contain; + width: inherit; + height: inherit; + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 0.2s; + } + + &.clicked { + &::after { + opacity: 1; + } + }*/ +} + +.popup { + display: grid; + place-items: center; + position: fixed; + inset: 0; + background-color: #5c5c5c5c; + + &:not(.show) { + display: none; + } + + .container { + padding: 1.2em; + border-radius: 0.8em; + background-color: white; + display: flex; + flex-direction: column; + gap: 0.8em; + + .title { + text-align: center; + margin: 0.4em 0; + } + + h2, h3 { + margin: 0; + margin-bottom: 0.4em; + } + + .buttons { + display: flex; + justify-content: center; + gap: 0.8em; + + button { + padding: 0.8em 1.6em; + background-color: #dfdfdf; + border: none; + cursor: pointer; + border-radius: 0.4em; + font-family: inherit; + font-size: inherit; + font-weight: bold; + + &:hover { + background-color: #e7e7e7; + } + + &:active { + background-color: #d7d7d7; + } + } + } + } +} + +#integrity-popup { + h3 { + margin-bottom: 0.4em; + } + + label { + font-weight: bold; + } + + .description { + span { + font-weight: bold; + } + } + + input[type="text"] { + width: 100%; + } + + .original { + .values { + display: flex; + flex-direction: column; + gap: 0.2em; + padding-left: 1.2em; + } + + .name, .field { + display: flex; + gap: 0.4em; + align-items: center; + } + } + + .correction { + .options { + display: flex; + flex-direction: column; + gap: 0.4em; + + .option { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.2em 0.4em; + align-items: center; + padding: 0.4em; + border: solid black 2px; + cursor: pointer; + border-radius: 0.2em; + + &.selected { + border-color: #6ee74a; + } + + input[type="radio"] { + grid-column: 1; + grid-row: 1; + } + + label { + grid-column: 2; + grid-row: 1; + } + + .arrow { + background: url("/static/images/arrow.svg"); + width: 2em; + height: 2em; + background-size: contain; + } + + .value { + grid-column: 2; + grid-row: 2; + display: flex; + align-items: center; + gap: 0.2em; + } + } + } + } + + .select { + border: solid black 1px; + padding: 0.2em 0.4em; + background-color: #fafafa; + } +} + +#notifs-hist { + padding: 0.8em; + height: 100%; + 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) { + transform: translateX(100%); + } + + #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; + } + } +} + +.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/css/index.css b/editor/public/static/css/index.css new file mode 100644 index 0000000..5117ddb --- /dev/null +++ b/editor/public/static/css/index.css @@ -0,0 +1,96 @@ +#files { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15em, 1fr)); + grid-auto-rows: 15em; + gap: 0.8em; + place-items: center; + padding: 0.8em 0; + + .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; + + &.hidden { + display: none; + } + + &:hover { + background-color: #f8f8f8; + } + + img { + width: 10em; + height: 10em; + } + + .title { + overflow-wrap: anywhere; + text-align: center; + 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/images/arrow.svg b/editor/public/static/images/arrow.svg new file mode 100644 index 0000000..3216880 --- /dev/null +++ b/editor/public/static/images/arrow.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + 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/improve.svg b/editor/public/static/images/improve.svg new file mode 100644 index 0000000..def8c9a --- /dev/null +++ b/editor/public/static/images/improve.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + diff --git a/editor/public/static/images/improve_clicked.svg b/editor/public/static/images/improve_clicked.svg new file mode 100644 index 0000000..3eb7f64 --- /dev/null +++ b/editor/public/static/images/improve_clicked.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + 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/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/edit.mjs b/editor/public/static/js/edit.mjs new file mode 100644 index 0000000..b00936a --- /dev/null +++ b/editor/public/static/js/edit.mjs @@ -0,0 +1,8 @@ +import Editor from "./editor.mjs"; +import * as utils from "./utils.mjs" + +window.addEventListener("load", () => { + const editor = new Editor() + window.editor = editor + window.utils = utils +}) diff --git a/editor/public/static/js/editor.mjs b/editor/public/static/js/editor.mjs new file mode 100644 index 0000000..1cfd1ea --- /dev/null +++ b/editor/public/static/js/editor.mjs @@ -0,0 +1,170 @@ +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() { + 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.metadata = null + this.data = {} + this.dirty = false + + this.integrityMgr = 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()) + 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() + } + + 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.metadata = loadMetadata(res) + this.displayData() + } + }) + } + + displayData() { + 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) + } + + save() { + fetch(`/api/file/${this.filename}`, { + method: "POST", + body: JSON.stringify(this.metadata.data), + headers: { + "Content-Type": "application/json" + } + }).then(res => { + if (res.ok) { + this.dirty = false + document.getElementById("unsaved").classList.remove("show") + this.notify("Saved successfully !", "success") + } else { + this.notify(`Error ${res.status}: ${res.statusText}`, "error", 10000) + } + }) + } + + setDirty() { + this.dirty = true + document.getElementById("unsaved").classList.add("show") + } + + editTrack(listKey, trackIdx, key, value) { + 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.integrityMgr.checkIntegrity()) { + this.notify("No integrity error detected !", "success") + } + } + + improveAllNames() { + this.integrityMgr.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") + } + + 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 new file mode 100644 index 0000000..1a0c566 --- /dev/null +++ b/editor/public/static/js/index.js @@ -0,0 +1,107 @@ +let fileNodes = [] + +function makeFilm(meta) { + const file = document.getElementById("film-template").cloneNode(true) + file.querySelector(".title").innerText = meta.title + return file +} + +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) + 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/integrity_manager.mjs b/editor/public/static/js/integrity_manager.mjs new file mode 100644 index 0000000..4b9309c --- /dev/null +++ b/editor/public/static/js/integrity_manager.mjs @@ -0,0 +1,378 @@ +import { Track } from "./tracks_table.mjs" +import { findLanguage, isLanguageTagAlias, flattenObj, updateObjectFromJoinedKey, LANGUAGES } from "./utils.mjs" + +class Mismatch { + constructor(track, key, value) { + /** @type {Track} */ + this.track = track + + /** @type {string} */ + this.key = key + this.value = value + } +} + +const CorrectionType = { + NOTHING: "nothing", + NAME: "name", + FIELD: "field" +} + +const WORDS = { + forced: { + default: "Forced", + fre: "Forcés", + "fre-ca": "Forcés" + }, + full: { + default: "Full", + fre: "Complets", + "fre-ca": "Complets" + }, + +} + +function containsWord(parts, word) { + return Object.values(WORDS[word]).some(w => parts.includes(w)) +} + +function getWord(word, lang) { + const words = WORDS[word] + return words[lang] ?? words.default +} + +class MismatchCorrection { + /** + * + * @param {Mismatch} mismatch + * @param {string} oldName + * @param {string} newName + */ + constructor(mismatch, oldName, newName) { + this.track = mismatch.track + this.key = mismatch.key + + this.oldName = oldName + this.newName = newName + this.oldValue = this.track.fields[mismatch.key] + this.newValue = mismatch.value + } + + apply(correctionType) { + switch (correctionType) { + case CorrectionType.NOTHING: + break + case CorrectionType.NAME: + this.track.editValue("name", this.newName) + break + case CorrectionType.FIELD: + this.track.editValue(this.key, this.newValue) + break + default: + throw new Error( + `Invalid correction type, must be one of [${Object.values(CorrectionType)}], got ${correctionType}` + ) + } + } +} + +export default class IntegrityManager { + IGNORE_KEYS = [ + "type", "channels_details" + ] + + /** + * + * @param {import('./editor.mjs').default} editor + */ + constructor(editor) { + this.editor = editor + + /** @type {Mismatch[]} */ + this.mismatches = [] + + /** @type {?MismatchCorrection} */ + this.correction = null + + this.ignoreList = [] + + this.popup = document.getElementById("integrity-popup") + + this.popup.querySelectorAll(".correction .option").forEach(opt => { + const radio = opt.querySelector("input[type='radio']") + opt.addEventListener("click", e => { + radio.click() + }) + radio.addEventListener("input", () => { + if (radio.checked) { + const prev = this.popup.querySelector(".option.selected input[type='radio']:not(:checked)") + prev.parentElement.classList.remove("selected") + opt.classList.add("selected") + } else { + opt.classList.remove("selected") + } + }) + if (radio.checked) { + opt.classList.add("selected") + } + }) + + this.popup.querySelector("#int-apply").addEventListener("click", () => { + const fd = new FormData(this.popup.querySelector("form")) + const correctionType = fd.get("int-corr-type") + this.correct(correctionType) + }) + } + + nextError() { + if (this.mismatches.length === 0) { + this.hideMismatchPopup() + return + } + const mismatch = this.mismatches.shift() + console.log("Next mismatch:", mismatch) + this.mismatches = this.mismatches.filter(m => m.track !== mismatch.track) + this.showMismatchPopup(mismatch) + } + + 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 + } + + /** + * + * @param {import('./tracks_table.mjs').default} table + */ + checkTableIntegrity(table) { + for (const track of table.tracks) { + this.checkTrackIntegrity(track) + } + } + + /** + * + * @param {Track} track + */ + checkTrackIntegrity(track, prepend=false) { + let fields = this.parseName(track.table.type, track.fields["name"]) + fields = flattenObj(fields) + + Object.entries(fields).map(([key, value]) => { + if (this.IGNORE_KEYS.includes(key)) { + return + } + if (this.ignoreList.some(o => o.track === track && o.key === key)) { + return + } + + let equal = track.fields[key] === value + if (key === "language") { + equal = isLanguageTagAlias(value, track.fields[key]) + } + if (!equal) { + this.addMismatchField(track, key, value, prepend) + //console.error(`Mismatch for field ${key}:\n- name: ${value}\n- track: ${track.fields[key]}`) + } else { + track.fields[key] = value + } + }) + } + + parseName(trackType, name) { + /** @type {string} */ + const lower = name.toLowerCase() + const parts = lower.split(/\b/) + const fields = {flags: {}} + switch (trackType) { + case "audio": + const audioLang = findLanguage(lower) + if (audioLang !== null) {fields.language = audioLang} + const original = parts.includes("vo") + if (original) {fields.flags.original = original} + const ad = parts.includes("ad") + if (ad) {fields.flags.visual_impaired = ad} + + const channels = lower.match(/\d+\.\d+/) + if (channels) { + fields.channels_details = channels[0] + } + + break + + case "subtitle": + const stLang = findLanguage(lower) + if (stLang !== null) {fields.language = stLang} + const isForced = containsWord(parts, "forced") + const isFull = containsWord(parts, "full") + if (isForced) { + fields.flags.forced = true + } else if (isFull) { + fields.flags.forced = false + } + const sdh = parts.includes("sdh") + if (sdh) {fields.flags.hearing_impaired = sdh} + + if (parts.includes("pgs")) { + fields.type = "PGS" + } else { + fields.type = "SRT" + } + break + } + return fields + } + + addMismatchField(track, key, value, prepend=false) { + const mismatch = new Mismatch(track, key, value) + if (prepend) { + this.mismatches.splice(0, 0, mismatch) + } else { + this.mismatches.push(mismatch) + } + } + + /** + * + * @param {Mismatch} mismatch + */ + showMismatchPopup(mismatch) { + const q = sel => this.popup.querySelector(sel) + const track = mismatch.track + const table = track.table + const fieldProps = table.getFieldProps(mismatch.key) + const ogName = track.fields["name"] + const ogValue = track.fields[mismatch.key] + + let fields = this.parseName(table.type, ogName) + let keys = Object.keys(flattenObj(fields)) + + Object.entries(flattenObj(track.fields)).forEach(([key, val]) => { + if (!keys.includes(key) || key == mismatch.key) { + updateObjectFromJoinedKey(fields, key, val) + } + }) + const newName = this.reconstructName(table.type, fields) + + this.correction = new MismatchCorrection( + mismatch, ogName, newName + ) + + let ogField = track.makeInput(fieldProps, ogValue, false) + let newField = track.makeInput(fieldProps, mismatch.value, false) + + ogField = this.lockInput(ogField) + newField = this.lockInput(newField) + + ogField.id = "int-og-field" + newField.id = "int-corr-new-field" + + q("#int-track-type").innerText = table.type + q("#int-track-idx").innerText = track.idx + q("#int-field-name").innerText = fieldProps.name + q("#int-og-name").value = ogName + q("#int-og-field-name").innerText = fieldProps.name + q("#int-og-field").replaceWith(ogField) + q("#int-corr-new-name").value = newName + q("#int-corr-new-field-name").innerText = fieldProps.name + q("#int-corr-new-field").replaceWith(newField) + + this.popup.classList.add("show") + } + + reconstructName(tableType, fields) { + let name = LANGUAGES[fields.language].display + switch (tableType) { + case "audio": + if (fields.flags.original) { + name += " VO" + }/* else if (fields.language === "fre") { + name += " VFF" + } else if (fields.language === "fre-ca") { + name += " VFQ" + }*/ + if (fields.flags.visual_impaired) { + name += " AD" + } + if (fields.channels_details) { + name += " / " + fields.channels_details + } + break + case "subtitle": + if (fields.flags.forced) { + name += " " + getWord("forced", fields.language) + } else { + name += " " + getWord("full", fields.language) + } + if (fields.flags.hearing_impaired) { + name += " SDH" + } + name += " | " + fields.type + break + } + return name + } + + hideMismatchPopup() { + this.popup.classList.remove("show") + } + + correct(correctionType) { + if (correctionType === CorrectionType.NOTHING) { + this.ignoreList.push({ + track: this.correction.track, + key: this.correction.key + }) + } + this.correction.apply(correctionType) + this.checkTrackIntegrity(this.correction.track, true) + this.nextError() + } + + lockInput(input) { + input.readOnly = true + if (input.type === "checkbox") { + input.disabled = true + } else if (input.tagName === "SELECT") { + let text = document.createElement("div") + text.classList.add("select") + text.innerText = input.value + text.dataset.key = input.dataset.key + input = text + } + return input + } + + improveAllNames() { + for (const table of Object.values(this.editor.tables)) { + for (const track of table.tracks) { + this.improveName(track) + } + } + } + + /** + * + * @param {Track} track + */ + improveName(track) { + let nameFields = this.parseName(track.table.type, track.fields["name"]) + const keys = Object.keys(flattenObj(nameFields)) + Object.entries(flattenObj(track.fields)).forEach(([key, val]) => { + if (!keys.includes(key)) { + updateObjectFromJoinedKey(nameFields, key, val) + } + }) + const name = this.reconstructName(track.table.type, nameFields) + track.editValue("name", name) + } +} \ 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 new file mode 100644 index 0000000..2069323 --- /dev/null +++ b/editor/public/static/js/tracks_table.mjs @@ -0,0 +1,248 @@ +import { findLanguage, flattenObj, getLanguageOptions } from "./utils.mjs" + +export class Track { + constructor(table, idx, fields) { + /** @type {TracksTable} */ + this.table = table + + /** @type {number} */ + this.idx = idx + + /** @type {object} */ + this.fields = flattenObj(fields) + + this.row = null + } + + makeRow() { + this.row = document.createElement("tr") + this.row.dataset.i = this.idx + this.table.fields.forEach(field => { + const td = this.row.insertCell(-1) + const input = this.makeInput(field, this.fields[field.key]) + td.appendChild(input) + + if (field.key === "name") { + const btn = document.getElementById("improve-btn").cloneNode(true) + btn.id = null + btn.classList.remove("template") + btn.addEventListener("click", () => { + this.improveName() + btn.classList.add("clicked") + setTimeout(() => btn.classList.remove("clicked"), 1000) + }) + td.appendChild(btn) + } + }) + return this.row + } + + makeInput(field, value, listeners=true) { + 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" + + getValue = () => input.checked + const onehot = this.table.CONSTRAINTS[field.key]?.type == "onehot" + + if (listeners && onehot) { + if (value) { + 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 + } + } + input.addEventListener("click", e => { + if (!input.checked) { + e.preventDefault() + } else { + 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.onehots[field.key] = input + } + }) + } + input.checked = value + break + + case "sel": + input = document.createElement("select") + let options = this.table.OPTIONS[field.key] + if (typeof options === "function") { + options = options() + } + options.forEach(option => { + const opt = document.createElement("option") + opt.innerText = option.display + 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 + this.editValue(field.key, value) + } + } + + + 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 + } + if (listeners) { + input.addEventListener("change", () => { + this.editValue(field.key, getValue()) + }) + } + return input + } + + editValue(key, value) { + this.fields[key] = value + this.table.editTrack(this.idx, key, value) + + const input = this.row.querySelector(`[data-key='${key}']`) + 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.integrityMgr.improveName(this) + } +} + +export default class TracksTable { + OPTIONS = { + "language": getLanguageOptions + } + CONSTRAINTS = { + "flags/default": { + type: "onehot" + }, + "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.dataKey = dataKey + + this.tracks = [] + this.fields = [] + this.onehots = {} + } + + getFieldProps(key) { + return this.fields.find(f => f.key == key) + } + + loadTracks(tracks) { + this.clear() + this.tracks = tracks.map((t, i) => new Track(this, i, t)) + if (tracks.length === 0) { + return + } + this.detectFields() + this.addHeaders() + this.tracks.forEach(track => { + this.body.appendChild(track.makeRow()) + }) + } + + clear() { + this.tracks = [] + this.fields = [] + this.onehots = {} + this.headers.innerHTML = "" + this.body.innerHTML = "" + } + + 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..3b711a3 --- /dev/null +++ b/editor/public/static/js/utils.mjs @@ -0,0 +1,121 @@ +/** + * 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 +} + +// Code (Flag): https://en.wikipedia.org/wiki/Regional_indicator_symbol +// Tag: https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes +export const LANGUAGES = { + "fre-ca": { + display: "Français CA", + code: "ca", + aliases: ["fr-ca", "vfq", "quebec", "québec", "ca", "canada"] + }, + "fre": { + display: "Français FR", + code: "fr", + aliases: ["fr", "fra", "french", "francais", "français", "vf", "vff", "france"] + }, + "eng": { + display: "English", + code: "gb", + aliases: ["en", "ang", "english", "anglais", "uk", "gb", "usa", "british", "american", "amérique", "amerique", "angleterre", "royaume-uni"] + }, + "deu": { + display: "Deutsch", + 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", + aliases: ["ko", "kr", "cor", "korean", "coreen", "coréen", "corée", "coree", "korea"] + }, + "jpn": { + display: "Japanese", + code: "jp", + aliases: ["ja", "jp", "jap", "japanese", "japonais", "japon", "japan"] + }, + "tur": { + display: "Turkish", + code: "tr", + aliases: ["tu", "tr", "tür", "turkish", "turc", "turquie"] + }, + "und": { + display: "Undefined", + code: "", + aliases: [] + } +} + +export function getLanguageAliases(langTag) { + return [langTag].concat(LANGUAGES[langTag].aliases) +} + +/** + * 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 = getLanguageAliases(lang) + const matches = aliases.some(a => { + return new RegExp("\\b" + a + "\\b").test(value) + }) + if (matches) { + return lang + } + } + return null +} + +export function isLanguageTagAlias(langTag, value) { + return getLanguageAliases(langTag).includes(value) +} + +export function updateObjectFromJoinedKey(obj, joinedKey, value) { + const keyParts = joinedKey.split("/") + for (const part of keyParts.slice(0, -1)) { + obj = obj[part] + } + obj[keyParts[keyParts.length - 1]] = value +} + +/** + * @param {string} code + */ +export function makeFlag(code) { + return code.split("") + .map(c => String.fromCodePoint( + 0x1f1e6 + c.codePointAt(0) - 97 + )) + .join("") +} + +export function getLanguageOptions() { + return Object.entries(LANGUAGES).map(([tag, props]) => { + const flag = makeFlag(props.code) + return {value: tag, display: `${flag} ${props.display}`} + }) +} \ No newline at end of file diff --git a/editor/server.py b/editor/server.py old mode 100644 new mode 100755 index 1133468..0b93716 --- a/editor/server.py +++ b/editor/server.py @@ -1,15 +1,40 @@ -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 +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 class MyHandler(SimpleHTTPRequestHandler): + MAX_PAYLOAD_SIZE = 1e6 DATA_DIR = "metadata" + CACHE = {} def __init__(self, *args, **kwargs): super().__init__( @@ -21,9 +46,13 @@ class MyHandler(SimpleHTTPRequestHandler): 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"])) + size: int = int(self.headers["Content-Length"]) + if size > self.MAX_PAYLOAD_SIZE: + self.send_error(HTTPStatus.CONTENT_TOO_LARGE) + 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) except: self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body") @@ -32,6 +61,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 +69,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("/")) @@ -46,25 +77,34 @@ 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() + files: list[str] = self.get_files_meta() 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) + else: + 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,17 +112,102 @@ 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", 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() + 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: - 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()