15 Commits

Author SHA1 Message Date
658addae56 feat: add CLI arguments + env variables support 2025-05-03 16:07:24 +02:00
79cd7a32ed feat: add sorting and filtering in file list 2025-05-03 16:07:23 +02:00
02279b8c6f feat: rework home page file selector 2025-05-03 16:07:23 +02:00
ed3c6d7cc7 feat: add support for series 2025-05-03 16:07:23 +02:00
bc5371de71 feat: add toolbar + notifications 2025-05-03 16:07:22 +02:00
2afecd1c04 feat: add improve name button 2025-05-03 16:07:22 +02:00
8fbe5ae3c4 fix: add gitignore 2025-05-03 16:07:21 +02:00
acf7b5047f feat: add basic integrity checks + corrections 2025-05-03 16:07:21 +02:00
82d02cfe76 refactor: split editor in JS modules 2025-05-03 16:07:21 +02:00
d19ab90f38 feat: add saving 2025-05-03 16:07:20 +02:00
477e8951a9 feat: add hotone bool 2025-05-03 16:07:20 +02:00
5110f41152 Merge pull request 'fix/improve-gpt-script' (#1) from fix/improve-gpt-script into main
Reviewed-on: Klagarge/medias-migration#1
2025-05-03 13:24:47 +00:00
2de5044dfe feat: output directory path by output flag
BREAKING-CHANGE: output flag use now directory path instead of output file
2025-05-03 15:22:31 +02:00
9d4f4319c2 fix: remove unnecessary flags from metadata extraction 2025-05-03 15:19:43 +02:00
ca24665574 fix: remove useless cq
The constant quality parameter is already present in the command
2025-05-03 15:19:00 +02:00
17 changed files with 1039 additions and 84 deletions

View File

@ -23,6 +23,12 @@
</header>
<main id="main">
<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="field">
<label for="title">Title</label>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metadata Editor</title>
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/index.css">
<script src="/static/js/index.js"></script>
</head>
<body>
@ -12,10 +13,41 @@
<h1>Metadata Editor</h1>
</header>
<main>
<div>
<label for="file-sel">Choose a file</label>
<select name="file-sel" id="file-sel" oninput="selectFile(event)"></select>
<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>
</html>

View File

@ -337,9 +337,12 @@ button.improve {
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) {
display: none;
transform: translateX(100%);
}
#close-notifs {
@ -370,3 +373,43 @@ button.improve {
}
}
}
.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,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,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

