5 Commits

20 changed files with 131 additions and 1637 deletions

View File

@ -9,30 +9,20 @@
<script src="/static/js/edit.mjs" type="module"></script> <script src="/static/js/edit.mjs" type="module"></script>
</head> </head>
<body> <body>
<button id="improve-btn" class="template improve"> <header id="topbar">
<img src="/static/images/improve.svg"> <nav>
<img class="clicked" src="/static/images/improve_clicked.svg">
</button> </nav>
<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>Editing <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1> <h1>Edit <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" size="50"> <input type="text" id="title" name="title">
</div> </div>
</div> </div>
<div class="audio"> <div class="audio">
@ -105,12 +95,5 @@
</div> </div>
</form> </form>
</div> </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

@ -5,49 +5,10 @@
<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>
<link rel="stylesheet" href="/static/css/base.css"> <link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/index.css">
<script src="/static/js/index.js"></script> <script src="/static/js/index.js"></script>
</head> </head>
<body> <body>
<header> <h1>Metadata Editor</h1>
<h1>Metadata Editor</h1> <select name="file-sel" id="file-sel" oninput="selectFile(event)"></select>
</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,107 +1,9 @@
* { * {
margin: 0; /*padding: 0;*/
/*margin: 0;*/
box-sizing: border-box; box-sizing: border-box;
} }
html, body {
height: 100%;
}
body { body {
font-family: Ubuntu; 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

@ -1,13 +1,3 @@
main {
display: flex;
flex-direction: column;
gap: 1.2em;
}
#toggle-notifs {
margin-left: auto;
}
#filename { #filename {
font-size: 80%; font-size: 80%;
font-style: italic; font-style: italic;
@ -38,7 +28,6 @@ table {
td { td {
text-align: center; text-align: center;
position: relative;
} }
} }
} }
@ -115,78 +104,6 @@ label {
cursor: pointer; 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 { .popup {
display: grid; display: grid;
place-items: center; place-items: center;
@ -321,95 +238,13 @@ button.improve {
align-items: center; align-items: center;
gap: 0.2em; gap: 0.2em;
} }
.select {
border: solid black 1px;
padding: 0.2em 0.4em;
background-color: #fafafa;
}
} }
} }
} }
.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

@ -1,96 +0,0 @@
#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

