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> </header>
<main id="main"> <main id="main">
<h1>Editing <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1> <h1>Editing <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1>
<div id="series-toolbar">
<button id="prev-episode"></button>
<div id="cur-episode"></div>
<button id="next-episode"></button>
</div>
<div class="sep"></div>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<label for="title">Title</label> <label for="title">Title</label>

View File

@ -5,6 +5,7 @@
<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>
@ -12,10 +13,41 @@
<h1>Metadata Editor</h1> <h1>Metadata Editor</h1>
</header> </header>
<main> <main>
<div> <a id="film-template" class="template file film">
<label for="file-sel">Choose a file</label> <img src="/static/images/film.svg">
<select name="file-sel" id="file-sel" oninput="selectFile(event)"></select> <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>
<div id="files"></div>
</main> </main>
</body> </body>
</html> </html>

View File

@ -337,9 +337,12 @@ button.improve {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 30em;
transform: translateX(0%);
transition: transform 0.5s cubic-bezier(0.22, 0.61, 0.36, 1);
&:not(.show) { &:not(.show) {
display: none; transform: translateX(100%);
} }
#close-notifs { #close-notifs {
@ -369,4 +372,44 @@ button.improve {
padding: 0.4em; padding: 0.4em;
} }
} }
}
.sep {
border-bottom: solid black 1px;
}
#series-toolbar {
display: flex;
gap: 0.4em;
padding: 0.4em;
align-items: center;
&:not(.show) {
display: none;
}
button {
background: var(--img);
width: 2.4em;
height: 2.4em;
background-color: transparent;
border: none;
background-size: 80%;
background-position: center;
background-repeat: no-repeat;
cursor: pointer;
border-radius: 0.4em;
&:hover {
background-color: #f1f1f1;
}
&#prev-episode {
--img: url("/static/images/prev.svg");
}
&#next-episode {
--img: url("/static/images/next.svg");
}
}
} }

View File

@ -0,0 +1,96 @@
#files {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
grid-auto-rows: 15em;
gap: 0.8em;
place-items: center;
padding: 0.8em 0;
.file {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 100%;
height: 100%;
text-decoration: none;
color: black;
font-family: inherit;
font-size: inherit;
padding: 0.4em;
border-radius: 1.2em;
&.hidden {
display: none;
}
&:hover {
background-color: #f8f8f8;
}
img {
width: 10em;
height: 10em;
}
.title {
overflow-wrap: anywhere;
text-align: center;
font-weight: bold;
}
}
}
.toolbar {
display: flex;
gap: 1.2em;
border-bottom: solid black 1px;
padding: 0.4em 0;
.tool {
display: flex;
flex-direction: column;
gap: 0.2em;
label[for] {
font-weight: bold;
}
input, select {
font-family: inherit;
font-size: inherit;
height: 100%;
}
.toggle {
height: 2em;
border-radius: 1em;
display: grid;
grid-template-columns: 1fr 1fr;
user-select: none;
cursor: pointer;
input {
display: none;
&:not(:checked) ~ .off, &:checked ~ .on {
background-color: #6ee74a;
}
}
div {
padding: 0 0.4em;
display: grid;
place-items: center;
&.off {
border-radius: 1em 0 0 1em;
}
&.on {
border-radius: 0 1em 1em 0;
}
}
}
}
}

View File