@ -1,6 +1,7 @@
import TracksTable from "./tracks_table.mjs"
import IntegrityManager from "./integrity_manager.mjs"
import { updateObjectFromJoinedKey } from "./utils.mjs"
import { loadMetadata, SeriesMetadata } from "./metadata.mjs"
export default class Editor {
constructor() {
@ -18,10 +19,11 @@ export default class Editor {
subtitle: new TracksTable(this, "subtitle", "subtitle-tracks", "subtitle_tracks")
}
this.metadata = null
this.data = {}
this.dirty = false
this.integrity_mgr = new IntegrityManager(this)
this.integrityMgr = new IntegrityManager(this)
document.getElementById("check-integrity").addEventListener("click", () => this.checkIntegrity())
document.getElementById("improve-all").addEventListener("click", () => this.improveAllNames())
@ -29,6 +31,14 @@ export default class Editor {
document.getElementById("reload").addEventListener("click", () => window.location.reload())
document.getElementById("toggle-notifs").addEventListener("click", () => this.toggleNotifications())
document.getElementById("close-notifs").addEventListener("click", () => this.closeNotifications())
document.getElementById("prev-episode").addEventListener("click", () => this.prevEpisode())
document.getElementById("next-episode").addEventListener("click", () => this.nextEpisode())
this.titleInput = document.getElementById("title")
this.titleInput.addEventListener("change", () => {
this.data.title = this.titleInput.value
this.setDirty()
})
this.setup()
}
@ -46,14 +56,27 @@ export default class Editor {
return null
}).then(res => {
if (res !== null) {
this.data = res
this.metadata = loadMetadata(res)
this.displayData()
}
})
}
displayData() {
document.getElementById("title").value = this.data.title
const seriesToolbar = document.getElementById("series-toolbar")
if (this.metadata instanceof SeriesMetadata) {
seriesToolbar.classList.add("show")
const cur = this.metadata.episodeIdx + 1
const tot = this.metadata.episodes.length
const epMeta = this.metadata.getCurrentEpisode()
const season = epMeta.season
const episode = epMeta.episode
seriesToolbar.querySelector("#cur-episode").innerText = `S${season}E${episode} (${cur} / ${tot})`
} else {
seriesToolbar.classList.remove("show")
}
this.data = this.metadata.getData()
this.titleInput.value = this.data.title
this.tables.audio.loadTracks(this.data.audio_tracks)
this.tables.subtitle.loadTracks(this.data.subtitle_tracks)
}
@ -61,7 +84,7 @@ export default class Editor {
save() {
fetch(`/api/file/${this.filename}`, {
method: "POST",
body: JSON.stringify(this.data),
body: JSON.stringify(this.metadata.data),
headers: {
"Content-Type": "application/json"
}
@ -100,13 +123,13 @@ export default class Editor {
}
checkIntegrity() {
if (this.integrity_mgr.checkIntegrity()) {
if (this.integrityMgr.checkIntegrity()) {
this.notify("No integrity error detected !", "success")
}
}
improveAllNames() {
this.integrity_mgr.improveAllNames()
this.integrityMgr.improveAllNames()
this.notify("Improved all names !", "success")
}
@ -128,4 +151,20 @@ export default class Editor {
const hist = document.getElementById("notifs-hist")
hist.classList.remove("show")
}
prevEpisode() {
if (this.metadata instanceof SeriesMetadata) {
if (this.metadata.prev()) {
this.displayData()
}
}
}
nextEpisode() {
if (this.metadata instanceof SeriesMetadata) {
if (this.metadata.next()) {
this.displayData()
}
}
}
}

View File

@ -1,31 +1,107 @@
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)
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 selectFile(event) {
const file = event.target.value
if (file !== "") {
const url = new URL("/edit/", window.location.origin)
url.searchParams.set("f", file)
window.location.href = url.href
}
function 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 => {
addOptions(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

@ -316,7 +316,7 @@ export default class IntegrityManager {
if (fields.flags.hearing_impaired) {
name += " SDH"
}
name += " : " + fields.type
name += " | " + fields.type
break
}
return 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

@ -113,6 +113,7 @@ export class Track {
"warning"
)
value = lang
this.editValue(field.key, value)
}
}
@ -140,19 +141,21 @@ export class Track {
this.table.editTrack(this.idx, key, value)
const input = this.row.querySelector(`[data-key='${key}']`)
const fieldType = this.table.getFieldProps(key).type
switch (fieldType) {
case "bool":
input.checked = value
break
default:
input.value = value
break
if (input) {
const fieldType = this.table.getFieldProps(key).type
switch (fieldType) {
case "bool":
input.checked = value
break
default:
input.value = value
break
}
}
}
improveName() {
this.table.editor.integrity_mgr.improveName(this)
this.table.editor.integrityMgr.improveName(this)
}
}
@ -184,9 +187,10 @@ export default class TracksTable {
this.table = document.getElementById(tableId)
this.headers = this.table.querySelector("thead tr")
this.body = this.table.querySelector("tbody")
this.fields = []
this.tracks = []
this.dataKey = dataKey
this.tracks = []
this.fields = []
this.onehots = {}
}
@ -195,8 +199,8 @@ export default class TracksTable {
}
loadTracks(tracks) {
this.tracks = tracks.map((t, i) => new Track(this, i, t))
this.clear()
this.tracks = tracks.map((t, i) => new Track(this, i, t))
if (tracks.length === 0) {
return
}
@ -208,9 +212,11 @@ export default class TracksTable {
}
clear() {
this.tracks = []
this.fields = []
this.onehots = {}
this.headers.innerHTML = ""
this.body.innerHTML = ""
this.fields = []
}
detectFields() {

View File

@ -41,6 +41,11 @@ export const LANGUAGES = {
code: "de",
aliases: ["de", "ger", "german", "allemand", "deutsch", "germany", "allemagne"]
},
"ita": {
display: "Italiano",
code: "it",
aliases: ["it", "ita", "italian", "italien", "italiano", "italy", "italie"]
},
"kor": {
display: "Korean",
code: "kr",
@ -64,7 +69,7 @@ export const LANGUAGES = {
}
export function getLanguageAliases(langTag) {
return (langTag === "und" ? [] : [langTag]).concat(LANGUAGES[langTag].aliases)
return [langTag].concat(LANGUAGES[langTag].aliases)
}
/**

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

@ -1,16 +1,40 @@
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
#!/usr/bin/env python3
import argparse
import json
import os
import socketserver
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
from typing import Optional
from urllib.parse import urlparse, parse_qs, unquote
from urllib.parse import parse_qs, unquote, urlparse
# https://stackoverflow.com/a/10551190/11109181
class EnvDefault(argparse.Action):
def __init__(self, envvar, required=True, default=None, help=None, **kwargs):
if envvar:
if envvar in os.environ:
default = os.environ[envvar]
if required and default is not None:
required = False
if default is not None and help is not None:
help += f" (default: {default})"
if envvar and help is not None:
help += f"\nCan also be specified through the {envvar} environment variable"
super(EnvDefault, self).__init__(default=default, required=required, help=help,
**kwargs)
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, values)
PORT = 8000
MAX_SIZE = 10e6
class MyHandler(SimpleHTTPRequestHandler):
MAX_PAYLOAD_SIZE = 1e6
DATA_DIR = "metadata"
CACHE = {}
def __init__(self, *args, **kwargs):
super().__init__(
@ -22,12 +46,11 @@ class MyHandler(SimpleHTTPRequestHandler):
self.data: Optional[dict|list] = None
def read_body_data(self):
self.log_message("Reading body data")
try:
size: int = int(self.headers["Content-Length"])
if size > MAX_SIZE:
if size > self.MAX_PAYLOAD_SIZE:
self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
self.log_error(f"Payload is too big ({MAX_SIZE=}B)")
self.log_error(f"Payload is too big ({self.MAX_PAYLOAD_SIZE=}B)")
return False
raw_data = self.rfile.read(size)
self.data = json.loads(raw_data)
@ -54,18 +77,16 @@ class MyHandler(SimpleHTTPRequestHandler):
self.send_error(HTTPStatus.NOT_FOUND)
def handle_api_get(self, path: str):
print(f"API request at {path}")
self.log_message(f"API request at {path}")
if path == "files":
files: list[str] = self.get_files()
files: list[str] = self.get_files_meta()
self.send_json(files)
elif path.startswith("file"):
filename: str = path.split("/", 1)[1]
data = self.read_file(filename)
if data is None:
self.send_error(HTTPStatus.NOT_FOUND)
self.log_message("File not found")
else:
self.log_message("Got file")
self.send_json(data)
else:
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
@ -104,17 +125,89 @@ class MyHandler(SimpleHTTPRequestHandler):
return False
try:
with open(os.path.join(self.DATA_DIR, filename), "w") as f:
json.dump(data, f, indent=2)
with open(os.path.join(self.DATA_DIR, filename), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except:
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
return False
return True
def get_files_meta(self):
files: list[str] = self.get_files()
files_meta: list[dict] = []
deleted = set(self.CACHE.keys()) - set(files)
for filename in deleted:
del self.CACHE[deleted]
for filename in files:
path: str = os.path.join(self.DATA_DIR, filename)
last_modified: float = os.path.getmtime(path)
if filename not in self.CACHE or self.CACHE[filename]["ts"] < last_modified:
self.update_file_meta(filename)
files_meta.append(self.CACHE[filename])
return files_meta
def update_file_meta(self, filename: str):
path: str = os.path.join(self.DATA_DIR, filename)
meta = {
"filename": filename,
"ts": os.path.getmtime(path)
}
with open(path, "r") as f:
data = json.load(f)
is_series = "filename" not in data
meta["type"] = "series" if is_series else "film"
if is_series:
meta["episodes"] = len(data)
meta["title"] = filename.split("_metadata")[0]
else:
meta["title"] = data["title"]
self.CACHE[filename] = meta
def main():
with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
print(f"Serving on port {PORT}")
parser = argparse.ArgumentParser(
description="Starts the Melies server",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument(
"-p", "--port",
action=EnvDefault,
envvar="MELIES_PORT",
default=8000,
type=int,
help="Port on which the server listens"
)
parser.add_argument(
"--max-payload-size",
action=EnvDefault,
envvar="MELIES_MAX_PAYLOAD_SIZE",
default=1e6,
type=int,
help="Maximum POST payload size in bytes that the server accepts"
)
parser.add_argument(
"--metadata-dir",
action=EnvDefault,
envvar="MELIES_METADATA_DIR",
default="metadata",
help="Path to the directory containing metadata files"
)
args = parser.parse_args()
port = args.port
MyHandler.MAX_PAYLOAD_SIZE = args.max_payload_size
MyHandler.DATA_DIR = args.metadata_dir
with socketserver.TCPServer(("", port), MyHandler) as httpd:
print(f"Serving on port {port}")
httpd.serve_forever()

View File

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

View File

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