@ -1,148 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -1,77 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,78 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,64 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1,64 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1,188 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -1,7 +1,6 @@
import TracksTable from "./tracks_table.mjs" import TracksTable from "./tracks_table.mjs"
import IntegrityManager from "./integrity_manager.mjs" import IntegrityManager from "./integrity_manager.mjs"
import { updateObjectFromJoinedKey } from "./utils.mjs" import { updateObjectFromJoinedKey } from "./utils.mjs"
import { loadMetadata, SeriesMetadata } from "./metadata.mjs"
export default class Editor { export default class Editor {
constructor() { constructor() {
@ -19,26 +18,10 @@ export default class Editor {
subtitle: new TracksTable(this, "subtitle", "subtitle-tracks", "subtitle_tracks") subtitle: new TracksTable(this, "subtitle", "subtitle-tracks", "subtitle_tracks")
} }
this.metadata = null
this.data = {} this.data = {}
this.dirty = false this.dirty = false
this.integrityMgr = new IntegrityManager(this) this.integrity_mgr = new IntegrityManager(this)
document.getElementById("check-integrity").addEventListener("click", () => this.checkIntegrity())
document.getElementById("improve-all").addEventListener("click", () => this.improveAllNames())
document.getElementById("save").addEventListener("click", () => this.save())
document.getElementById("reload").addEventListener("click", () => window.location.reload())
document.getElementById("toggle-notifs").addEventListener("click", () => this.toggleNotifications())
document.getElementById("close-notifs").addEventListener("click", () => this.closeNotifications())
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() this.setup()
} }
@ -56,35 +39,23 @@ export default class Editor {
return null return null
}).then(res => { }).then(res => {
if (res !== null) { if (res !== null) {
this.metadata = loadMetadata(res) this.data = res
this.displayData() this.displayData()
} }
}) })
} }
displayData() { displayData() {
const seriesToolbar = document.getElementById("series-toolbar") document.getElementById("title").value = this.data.title
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.audio.loadTracks(this.data.audio_tracks)
this.tables.subtitle.loadTracks(this.data.subtitle_tracks) this.tables.subtitle.loadTracks(this.data.subtitle_tracks)
this.integrity_mgr.checkIntegrity()
} }
save() { save() {
fetch(`/api/file/${this.filename}`, { fetch(`/api/file/${this.filename}`, {
method: "POST", method: "POST",
body: JSON.stringify(this.metadata.data), body: JSON.stringify(this.data),
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
@ -92,9 +63,8 @@ export default class Editor {
if (res.ok) { if (res.ok) {
this.dirty = false this.dirty = false
document.getElementById("unsaved").classList.remove("show") document.getElementById("unsaved").classList.remove("show")
this.notify("Saved successfully !", "success")
} else { } else {
this.notify(`Error ${res.status}: ${res.statusText}`, "error", 10000) alert(`Error ${res.status}: ${res.statusText}`)
} }
}) })
} }
@ -108,63 +78,4 @@ export default class Editor {
updateObjectFromJoinedKey(this.data[listKey][trackIdx], key, value) updateObjectFromJoinedKey(this.data[listKey][trackIdx], key, value)
this.setDirty() 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

@ -1,107 +1,31 @@
let fileNodes = [] function addOptions(files) {
const select = document.getElementById("file-sel")
function makeFilm(meta) { select.innerHTML = ""
const file = document.getElementById("film-template").cloneNode(true) const defaultOpt = document.createElement("option")
file.querySelector(".title").innerText = meta.title defaultOpt.innerText = "----- Select a file -----"
return file defaultOpt.value = ""
select.appendChild(defaultOpt)
files.forEach(file => {
const option = document.createElement("option")
option.innerText = file
option.value = file
select.appendChild(option)
})
} }
function makeSeries(meta) { function selectFile(event) {
const file = document.getElementById("series-template").cloneNode(true) const file = event.target.value
file.querySelector(".title").innerText = meta.title if (file !== "") {
file.querySelector(".episodes .num").innerText = meta.episodes const url = new URL("/edit/", window.location.origin)
return file url.searchParams.set("f", file)
} window.location.href = url.href
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", () => { window.addEventListener("load", () => {
fetch("/api/files").then(res => { fetch("/api/files").then(res => {
return res.json() return res.json()
}).then(files => { }).then(files => {
addFiles(files) addOptions(files)
sortFiles()
}) })
document.getElementById("sort-by").addEventListener("change", () => sortFiles())
document.getElementById("sort-desc").addEventListener("click", () => sortFiles())
document.getElementById("filter").addEventListener("change", () => sortFiles())
}) })

View File

