Merge pull request 'feat: Web metadata editor' (#3) from feat/web-editor into main

Reviewed-on: #3
This commit is contained in:
Louis Heredero 2025-05-03 14:19:44 +00:00
commit 770d3cd25b
25 changed files with 2706 additions and 246 deletions

1
editor/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
metadata/

View File

@ -1,9 +0,0 @@
* {
/*padding: 0;*/
/*margin: 0;*/
box-sizing: border-box;
}
body {
font-family: Ubuntu;
}

View File

@ -1,13 +0,0 @@
#filename {
font-size: 80%;
font-style: italic;
}
#unsaved {
color: #9d4916;
font-size: 80%;
&:not(.show) {
display: none;
}
}

View File

@ -4,25 +4,35 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit</title> <title>Edit</title>
<link rel="stylesheet" href="/base.css"> <link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="index.css"> <link rel="stylesheet" href="/static/css/edit.css">
<script src="index.js"></script> <script src="/static/js/edit.mjs" type="module"></script>
</head> </head>
<body> <body>
<header id="topbar"> <button id="improve-btn" class="template improve">
<nav> <img src="/static/images/improve.svg">
<img class="clicked" src="/static/images/improve_clicked.svg">
</nav> </button>
<header id="toolbar">
<a href="/">Home</a>
<button id="check-integrity">Check integrity</button>
<button id="improve-all">Improve all names</button>
<button id="save">Save</button>
<button id="reload">Reload</button>
<button id="toggle-notifs">Notifications</button>
</header> </header>
<aside id="toolbar">
</aside>
<main id="main"> <main id="main">
<h1>Edit <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1> <h1>Editing <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1>
<div id="series-toolbar">
<button id="prev-episode"></button>
<div id="cur-episode"></div>
<button id="next-episode"></button>
</div>
<div class="sep"></div>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<label for="title">Title</label> <label for="title">Title</label>
<input type="text" id="title" name="title"> <input type="text" id="title" name="title" size="50">
</div> </div>
</div> </div>
<div class="audio"> <div class="audio">
@ -44,5 +54,63 @@
</table> </table>
</div> </div>
</main> </main>
<div class="popup" id="integrity-popup">
<form class="container">
<h2 class="title">Integrity Error</h2>
<p class="description">
There seem to be a mismatch between the name of <span id="int-track-type"></span> track <span id="int-track-idx"></span> and its '<span id="int-field-name"></span>' field
</p>
<div class="original">
<h3>Current values</h3>
<div class="values">
<div class="name">
<label for="int-og-name">name</label>
<input type="text" readonly id="int-og-name">
</div>
<div class="field">
<label for="int-og-field" id="int-og-field-name"></label>
<input type="text" readonly id="int-og-field">
</div>
</div>
</div>
<div class="correction">
<h3>Correction</h3>
<div class="options">
<div class="option">
<input type="radio" value="nothing" name="int-corr-type" id="int-corr-nothing" checked>
<label for="int-corr-nothing">Do nothing</label>
</div>
<div class="option">
<input type="radio" value="name" name="int-corr-type" id="int-corr-name">
<label for="int-corr-name">Adapt name to field</label>
<div class="value">
<label for="int-corr-new-name">name</label>
<div class="arrow"></div>
<input type="text" readonly id="int-corr-new-name">
</div>
</div>
<div class="option">
<input type="radio" value="field" name="int-corr-type" id="int-corr-field">
<label for="int-corr-field">Adapt field to name</label>
<div class="value">
<label for="int-corr-new-field" id="int-corr-new-field-name"></label>
<div class="arrow"></div>
<input type="text" readonly id="int-corr-new-field">
</div>
</div>
</div>
</div>
<div class="buttons">
<button type="button" id="int-apply">Apply</button>
</div>
</form>
</div>
<aside id="notifs-hist">
<button id="close-notifs">Close</button>
<h2>Notifications</h2>
<div class="list"></div>
</aside>
<div id="notifs"></div>
</body> </body>
</html> </html>

View File

@ -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)
})

View File