@ -0,0 +1,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 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() {
@ -18,10 +19,11 @@ 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.integrity_mgr = new IntegrityManager(this) this.integrityMgr = new IntegrityManager(this)
document.getElementById("check-integrity").addEventListener("click", () => this.checkIntegrity()) document.getElementById("check-integrity").addEventListener("click", () => this.checkIntegrity())
document.getElementById("improve-all").addEventListener("click", () => this.improveAllNames()) 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("reload").addEventListener("click", () => window.location.reload())
document.getElementById("toggle-notifs").addEventListener("click", () => this.toggleNotifications()) document.getElementById("toggle-notifs").addEventListener("click", () => this.toggleNotifications())
document.getElementById("close-notifs").addEventListener("click", () => this.closeNotifications()) 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()
} }
@ -46,14 +56,27 @@ export default class Editor {
return null return null
}).then(res => { }).then(res => {
if (res !== null) { if (res !== null) {
this.data = res this.metadata = loadMetadata(res)
this.displayData() this.displayData()
} }
}) })
} }
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.audio.loadTracks(this.data.audio_tracks)
this.tables.subtitle.loadTracks(this.data.subtitle_tracks) this.tables.subtitle.loadTracks(this.data.subtitle_tracks)
} }
@ -61,7 +84,7 @@ export default class Editor {
save() { save() {
fetch(`/api/file/${this.filename}`, { fetch(`/api/file/${this.filename}`, {
method: "POST", method: "POST",
body: JSON.stringify(this.data), body: JSON.stringify(this.metadata.data),
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
@ -100,13 +123,13 @@ export default class Editor {
} }
checkIntegrity() { checkIntegrity() {
if (this.integrity_mgr.checkIntegrity()) { if (this.integrityMgr.checkIntegrity()) {
this.notify("No integrity error detected !", "success") this.notify("No integrity error detected !", "success")
} }
} }
improveAllNames() { improveAllNames() {
this.integrity_mgr.improveAllNames() this.integrityMgr.improveAllNames()
this.notify("Improved all names !", "success") this.notify("Improved all names !", "success")
} }
@ -128,4 +151,20 @@ export default class Editor {
const hist = document.getElementById("notifs-hist") const hist = document.getElementById("notifs-hist")
hist.classList.remove("show") 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) { let fileNodes = []
const select = document.getElementById("file-sel")
select.innerHTML = "" function makeFilm(meta) {
const defaultOpt = document.createElement("option") const file = document.getElementById("film-template").cloneNode(true)
defaultOpt.innerText = "----- Select a file -----" file.querySelector(".title").innerText = meta.title
defaultOpt.value = "" return file
select.appendChild(defaultOpt) }
files.forEach(file => {
const option = document.createElement("option") function makeSeries(meta) {
option.innerText = file const file = document.getElementById("series-template").cloneNode(true)
option.value = file file.querySelector(".title").innerText = meta.title
select.appendChild(option) 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) { function sortFiles() {
const file = event.target.value const sortBy = document.getElementById("sort-by").value
if (file !== "") { const sortDesc = document.getElementById("sort-desc").checked
const url = new URL("/edit/", window.location.origin) const filter = document.getElementById("filter").value
url.searchParams.set("f", file)
window.location.href = url.href 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 => {
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) { if (fields.flags.hearing_impaired) {
name += " SDH" name += " SDH"
} }
name += " : " + fields.type name += " | " + fields.type
break break
} }
return name 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" "warning"
) )
value = lang value = lang
this.editValue(field.key, value)
} }
} }
@ -140,19 +141,21 @@ 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}']`)
const fieldType = this.table.getFieldProps(key).type if (input) {
switch (fieldType) { const fieldType = this.table.getFieldProps(key).type
case "bool": switch (fieldType) {
input.checked = value case "bool":
break input.checked = value
default: break
input.value = value default:
break input.value = value
break
}
} }
} }
improveName() { 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.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.fields = []
this.tracks = []
this.dataKey = dataKey this.dataKey = dataKey
this.tracks = []
this.fields = []
this.onehots = {} this.onehots = {}
} }
@ -195,8 +199,8 @@ export default class TracksTable {
} }
loadTracks(tracks) { loadTracks(tracks) {
this.tracks = tracks.map((t, i) => new Track(this, i, t))
this.clear() this.clear()
this.tracks = tracks.map((t, i) => new Track(this, i, t))
if (tracks.length === 0) { if (tracks.length === 0) {
return return
} }
@ -208,9 +212,11 @@ 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

@ -41,6 +41,11 @@ export const LANGUAGES = {
code: "de", code: "de",
aliases: ["de", "ger", "german", "allemand", "deutsch", "germany", "allemagne"] aliases: ["de", "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",
@ -64,7 +69,7 @@ export const LANGUAGES = {
} }
export function getLanguageAliases(langTag) { 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 #!/usr/bin/env python3
from http.server import SimpleHTTPRequestHandler
import argparse
import json import json
import os import os
import socketserver import socketserver
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
from typing import Optional from typing import Optional
from urllib.parse import urlparse, parse_qs, 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): 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__(
@ -22,12 +46,11 @@ 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 > MAX_SIZE: if size > self.MAX_PAYLOAD_SIZE:
self.send_error(HTTPStatus.CONTENT_TOO_LARGE) 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 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)
@ -54,18 +77,16 @@ class MyHandler(SimpleHTTPRequestHandler):
self.send_error(HTTPStatus.NOT_FOUND) self.send_error(HTTPStatus.NOT_FOUND)
def handle_api_get(self, path: str): def handle_api_get(self, path: str):
print(f"API request at {path}") self.log_message(f"API request at {path}")
if path == "files": if path == "files":
files: list[str] = self.get_files() files: list[str] = self.get_files_meta()
self.send_json(files) self.send_json(files)
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}")
@ -104,17 +125,89 @@ class MyHandler(SimpleHTTPRequestHandler):
return False return False
try: try:
with open(os.path.join(self.DATA_DIR, filename), "w") as f: with open(os.path.join(self.DATA_DIR, filename), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2, ensure_ascii=False)
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():
with socketserver.TCPServer(("", PORT), MyHandler) as httpd: parser = argparse.ArgumentParser(
print(f"Serving on port {PORT}") description="Starts the Melies server",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument(
"-p", "--port",
action=EnvDefault,
envvar="MELIES_PORT",
default=8000,
type=int,
help="Port on which the server listens"
)
parser.add_argument(
"--max-payload-size",
action=EnvDefault,
envvar="MELIES_MAX_PAYLOAD_SIZE",
default=1e6,
type=int,
help="Maximum POST payload size in bytes that the server accepts"
)
parser.add_argument(
"--metadata-dir",
action=EnvDefault,
envvar="MELIES_METADATA_DIR",
default="metadata",
help="Path to the directory containing metadata files"
)
args = parser.parse_args()
port = args.port
MyHandler.MAX_PAYLOAD_SIZE = args.max_payload_size
MyHandler.DATA_DIR = args.metadata_dir
with socketserver.TCPServer(("", port), MyHandler) as httpd:
print(f"Serving on port {port}")
httpd.serve_forever() httpd.serve_forever()

View File

@ -55,7 +55,6 @@ 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,8 +55,6 @@ 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
@ -73,7 +71,6 @@ 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
} }
@ -86,13 +83,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_file=None): def process_file(file_path, output_dir=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_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): if not os.path.isfile(file_path):
print(f"❌ File not found: {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) metadata = get_video_metadata(file_path)
if metadata: 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] 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 # 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) 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 True
return False 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. 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_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): if not os.path.isdir(directory_path):
print(f"❌ Directory not found: {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}") print(f"❌ No supported video files found in {directory_path}")
return False return False
if not output_file: # Generate output filename based on directory name
# Generate output filename based on directory name dir_name = os.path.basename(os.path.normpath(directory_path))
dir_name = os.path.basename(os.path.normpath(directory_path)) filename = f"{dir_name}_metadata.json"
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_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) 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 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="Path to output JSON file") parser.add_argument("-o", "--output", help="Directory path where output JSON files will be saved")
args = parser.parse_args() args = parser.parse_args()
input_path = args.input input_path = args.input
output_file = args.output output_dir = args.output
if os.path.isfile(input_path): if os.path.isfile(input_path):
process_file(input_path, output_file) process_file(input_path, output_dir)
elif os.path.isdir(input_path): elif os.path.isdir(input_path):
process_directory(input_path, output_file) process_directory(input_path, output_dir)
else: else:
print(f"❌ Path not found: {input_path}") print(f"❌ Path not found: {input_path}")
sys.exit(1) sys.exit(1)