@ -18,29 +18,6 @@ const CorrectionType = {
FIELD: "field" 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 { class MismatchCorrection {
/** /**
* *
@ -78,7 +55,7 @@ class MismatchCorrection {
export default class IntegrityManager { export default class IntegrityManager {
IGNORE_KEYS = [ IGNORE_KEYS = [
"type", "channels_details" "type", "channels"
] ]
/** /**
@ -136,17 +113,11 @@ export default class IntegrityManager {
} }
checkIntegrity() { checkIntegrity() {
this.ignoreList = []
this.mismatches = []
for (const table of Object.values(this.editor.tables)) { for (const table of Object.values(this.editor.tables)) {
this.checkTableIntegrity(table) this.checkTableIntegrity(table)
} }
if (this.mismatches.length === 0) {
return true
}
this.nextError() this.nextError()
return false
} }
/** /**
@ -204,7 +175,7 @@ export default class IntegrityManager {
const channels = lower.match(/\d+\.\d+/) const channels = lower.match(/\d+\.\d+/)
if (channels) { if (channels) {
fields.channels_details = channels[0] fields.channels = channels[0]
} }
break break
@ -212,13 +183,8 @@ export default class IntegrityManager {
case "subtitle": case "subtitle":
const stLang = findLanguage(lower) const stLang = findLanguage(lower)
if (stLang !== null) {fields.language = stLang} if (stLang !== null) {fields.language = stLang}
const isForced = containsWord(parts, "forced") const forced = parts.includes("forced")
const isFull = containsWord(parts, "full") if (forced) {fields.flags.forced = forced}
if (isForced) {
fields.flags.forced = true
} else if (isFull) {
fields.flags.forced = false
}
const sdh = parts.includes("sdh") const sdh = parts.includes("sdh")
if (sdh) {fields.flags.hearing_impaired = sdh} if (sdh) {fields.flags.hearing_impaired = sdh}
@ -295,28 +261,25 @@ export default class IntegrityManager {
case "audio": case "audio":
if (fields.flags.original) { if (fields.flags.original) {
name += " VO" name += " VO"
}/* else if (fields.language === "fre") { } else if (fields.language === "fr") {
name += " VFF" name += " VFF"
} else if (fields.language === "fre-ca") { } else if (fields.language === "fr-ca") {
name += " VFQ" name =+ " VFQ"
}*/
if (fields.flags.visual_impaired) {
name += " AD"
} }
if (fields.channels_details) { if (fields.channels) {
name += " / " + fields.channels_details name += " : " + fields.channels
} }
break break
case "subtitle": case "subtitle":
if (fields.flags.forced) { if (fields.flags.forced) {
name += " " + getWord("forced", fields.language) name += " Forced"
} else { } else {
name += " " + getWord("full", fields.language) name += " Full"
} }
if (fields.flags.hearing_impaired) { if (fields.flags.hearing_impaired) {
name += " SDH" name += " SDH"
} }
name += " | " + fields.type name += " : " + fields.type
break break
} }
return name return name
@ -351,28 +314,4 @@ export default class IntegrityManager {
} }
return input 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

@ -1,85 +0,0 @@
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