@ -4,10 +4,50 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metadata Editor</title> <title>Metadata Editor</title>
<script src="index.js"></script> <link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/index.css">
<script src="/static/js/index.js"></script>
</head> </head>
<body> <body>
<h1>Metadata Editor</h1> <header>
<select name="file-sel" id="file-sel" oninput="selectFile(event)"></select> <h1>Metadata Editor</h1>
</header>
<main>
<a id="film-template" class="template file film">
<img src="/static/images/film.svg">
<div class="title"></div>
</a>
<a id="series-template" class="template file series">
<img src="/static/images/series.svg">
<div class="title"></div>
<div class="episodes"><span class="num"></span> episode(s)</div>
</a>
<div class="toolbar">
<div class="tool">
<label for="sort-by">Sort by</label>
<select id="sort-by">
<option value="title">Title</option>
<option value="ts">Last modified</option>
</select>
</div>
<div class="tool">
<label for="sort-desc">Order</label>
<label class="toggle">
<input type="checkbox" id="sort-desc">
<div class="off">ASC</div>
<div class="on">DESC</div>
</label>
</div>
<div class="tool">
<label for="filter">Filter</label>
<select id="filter">
<option value="all">All</option>
<option value="film">Films</option>
<option value="series">Series</option>
</select>
</div>
</div>
<div id="files"></div>
</main>
</body> </body>
</html> </html>

View File

@ -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)
})
})

View File

@ -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;
}
}

View File

@ -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");
}
}
}

View File