@ -1,4 +1,4 @@
import { findLanguage, flattenObj, getLanguageOptions } from "./utils.mjs" import { flattenObj, getLanguageOptions } from "./utils.mjs"
export class Track { export class Track {
constructor(table, idx, fields) { constructor(table, idx, fields) {
@ -19,20 +19,8 @@ export class Track {
this.row.dataset.i = this.idx this.row.dataset.i = this.idx
this.table.fields.forEach(field => { this.table.fields.forEach(field => {
const td = this.row.insertCell(-1) const td = this.row.insertCell(-1)
const input = this.makeInput(field, this.fields[field.key]) const input = this.makeInput(field, this.fields[field.key], this.idx)
td.appendChild(input) 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 return this.row
} }
@ -54,36 +42,29 @@ export class Track {
case "bool": case "bool":
input.type = "checkbox" input.type = "checkbox"
input.checked = value
getValue = () => input.checked getValue = () => input.checked
const onehot = this.table.CONSTRAINTS[field.key]?.type == "onehot" const hotone = this.table.CONSTRAINTS[field.key]?.type == "hotone"
if (listeners && onehot) { if (listeners && hotone) {
if (value) { if (value) {
if (field.key in this.table.onehots) { if (field.key in this.table.hotones) {
this.table.editor.notify( alert(`Error in metadata file: field ${field.name} is hotone but multiple tracks are enabled`)
`Error in metadata file: field '${field.name}' is onehot but multiple tracks are enabled. Only the first one will be enabled`,
"error",
20000
)
value = false
} else {
this.table.onehots[field.key] = input
} }
this.table.hotones[field.key] = input
} }
input.addEventListener("click", e => { input.addEventListener("click", e => {
if (!input.checked) { if (!input.checked) {
e.preventDefault() e.preventDefault()
} else { } else {
if (field.key in this.table.onehots) { if (field.key in this.table.hotones) {
this.table.onehots[field.key].checked = false this.table.hotones[field.key].checked = false
this.table.onehots[field.key].dispatchEvent(new Event("change")) this.table.hotones[field.key].dispatchEvent(new Event("change"))
} }
this.table.onehots[field.key] = input this.table.hotones[field.key] = input
} }
}) })
} }
input.checked = value
break break
case "sel": case "sel":
@ -98,32 +79,11 @@ export class Track {
opt.value = option.value opt.value = option.value
input.appendChild(opt) 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 input.value = value
default: default:
break break
} }
input.name = field.key + "[]"
input.dataset.key = field.key input.dataset.key = field.key
if (this.table.CONSTRAINTS[field.key]?.type === "readonly") { if (this.table.CONSTRAINTS[field.key]?.type === "readonly") {
input.disabled = true input.disabled = true
@ -141,22 +101,16 @@ export class Track {
this.table.editTrack(this.idx, key, value) this.table.editTrack(this.idx, key, value)
const input = this.row.querySelector(`[data-key='${key}']`) const input = this.row.querySelector(`[data-key='${key}']`)
if (input) { const fieldType = this.table.getFieldProps(key).type
const fieldType = this.table.getFieldProps(key).type switch (fieldType) {
switch (fieldType) { case "bool":
case "bool": input.checked = value
input.checked = value break
break default:
default: input.value = value
input.value = value break
break
}
} }
} }
improveName() {
this.table.editor.integrityMgr.improveName(this)
}
} }
export default class TracksTable { export default class TracksTable {
@ -165,7 +119,7 @@ export default class TracksTable {
} }
CONSTRAINTS = { CONSTRAINTS = {
"flags/default": { "flags/default": {
type: "onehot" type: "hotone"
}, },
"index": { "index": {
type: "readonly" type: "readonly"
@ -187,11 +141,10 @@ export default class TracksTable {
this.table = document.getElementById(tableId) this.table = document.getElementById(tableId)
this.headers = this.table.querySelector("thead tr") this.headers = this.table.querySelector("thead tr")
this.body = this.table.querySelector("tbody") this.body = this.table.querySelector("tbody")
this.dataKey = dataKey
this.tracks = []
this.fields = [] this.fields = []
this.onehots = {} this.tracks = []
this.dataKey = dataKey
this.hotones = {}
} }
getFieldProps(key) { getFieldProps(key) {
@ -199,8 +152,8 @@ export default class TracksTable {
} }
loadTracks(tracks) { loadTracks(tracks) {
this.clear()
this.tracks = tracks.map((t, i) => new Track(this, i, t)) this.tracks = tracks.map((t, i) => new Track(this, i, t))
this.clear()
if (tracks.length === 0) { if (tracks.length === 0) {
return return
} }
@ -212,11 +165,9 @@ export default class TracksTable {
} }
clear() { clear() {
this.tracks = []
this.fields = []
this.onehots = {}
this.headers.innerHTML = "" this.headers.innerHTML = ""
this.body.innerHTML = "" this.body.innerHTML = ""
this.fields = []
} }
detectFields() { detectFields() {

View File

@ -29,27 +29,22 @@ export const LANGUAGES = {
"fre": { "fre": {
display: "Français FR", display: "Français FR",
code: "fr", code: "fr",
aliases: ["fr", "fra", "french", "francais", "français", "vf", "vff", "france"] aliases: ["fr", "fre", "fra", "french", "francais", "français", "vf", "vff", "france"]
}, },
"eng": { "eng": {
display: "English", display: "English",
code: "gb", code: "gb",
aliases: ["en", "ang", "english", "anglais", "uk", "gb", "usa", "british", "american", "amérique", "amerique", "angleterre", "royaume-uni"] aliases: ["en", "eng", "ang", "english", "anglais", "uk", "gb", "usa", "british", "american", "amérique", "amerique", "angleterre", "royaume-uni"]
}, },
"deu": { "deu": {
display: "Deutsch", display: "Deutsch",
code: "de", code: "de",
aliases: ["de", "ger", "german", "allemand", "deutsch", "germany", "allemagne"] aliases: ["de", "deu", "ger", "german", "allemand", "deutsch", "germany", "allemagne"]
},
"ita": {
display: "Italiano",
code: "it",
aliases: ["it", "ita", "italian", "italien", "italiano", "italy", "italie"]
}, },
"kor": { "kor": {
display: "Korean", display: "Korean",
code: "kr", code: "kr",
aliases: ["ko", "kr", "cor", "korean", "coreen", "coréen", "corée", "coree", "korea"] aliases: ["ko", "kr", "kor", "cor", "korean", "coreen", "coréen", "corée", "coree", "korea"]
}, },
"jpn": { "jpn": {
display: "Japanese", display: "Japanese",
@ -59,7 +54,7 @@ export const LANGUAGES = {
"tur": { "tur": {
display: "Turkish", display: "Turkish",
code: "tr", code: "tr",
aliases: ["tu", "tr", "tür", "turkish", "turc", "turquie"] aliases: ["tu", "tr", "tur", "tür", "turkish", "turc", "turquie"]
}, },
"und": { "und": {
display: "Undefined", display: "Undefined",
@ -68,10 +63,6 @@ export const LANGUAGES = {
} }
} }
export function getLanguageAliases(langTag) {
return [langTag].concat(LANGUAGES[langTag].aliases)
}
/** /**
* Tries to find a language name in the given string * Tries to find a language name in the given string
* @param {string} value The string in which to search for a language * @param {string} value The string in which to search for a language
@ -79,11 +70,11 @@ export function getLanguageAliases(langTag) {
*/ */
export function findLanguage(value) { export function findLanguage(value) {
for (const lang in LANGUAGES) { for (const lang in LANGUAGES) {
const aliases = getLanguageAliases(lang) const aliases = LANGUAGES[lang].aliases
const matches = aliases.some(a => { const matches = aliases.map(a => {
return new RegExp("\\b" + a + "\\b").test(value) return new RegExp("\\b" + a + "\\b").test(value)
}) })
if (matches) { if (matches.some(v => v)) {
return lang return lang
} }
} }
@ -91,7 +82,7 @@ export function findLanguage(value) {
} }
export function isLanguageTagAlias(langTag, value) { export function isLanguageTagAlias(langTag, value) {
return getLanguageAliases(langTag).includes(value) return LANGUAGES[langTag].aliases.includes(value)
} }
export function updateObjectFromJoinedKey(obj, joinedKey, value) { export function updateObjectFromJoinedKey(obj, joinedKey, value) {

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

@ -1,40 +1,16 @@
#!/usr/bin/env python3 from http import HTTPStatus
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 parse_qs, unquote, urlparse from urllib.parse import urlparse, parse_qs, unquote
# https://stackoverflow.com/a/10551190/11109181
class EnvDefault(argparse.Action):
def __init__(self, envvar, required=True, default=None, help=None, **kwargs):
if envvar:
if envvar in os.environ:
default = os.environ[envvar]
if required and default is not None:
required = False
if default is not None and help is not None:
help += f" (default: {default})"
if envvar and help is not None:
help += f"\nCan also be specified through the {envvar} environment variable"
super(EnvDefault, self).__init__(default=default, required=required, help=help,
**kwargs)
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, values)
PORT = 8000
MAX_SIZE = 10e6
class MyHandler(SimpleHTTPRequestHandler): 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__(
@ -46,11 +22,12 @@ 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:
size: int = int(self.headers["Content-Length"]) size: int = int(self.headers["Content-Length"])
if size > self.MAX_PAYLOAD_SIZE: if size > MAX_SIZE:
self.send_error(HTTPStatus.CONTENT_TOO_LARGE) self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
self.log_error(f"Payload is too big ({self.MAX_PAYLOAD_SIZE=}B)") self.log_error(f"Payload is too big ({MAX_SIZE=}B)")
return False return False
raw_data = self.rfile.read(size) raw_data = self.rfile.read(size)
self.data = json.loads(raw_data) self.data = json.loads(raw_data)
@ -77,16 +54,18 @@ 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):
self.log_message(f"API request at {path}") print(f"API request at {path}")
if path == "files": if path == "files":
files: list[str] = self.get_files_meta() files: list[str] = self.get_files()
self.send_json(files) self.send_json(files)
elif path.startswith("file"): elif path.startswith("file"):
filename: str = path.split("/", 1)[1] filename: str = path.split("/", 1)[1]
data = self.read_file(filename) data = self.read_file(filename)
if data is None: if data is None:
self.send_error(HTTPStatus.NOT_FOUND) self.send_error(HTTPStatus.NOT_FOUND)
self.log_message("File not found")
else: else:
self.log_message("Got file")
self.send_json(data) self.send_json(data)
else: else:
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}") self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
@ -125,89 +104,17 @@ class MyHandler(SimpleHTTPRequestHandler):
return False return False
try: try:
with open(os.path.join(self.DATA_DIR, filename), "w", encoding="utf-8") as f: with open(os.path.join(self.DATA_DIR, filename), "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False) json.dump(data, f, indent=2)
except: except:
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR) self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
return False return False
return True 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():
parser = argparse.ArgumentParser( with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
description="Starts the Melies server", print(f"Serving on port {PORT}")
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()

View File

@ -55,6 +55,7 @@ def encode(input_file, codec, remove_source=False, save_log=False):
"ffmpeg", "ffmpeg",
"-i", input_file, "-i", input_file,
"-map", "0", "-map", "0",
"-cq", str(cq),
] + extra_params + [ ] + extra_params + [
"-c:v", ffmpeg_codec, "-c:v", ffmpeg_codec,
"-preset", "p4", "-preset", "p4",

View File

@ -55,6 +55,8 @@ def get_video_metadata(file_path):
"channels": stream.get("channels", 0), "channels": stream.get("channels", 0),
"flags": { "flags": {
"default": stream.get("disposition", {}).get("default", 0) == 1, "default": stream.get("disposition", {}).get("default", 0) == 1,
"forced": stream.get("disposition", {}).get("forced", 0) == 1,
"hearing_impaired": stream.get("disposition", {}).get("hearing_impaired", 0) == 1,
"visual_impaired": stream.get("disposition", {}).get("visual_impaired", 0) == 1, "visual_impaired": stream.get("disposition", {}).get("visual_impaired", 0) == 1,
"original": stream.get("disposition", {}).get("original", 0) == 1, "original": stream.get("disposition", {}).get("original", 0) == 1,
"commentary": stream.get("disposition", {}).get("comment", 0) == 1 "commentary": stream.get("disposition", {}).get("comment", 0) == 1
@ -71,6 +73,7 @@ def get_video_metadata(file_path):
"default": stream.get("disposition", {}).get("default", 0) == 1, "default": stream.get("disposition", {}).get("default", 0) == 1,
"forced": stream.get("disposition", {}).get("forced", 0) == 1, "forced": stream.get("disposition", {}).get("forced", 0) == 1,
"hearing_impaired": stream.get("disposition", {}).get("hearing_impaired", 0) == 1, "hearing_impaired": stream.get("disposition", {}).get("hearing_impaired", 0) == 1,
"visual_impaired": stream.get("disposition", {}).get("visual_impaired", 0) == 1,
"original": stream.get("disposition", {}).get("original", 0) == 1, "original": stream.get("disposition", {}).get("original", 0) == 1,
"commentary": stream.get("disposition", {}).get("comment", 0) == 1 "commentary": stream.get("disposition", {}).get("comment", 0) == 1
} }
@ -83,13 +86,13 @@ def get_video_metadata(file_path):
print(f"❌ Error processing {file_path}: {str(e)}") print(f"❌ Error processing {file_path}: {str(e)}")
return None return None
def process_file(file_path, output_dir=None): def process_file(file_path, output_file=None):
""" """
Process a single video file and write metadata to JSON. Process a single video file and write metadata to JSON.
Args: Args:
file_path (str): Path to the video file file_path (str): Path to the video file
output_dir (str, optional): Directory where the output JSON file will be saved output_file (str, optional): Path to output JSON file
""" """
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
print(f"❌ File not found: {file_path}") print(f"❌ File not found: {file_path}")
@ -103,34 +106,27 @@ def process_file(file_path, output_dir=None):
metadata = get_video_metadata(file_path) metadata = get_video_metadata(file_path)
if metadata: if metadata:
# Generate output filename based on input file if not output_file:
filename = os.path.basename(os.path.splitext(file_path)[0]) + "_metadata.json" # Generate output filename based on input file
if output_dir:
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, filename)
else:
# If no output directory specified, save in the same directory as the input file
base_name = os.path.splitext(file_path)[0] base_name = os.path.splitext(file_path)[0]
output_path = f"{base_name}_metadata.json" output_file = f"{base_name}_metadata.json"
# Write metadata to JSON file # Write metadata to JSON file
with open(output_path, 'w', encoding='utf-8') as f: with open(output_file, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False) json.dump(metadata, f, indent=2, ensure_ascii=False)
print(f"✅ Metadata saved to {output_path}") print(f"✅ Metadata saved to {output_file}")
return True return True
return False return False
def process_directory(directory_path, output_dir=None): def process_directory(directory_path, output_file=None):
""" """
Process all video files in a directory and write metadata to JSON. Process all video files in a directory and write metadata to JSON.
Args: Args:
directory_path (str): Path to the directory directory_path (str): Path to the directory
output_dir (str, optional): Directory where the output JSON file will be saved output_file (str, optional): Path to output JSON file
""" """
if not os.path.isdir(directory_path): if not os.path.isdir(directory_path):
print(f"❌ Directory not found: {directory_path}") print(f"❌ Directory not found: {directory_path}")
@ -156,38 +152,31 @@ def process_directory(directory_path, output_dir=None):
print(f"❌ No supported video files found in {directory_path}") print(f"❌ No supported video files found in {directory_path}")
return False return False
# Generate output filename based on directory name if not output_file:
dir_name = os.path.basename(os.path.normpath(directory_path)) # Generate output filename based on directory name
filename = f"{dir_name}_metadata.json" dir_name = os.path.basename(os.path.normpath(directory_path))
output_file = f"{dir_name}_metadata.json"
if output_dir:
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, filename)
else:
# If no output directory specified, save in the current directory
output_path = filename
# Write all metadata to a single JSON file # Write all metadata to a single JSON file
with open(output_path, 'w', encoding='utf-8') as f: with open(output_file, 'w', encoding='utf-8') as f:
json.dump(all_metadata, f, indent=2, ensure_ascii=False) json.dump(all_metadata, f, indent=2, ensure_ascii=False)
print(f"✅ Metadata for {file_count} files saved to {output_path}") print(f"✅ Metadata for {file_count} files saved to {output_file}")
return True return True
def main(): def main():
parser = argparse.ArgumentParser(description="Extract metadata from video files and save as JSON.") parser = argparse.ArgumentParser(description="Extract metadata from video files and save as JSON.")
parser.add_argument("input", help="Path to input video file or directory") parser.add_argument("input", help="Path to input video file or directory")
parser.add_argument("-o", "--output", help="Directory path where output JSON files will be saved") parser.add_argument("-o", "--output", help="Path to output JSON file")
args = parser.parse_args() args = parser.parse_args()
input_path = args.input input_path = args.input
output_dir = args.output output_file = args.output
if os.path.isfile(input_path): if os.path.isfile(input_path):
process_file(input_path, output_dir) process_file(input_path, output_file)
elif os.path.isdir(input_path): elif os.path.isdir(input_path):
process_directory(input_path, output_dir) process_directory(input_path, output_file)
else: else:
print(f"❌ Path not found: {input_path}") print(f"❌ Path not found: {input_path}")
sys.exit(1) sys.exit(1)