@ -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;
}
}
}
}
}

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg857"
inkscape:version="1.3.2 (1:1.3.2+202404261509+091e20ef0f)"
sodipodi:docname="after_icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs851" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="6.9603479"
inkscape:cx="17.025011"
inkscape:cy="32.900654"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:window-width="1366"
inkscape:window-height="716"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1">
<inkscape:grid
type="xygrid"
id="grid1402"
empspacing="4"
originx="0"
originy="0"
spacingy="1.0583333"
spacingx="1.0583333"
units="px"
visible="true" />
</sodipodi:namedview>
<metadata
id="metadata854">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-280.06665)">
<path
style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 2.1166666,288.53332 H 14.816666"
id="path1406"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 10.583333,285.35832 4.233333,3.175 -4.233333,3.175"
id="path1"
sodipodi:nodetypes="ccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="film.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="5.7798541"
inkscape:cx="54.932183"
inkscape:cy="64.274979"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="0.99999998"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="16"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g12"
transform="translate(1.7078686e-5,-8)">
<g
id="g11">
<g
id="g10">
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path1"
cx="56"
cy="72"
r="40" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path1-5"
cx="56"
cy="72"
r="32" />
</g>
<g
id="g7">
<g
id="g6">
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2"
cx="56"
cy="53"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-2"
cx="72.454483"
cy="62.5"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-7"
cx="72.454483"
cy="81.5"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-6"
cx="56"
cy="91"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-61"
cx="39.545517"
cy="81.5"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-20"
cx="39.545517"
cy="62.5"
r="6" />
</g>
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path3"
cx="56"
cy="72"
r="3" />
</g>
</g>
<g
id="g9">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 70.704221,109.19927 C 78.261106,107.5993 90,108 98,103 c 8,-5 12,-6 14,-7 l -7,-14 c -5,2 -6,7 -9,11 -3,4 -10.113506,5.585663 -10.113506,5.585663"
id="path4"
sodipodi:nodetypes="czcczc" />
<g
id="g8">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 97.649449,90.439824 5.231401,9.711746"
id="path5" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 90.543116,96.977586 -0.626729,9.286354"
id="path6"
sodipodi:nodetypes="cc" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.4.1 (1:1.4.1+202503302257+93de688d07)"
sodipodi:docname="improve.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="8.8461329"
inkscape:cx="18.652218"
inkscape:cy="27.808761"
inkscape:window-width="2048"
inkscape:window-height="1228"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
transform="matrix(2,0,0,2,-30,-24)">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 24,32 c 3,0 8,-5 8,-8 0,3 5,8 8,8 -3,0 -8,5 -8,8 0,-3 -5,-8 -8,-8 z"
id="path1"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.7;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 19,22 c 1.875,0 5,-3.125 5,-5 0,1.875 3.125,5 5,5 -1.875,0 -5,3.125 -5,5 0,-1.875 -3.125,-5 -5,-5 z"
id="path1-3"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 35,19 c 1.125,0 3,-1.875 3,-3 0,1.125 1.875,3 3,3 -1.125,0 -3,1.875 -3,3 0,-1.125 -1.875,-3 -3,-3 z"
id="path1-1"
sodipodi:nodetypes="ccccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.4.1 (1:1.4.1+202503302257+93de688d07)"
sodipodi:docname="improve_clicked.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="8.8461329"
inkscape:cx="18.652218"
inkscape:cy="27.808761"
inkscape:window-width="2048"
inkscape:window-height="1228"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
transform="matrix(2,0,0,2,-30,-24)"
style="fill:#efe348;fill-opacity:1">
<path
style="fill:#efe348;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="m 24,32 c 3,0 8,-5 8,-8 0,3 5,8 8,8 -3,0 -8,5 -8,8 0,-3 -5,-8 -8,-8 z"
id="path1"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#efe348;stroke:#000000;stroke-width:0.7;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="m 19,22 c 1.875,0 5,-3.125 5,-5 0,1.875 3.125,5 5,5 -1.875,0 -5,3.125 -5,5 0,-1.875 -3.125,-5 -5,-5 z"
id="path1-3"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#efe348;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="m 35,19 c 1.125,0 3,-1.875 3,-3 0,1.125 1.875,3 3,3 -1.125,0 -3,1.875 -3,3 0,-1.125 -1.875,-3 -3,-3 z"
id="path1-1"
sodipodi:nodetypes="ccccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
sodipodi:docname="next_icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="2.0560856"
inkscape:cx="8.7544994"
inkscape:cy="178.49452"
inkscape:window-width="1858"
inkscape:window-height="1016"
inkscape:window-x="1982"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="0.26458333"
spacingy="0.26458333"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
dotted="false"
gridanglex="30"
gridanglez="30"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="color:#000000;fill:#000000;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
d="m 19.698341,10.884614 a 4.2333331,4.2333331 0 0 0 -2.763672,1.691406 4.2333331,4.2333331 0 0 0 0.933594,5.914062 L 36.444435,31.999848 17.868263,45.509614 a 4.2333331,4.2333331 0 0 0 -0.933594,5.914062 4.2333331,4.2333331 0 0 0 5.914063,0.933594 L 46.131935,35.423676 a 4.2337564,4.2337564 0 0 0 0,-6.847656 L 22.848732,11.642426 a 4.2333331,4.2333331 0 0 0 -3.150391,-0.757812 z"
id="path1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
sodipodi:docname="prev_icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="2.0560856"
inkscape:cx="8.7544994"
inkscape:cy="178.49452"
inkscape:window-width="1858"
inkscape:window-height="1016"
inkscape:window-x="1982"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="0.26458333"
spacingy="0.26458333"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
dotted="false"
gridanglex="30"
gridanglez="30"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="color:#000000;fill:#000000;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
d="m 44.301659,10.884614 a 4.2333331,4.2333331 0 0 1 2.763672,1.691406 4.2333331,4.2333331 0 0 1 -0.933594,5.914062 L 27.555565,31.999848 46.131737,45.509614 a 4.2333331,4.2333331 0 0 1 0.933594,5.914062 4.2333331,4.2333331 0 0 1 -5.914063,0.933594 L 17.868065,35.423676 a 4.2337564,4.2337564 0 0 1 0,-6.847656 L 41.151268,11.642426 a 4.2333331,4.2333331 0 0 1 3.150391,-0.757812 z"
id="path1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,188 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="series.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="4.086974"
inkscape:cx="58.845493"
inkscape:cy="81.356034"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="0.99999998"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="16"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect15"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect14"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,2.1961523,0,1 @ F,0,1,1,0,2.1961523,0,1 @ F,0,0,1,0,2.1961523,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect15-4"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect15-8"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect15-9"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g4"
transform="translate(-8,-8)">
<g
id="g3">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 24,56 v 48 a 8,8 45 0 0 8,8 h 64 a 8,8 135 0 0 8,-8 V 56 A 8,8 45 0 0 96,48 H 32 a 8,8 135 0 0 -8,8 z"
id="path12"
inkscape:path-effect="#path-effect15"
inkscape:original-d="m 24,48 v 64 h 80 V 48 Z" />
<g
id="g1">
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path13"
cx="64"
cy="80"
r="16" />
<path
id="path14"
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"
inkscape:transform-center-x="-2"
transform="translate(-1)"
d="m 70.098076,81.098076 -8.196152,4.732051 A 1.2679491,1.2679491 30 0 1 60,84.732051 v -9.464102 a 1.2679491,1.2679491 150 0 1 1.901924,-1.098076 l 8.196152,4.732051 a 1.2679491,1.2679491 90 0 1 0,2.196152 z"
inkscape:path-effect="#path-effect14"
inkscape:original-d="M 72,80 60,86.928203 V 73.071797 Z" />
</g>
</g>
<g
id="g2">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 99.289904,111.29439 C 102.06725,110.03979 104,107.2456 104,104 V 56 c 0,-4.418278 -3.58172,-8 -8,-8 H 32 c -3.244751,0 -6.038321,1.93174 -7.293397,4.707908"
id="path12-9"
transform="translate(8,-8)"
sodipodi:nodetypes="cssssc" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 115.2899,95.29439 C 118.06725,94.03979 120,91.2456 120,88 V 40 c 0,-4.418278 -3.58172,-8 -8,-8 H 48 c -3.244751,0 -6.038321,1.93174 -7.293397,4.707908"
id="path12-9-5"
sodipodi:nodetypes="cssssc" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -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
})

View File

@ -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()
}
}
}
}

View File

@ -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())
})

View File

@ -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)
}
}

View File

@ -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(?<season>\d+)e(?<episode>\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)
}

View File

@ -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)
}
}

View File

@ -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}`}
})
}

167
editor/server.py Normal file → Executable file
View File

@ -1,15 +1,40 @@
from http import HTTPStatus #!/usr/bin/env python3
from http.server import SimpleHTTPRequestHandler
import argparse
import json import json
import os import os
import socketserver import socketserver
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
from typing import Optional 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): class MyHandler(SimpleHTTPRequestHandler):
MAX_PAYLOAD_SIZE = 1e6
DATA_DIR = "metadata" DATA_DIR = "metadata"
CACHE = {}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__( super().__init__(
@ -21,9 +46,13 @@ class MyHandler(SimpleHTTPRequestHandler):
self.data: Optional[dict|list] = None self.data: Optional[dict|list] = None
def read_body_data(self): def read_body_data(self):
self.log_message("Reading body data")
try: 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) self.data = json.loads(raw_data)
except: except:
self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body") self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body")
@ -32,6 +61,7 @@ class MyHandler(SimpleHTTPRequestHandler):
return True return True
def do_GET(self): def do_GET(self):
self.path = unquote(self.path)
self.query = parse_qs(urlparse(self.path).query) self.query = parse_qs(urlparse(self.path).query)
if self.path.startswith("/api/"): if self.path.startswith("/api/"):
self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/")) self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
@ -39,6 +69,7 @@ class MyHandler(SimpleHTTPRequestHandler):
super().do_GET() super().do_GET()
def do_POST(self): def do_POST(self):
self.path = unquote(self.path)
self.query = parse_qs(urlparse(self.path).query) self.query = parse_qs(urlparse(self.path).query)
if self.path.startswith("/api/"): if self.path.startswith("/api/"):
self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/")) self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
@ -46,25 +77,34 @@ class MyHandler(SimpleHTTPRequestHandler):
self.send_error(HTTPStatus.NOT_FOUND) self.send_error(HTTPStatus.NOT_FOUND)
def handle_api_get(self, path: str): def handle_api_get(self, path: str):
print(f"API request at {path}") self.log_message(f"API request at {path}")
if path == "files": if path == "files":
files: list[str] = self.get_files() files: list[str] = self.get_files_meta()
self.send_json(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)
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): def handle_api_post(self, path: str):
if path == "file": if path.startswith("file"):
if self.read_body_data(): if self.read_body_data():
data = self.get_file(self.data["file"]) filename: str = path.split("/", 1)[1]
if data is None: if self.write_file(filename, self.data):
self.send_error(HTTPStatus.NOT_FOUND) self.send_response(HTTPStatus.OK)
self.log_message("File not found") self.end_headers()
else: else:
self.log_message("Got file") self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
self.send_json(data) self.end_headers()
def send_json(self, data: dict|list): def send_json(self, data: dict|list):
self.send_response(200) self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.end_headers() self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8")) self.wfile.write(json.dumps(data).encode("utf-8"))
@ -72,17 +112,102 @@ class MyHandler(SimpleHTTPRequestHandler):
def get_files(self): def get_files(self):
return os.listdir(self.DATA_DIR) 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(): if filename not in self.get_files():
return None return None
with open(os.path.join(self.DATA_DIR, filename), "r") as f: with open(os.path.join(self.DATA_DIR, filename), "r") as f:
data = json.load(f) data = json.load(f)
return data 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(): def main():
with socketserver.TCPServer(("", PORT), MyHandler) as httpd: parser = argparse.ArgumentParser(
print(f"Serving on port {PORT}") 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() httpd.serve_forever()