Compare commits

...

4 Commits

19 changed files with 1886 additions and 257 deletions

View File

@ -2,4 +2,6 @@ __pycache__/
*.pyc *.pyc
.git .git
.env .env
metadata /to_convert/
/converted/
/metadata/

4
.gitignore vendored
View File

@ -1 +1,3 @@
metadata /to_convert/
/converted/
/metadata/

View File

@ -1,7 +1,29 @@
FROM python:3.13.3-alpine FROM debian:bullseye-slim AS builder
# Install ffmpeg and mkvtoolnix
# but only keep the binaries and libs for ffprobe and mkvmerge
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg mkvtoolnix \
&& mkdir -p /artifacts/bin /artifacts/lib \
&& cp $(which ffprobe) /artifacts/bin/ \
&& cp $(which mkvmerge) /artifacts/bin/ \
&& ldd $(which ffprobe) | awk '{print $3}' | xargs -I '{}' cp -v '{}' /artifacts/lib/ || true \
&& ldd $(which mkvmerge) | awk '{print $3}' | xargs -I '{}' cp -v '{}' /artifacts/lib/ || true
# Must be the same base as builder image for shared libraries compatibility
FROM python:3.13.3-slim-bullseye
COPY --from=builder /artifacts/bin/* /usr/local/bin/
COPY --from=builder /artifacts/lib/* /usr/local/lib/
ENV LD_LIBRARY_PATH=/usr/local/lib
WORKDIR /app WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
CMD ["python3.13", "src/server.py"] EXPOSE 8000
CMD ["python", "src/server.py"]

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
watchdog==6.0.0

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Melies - Conversion</title>
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/conversion.css">
<link rel="shortcut icon" href="/static/images/icon3.svg" type="image/svg+xml">
<script src="/static/js/conversion.js"></script>
</head>
<body>
<header>
<h1>Media Conversion</h1>
</header>
<main>
<button id="folder-template" class="template file folder">
<img src="/static/images/collection.svg">
<div class="name"></div>
<div class="children"><span class="num"></span> element(s)</div>
</button>
<button id="media-template" class="template file media">
<img src="/static/images/film.svg">
<div class="name"></div>
<div class="info"><span class="size"></span> / <span class="ext"></span></div>
</button>
<div id="selected-template" class="template file">
<button class="deselect">Deselect</button>
<div class="name"></div>
</div>
<label id="agent-template" class="template agent">
<input type="radio" name="selected-agent">
<div class="container">
<div class="icon"></div>
<div class="name"></div>
</div>
</label>
<div id="to-convert-panel" class="panel">
<h2 class="title">To convert</h2>
<button id="up">Parent directory</button>
<div id="no-file" class="empty-msg">Director is empty</div>
<div id="files"></div>
</div>
<div id="selected-panel" class="panel">
<h2 class="title">Selected</h2>
<div class="toolbar">
<button id="show-files" class="hidden"><img src="/static/images/prev.svg">Back to files</button>
<button id="deselect-all">Deselect All</button>
<button id="continue" disabled>
<div class="disabled">Select a media</div>
<div class="enabled">Continue<img src="/static/images/next.svg"></div>
</button>
</div>
<div id="selected"></div>
</div>
<div id="agents-panel" class="panel hidden">
<h2 class="title">Agents</h2>
<div id="no-agent" class="empty-msg show">No registered agent</div>
<div id="agents"></div>
<button id="convert" disabled>
<div class="disabled">Select an agent</div>
<div class="enabled">Convert</div>
</button>
</div>
</main>
</body>
</html>

View File

@ -3,51 +3,28 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metadata Editor</title> <title>Melies</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"> <link rel="stylesheet" href="/static/css/index.css">
<link rel="shortcut icon" href="/static/images/icon3.svg" type="image/svg+xml">
<script src="/static/js/index.js"></script> <script src="/static/js/index.js"></script>
</head> </head>
<body> <body>
<header> <header>
<h1>Metadata Editor</h1> <img src="/static/images/icon3.svg">
<h1>Melies</h1>
</header> </header>
<main> <main>
<a id="film-template" class="template file film"> <nav id="pages">
<img src="/static/images/film.svg"> <a class="page" href="/conversion/">
<div class="title"></div> <img src="/static/images/conversion.svg">
</a> <div class="name">Conversion</div>
<a id="series-template" class="template file series"> </a>
<img src="/static/images/series.svg"> <a class="page" href="/metadata/">
<div class="title"></div> <img src="/static/images/metadata.svg">
<div class="episodes"><span class="num"></span> episode(s)</div> <div class="name">Metadata</div>
</a> </a>
<div class="toolbar"> </nav>
<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> </main>
</body> </body>
</html> </html>

View File

@ -3,9 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit</title> <title>Melies - 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/edit.css"> <link rel="stylesheet" href="/static/css/edit.css">
<link rel="shortcut icon" href="/static/images/icon3.svg" type="image/svg+xml">
<script src="/static/js/edit.mjs" type="module"></script> <script src="/static/js/edit.mjs" type="module"></script>
</head> </head>
<body> <body>
@ -14,7 +15,7 @@
<img class="clicked" src="/static/images/improve_clicked.svg"> <img class="clicked" src="/static/images/improve_clicked.svg">
</button> </button>
<header id="toolbar"> <header id="toolbar">
<a href="/">Home</a> <a href="/metadata/">Back</a>
<button id="check-integrity">Check integrity</button> <button id="check-integrity">Check integrity</button>
<button id="improve-all">Improve all names</button> <button id="improve-all">Improve all names</button>
<button id="save">Save</button> <button id="save">Save</button>

View File

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

View File

@ -0,0 +1,265 @@
body {
display: flex;
flex-direction: column;
}
main {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
flex: 1;
overflow-y: hidden;
}
.panel {
display: flex;
flex-direction: column;
padding: 0.8em;
border: solid black 2px;
overflow: hidden;
&.hidden {
display: none;
}
.title {
margin: 0.4em 0;
}
}
button {
font-family: inherit;
font-size: inherit;
padding: 0.4em 0.8em;
border-radius: 0.4em;
background: none;
cursor: pointer;
border: solid #c5c5c5 2px;
text-align: center;
font-weight: bold;
&:hover {
background-color: #f8f8f8;
}
}
#up {
width: 100%;
padding: 0.8em 1.6em;
border-radius: 1.2em;
&:not(.show) {
display: none;
}
}
#files {
display: grid;
grid-template-columns: repeat(auto-fill, 12em);
grid-auto-rows: 12em;
gap: 0.8em;
place-items: center;
padding: 0.8em 0;
justify-content: space-evenly;
height: 100%;
overflow: auto;
.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;
background: none;
cursor: pointer;
gap: 0.2em;
border: solid transparent 2px;
&.selected {
border-color: rgb(153, 226, 153);
}
&:hover {
background-color: #f8f8f8;
}
img {
width: 5em;
height: 5em;
}
.name {
overflow-wrap: anywhere;
text-align: center;
font-weight: bold;
flex-shrink: 1;
overflow: hidden;
}
.info, .children {
font-size: 80%;
color: rgb(75, 75, 75);
font-style: italic;
font-weight: normal;
}
}
}
#selected {
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
.file {
display: flex;
padding: 0.4em 0.8em;
gap: 0.4em;
align-items: center;
&:nth-child(even) {
background-color: #fafafa;
}
.deselect {
background-color: #ffd8c6;
&:hover {
background-color: #ffc3a7;
}
}
.name {
overflow-wrap: anywhere;
}
}
}
.toolbar {
display: flex;
gap: 0.4em;
button, button .enabled {
display: flex;
gap: 0.2em;
align-items: center;
&.hidden {
display: none;
}
img {
width: 1.4em;
height: 1.4em;
}
}
}
.empty-msg {
color: #4d4d4d;
font-style: italic;
font-size: 120%;
text-align: center;
padding: 1.2em 0.4em;
&:not(.show) {
display: none;
}
}
#agents {
display: grid;
grid-template-columns: repeat(auto-fill, 12em);
grid-auto-rows: 12em;
gap: 0.8em;
place-items: center;
padding: 0.8em 0;
justify-content: space-evenly;
height: 100%;
overflow: auto;
.agent {
input {
display: none;
}
.container {
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;
background: none;
cursor: pointer;
gap: 0.2em;
border: none;
&.selected {
border-color: #99e299;
}
&:hover {
background-color: #f8f8f8;
}
.icon {
width: 8em;
height: 8em;
mask-image: url("/static/images/agent.svg");
mask-size: contain;
background-color: black;
}
.name {
overflow-wrap: anywhere;
text-align: center;
font-weight: bold;
font-size: 120%;
flex-shrink: 1;
overflow: hidden;
}
}
input:checked + .container {
.icon {
background-color: #1bb11b;
}
.name {
color: #1bb11b;
}
}
}
}
#convert {
font-size: 120%;
}
#continue, #convert {
&:not(:disabled) {
.disabled {
display: none;
}
}
&:disabled {
.enabled {
display: none;
}
}
}

View File

@ -1,12 +1,23 @@
#files { header {
align-items: center;
img {
width: 4em;
height: 4em;
object-fit: contain;
}
}
#pages {
display: grid; display: grid;
max-width: calc(max(50%, 20em));
margin: 0 auto;
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr)); grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
grid-auto-rows: 15em; grid-auto-rows: 15em;
gap: 0.8em; gap: 0.8em;
place-items: center; place-items: center;
padding: 0.8em 0; padding: 0.8em 0;
.file { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -20,10 +31,6 @@
padding: 0.4em; padding: 0.4em;
border-radius: 1.2em; border-radius: 1.2em;
&.hidden {
display: none;
}
&:hover { &:hover {
background-color: #f8f8f8; background-color: #f8f8f8;
} }
@ -33,64 +40,11 @@
height: 10em; height: 10em;
} }
.title { .name {
overflow-wrap: anywhere; overflow-wrap: anywhere;
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
} font-size: 150%;
}
}
.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,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,498 @@
<?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"
sodipodi:docname="agent.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
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="66.610678"
inkscape:cy="60.468655"
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="1"
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-effect17"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,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-effect16"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,2.3893809,0,1 @ F,0,1,1,0,0,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"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,0,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,1.9863443,0,1 @ F,0,1,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.1885909,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.1885909,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.1885909,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.1885909,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.1885909,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.1885909,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,0,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="copy_rotate"
starting_point="80,71.686292"
origin="64,71.686292"
id="path-effect8"
is_visible="true"
lpeversion="1.2"
lpesatellites=""
method="fuse_paths"
num_copies="8"
starting_angle="0"
rotation_angle="45"
gap="-0.01"
copies_to_360="true"
mirror_copies="false"
split_items="false"
link_styles="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect7"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,0,1 @ F,0,1,1,0,2.8348774,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-effect4"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect3"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,9,0,1 @ F,0,1,1,0,9,0,1 @ F,0,1,1,0,9,0,1 @ F,0,1,1,0,9,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-effect4-7"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-0"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-7-9"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-2-3"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-79"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-7-2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-2-0"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-8"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-7-97"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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-effect4-2-36"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,3,0,1 @ F,0,1,1,0,3,0,1 @ F,0,0,1,0,0,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">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 32,41 v 46 a 9,9 45 0 0 9,9 h 46 a 9,9 135 0 0 9,-9 V 41 A 9,9 45 0 0 87,32 H 41 a 9,9 135 0 0 -9,9 z"
id="path3"
inkscape:path-effect="#path-effect3"
inkscape:original-d="M 32,32 V 96 H 96 V 32 Z" />
<g
id="g4">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4"
inkscape:path-effect="#path-effect4"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-3"
inkscape:path-effect="#path-effect4-7"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc"
transform="translate(0,16)" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-1"
inkscape:path-effect="#path-effect4-2"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc"
transform="translate(0,32)" />
</g>
<g
id="g4-0"
transform="matrix(-1,0,0,1,128,0)">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-6"
inkscape:path-effect="#path-effect4-0"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-3-2"
inkscape:path-effect="#path-effect4-7-9"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc"
transform="translate(0,16)" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-1-6"
inkscape:path-effect="#path-effect4-2-3"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc"
transform="translate(0,32)" />
</g>
<g
id="g4-3"
transform="rotate(90,64,64)">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-7"
inkscape:path-effect="#path-effect4-79"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-3-5"
inkscape:path-effect="#path-effect4-7-2"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc"
transform="translate(0,16)" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-1-9"
inkscape:path-effect="#path-effect4-2-0"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc"
transform="translate(0,32)" />
</g>
<g
id="g4-2"
transform="rotate(-90,64,64)">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-9"
inkscape:path-effect="#path-effect4-8"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-3-3"
inkscape:path-effect="#path-effect4-7-97"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc"
transform="translate(0,16)" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 32,45 H 19 a 3,3 135 0 0 -3,3 3,3 45 0 0 3,3 h 13"
id="path4-1-1"
inkscape:path-effect="#path-effect4-2-36"
inkscape:original-d="M 32,45 H 16 v 6 h 16"
sodipodi:nodetypes="cccc"
transform="translate(0,32)" />
</g>
<path
id="path15"
style="baseline-shift:baseline;display:inline;overflow:visible;fill:#000000;fill-opacity:0.33900854;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1;stroke-opacity:1;stroke-width:2;stroke-dasharray:none"
d="M 63.496094 38.013672 A 4.2237119 4.2237119 0 0 0 59.345703 41.453125 L 58.523438 45.792969 A 2.2921583 2.2921583 0 0 1 54.980469 47.261719 L 51.337891 44.777344 A 4.2259625 4.2259625 0 0 0 45.970703 45.28125 L 45.267578 45.982422 A 4.2239053 4.2239053 0 0 0 44.765625 51.349609 L 47.251953 54.998047 A 2.292144 2.292144 0 0 1 45.785156 58.541016 L 41.453125 59.363281 A 4.2252807 4.2252807 0 0 0 38.013672 63.513672 L 38.013672 64.505859 A 4.2247307 4.2247307 0 0 0 41.453125 68.65625 L 45.792969 69.478516 A 2.2921527 2.2921527 0 0 1 47.261719 73.021484 L 44.777344 76.664062 A 4.2252132 4.2252132 0 0 0 45.28125 82.03125 L 45.982422 82.732422 A 4.2246351 4.2246351 0 0 0 51.349609 83.234375 L 54.998047 80.748047 A 2.292138 2.292138 0 0 1 58.541016 82.214844 L 59.363281 86.546875 A 4.2263602 4.2263602 0 0 0 63.513672 89.986328 L 64.503906 89.988281 A 4.2236546 4.2236546 0 0 0 68.654297 86.548828 L 69.476562 82.208984 A 2.2921525 2.2921525 0 0 1 73.019531 80.740234 L 76.662109 83.224609 A 4.2259565 4.2259565 0 0 0 82.029297 82.720703 L 82.732422 82.019531 A 4.2239049 4.2239049 0 0 0 83.234375 76.652344 L 80.748047 73.003906 A 2.292144 2.292144 0 0 1 82.214844 69.460938 L 86.546875 68.638672 A 4.2252807 4.2252807 0 0 0 89.986328 64.488281 L 89.986328 63.496094 A 4.2247307 4.2247307 0 0 0 86.546875 59.345703 L 82.207031 58.523438 A 2.2921527 2.2921527 0 0 1 80.738281 54.980469 L 83.222656 51.337891 A 4.2259563 4.2259563 0 0 0 82.71875 45.970703 L 82.017578 45.267578 A 4.2239034 4.2239034 0 0 0 76.650391 44.765625 L 73.001953 47.251953 A 2.2922228 2.2922228 0 0 1 69.457031 45.785156 L 68.638672 41.455078 A 4.226441 4.226441 0 0 0 64.486328 38.015625 L 63.496094 38.013672 z M 64 54 A 10 10 0 0 1 74 64 A 10 10 0 0 1 64 74 A 10 10 0 0 1 54 64 A 10 10 0 0 1 64 54 z " />
<path
sodipodi:type="star"
style="display:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path8"
inkscape:flatsided="true"
sodipodi:sides="8"
sodipodi:cx="64"
sodipodi:cy="64"
sodipodi:r1="16"
sodipodi:r2="14.782073"
sodipodi:arg1="0"
sodipodi:arg2="0.39269908"
inkscape:rounded="0"
inkscape:randomized="0"
d="M 80,64 75.313708,75.313708 64,80 52.686292,75.313708 48,64 52.686292,52.686292 64,48 75.313708,52.686292 Z"
transform="rotate(-22.5,64,64)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,107 @@
<?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="conversion.svg"
inkscape:export-filename="icon3.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
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"
inkscape:cx="52.5625"
inkscape:cy="60.0625"
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="1"
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-effect18"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,2.2320508,0,1 @ F,0,1,1,0,2.2320508,0,1 @ F,0,1,1,0,2.2320508,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="g13"
transform="matrix(1.21875,0,0,1.21875,-13.728742,-14)">
<path
id="path14"
style="fill:none;stroke:#000000;stroke-width:1.64103;stroke-linecap:round;stroke-linejoin:round"
d="m 30.956917,64 c 0,-18.126268 14.694243,-32.820511 32.820511,-32.820511 M 96.597939,64 c 0,18.126268 -14.694243,32.820511 -32.820511,32.820511"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:1.64103;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 57.213327,24.615384 6.564102,6.564103 -6.564102,6.564102"
id="path15" />
<path
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:1.64103;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 70.341532,103.38461 63.777429,96.820512 70.341532,90.25641"
id="path16" />
</g>
<path
id="path17"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 40 39 L 40 89 L 48 89 L 48 85 L 80 85 L 80 89 L 88 89 L 88 39 L 80 39 L 80 44 L 48 44 L 48 39 L 40 39 z M 42 44.5 L 46 44.5 L 46 47.5 L 42 47.5 L 42 44.5 z M 82 44.5 L 86 44.5 L 86 47.5 L 82 47.5 L 82 44.5 z M 48 48 L 80 48 L 80 80 L 48 80 L 48 48 z M 42 50.5 L 46 50.5 L 46 53.5 L 42 53.5 L 42 50.5 z M 82 50.5 L 86 50.5 L 86 53.5 L 82 53.5 L 82 50.5 z M 42 56.5 L 46 56.5 L 46 59.5 L 42 59.5 L 42 56.5 z M 82 56.5 L 86 56.5 L 86 59.5 L 82 59.5 L 82 56.5 z M 42 62.5 L 46 62.5 L 46 65.5 L 42 65.5 L 42 62.5 z M 82 62.5 L 86 62.5 L 86 65.5 L 82 65.5 L 82 62.5 z M 42 68.5 L 46 68.5 L 46 71.5 L 42 71.5 L 42 68.5 z M 82 68.5 L 86 68.5 L 86 71.5 L 82 71.5 L 82 68.5 z M 42 74.5 L 46 74.5 L 46 77.5 L 42 77.5 L 42 74.5 z M 82 74.5 L 86 74.5 L 86 77.5 L 82 77.5 L 82 74.5 z M 42 80.5 L 46 80.5 L 46 83.5 L 42 83.5 L 42 80.5 z M 82 80.5 L 86 80.5 L 86 83.5 L 82 83.5 L 82 80.5 z " />
<path
id="path18"
style="stroke-width:2;stroke-linecap:round;stroke-linejoin:round"
inkscape:transform-center-x="-2.25"
d="m 71.066987,65.116025 -9.633974,5.562179 A 1.2886751,1.2886751 29.999999 0 1 59.5,69.562178 V 58.437822 a 1.2886751,1.2886751 150 0 1 1.933013,-1.116026 l 9.633974,5.562179 a 1.2886752,1.2886752 90 0 1 0,2.23205 z"
inkscape:path-effect="#path-effect18"
inkscape:original-d="M 73,64 59.5,71.794229 V 56.205771 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,129 @@
<?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="icon3.svg"
inkscape:export-filename="icon3.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
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="false"
inkscape:zoom="4"
inkscape:cx="100.625"
inkscape:cy="71.125"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="g13">
<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="16"
enabled="true"
visible="false" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g13"
transform="matrix(1.21875,0,0,1.21875,-13.728742,-14)">
<circle
style="fill:#d7d7d7;fill-opacity:1;stroke:none;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path8"
cx="64"
cy="64"
r="48" />
<g
id="g6"
transform="matrix(0.94549245,0.64625754,-0.64625754,0.94549245,16.322685,-59.513608)"
inkscape:transform-center-x="21.678799"
inkscape:transform-center-y="-15.571769">
<path
style="fill:#cb5739;fill-opacity:1;stroke:#434343;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 48,56 v 16 h 32 l 8,-8 -8,-8 z"
id="path2" />
<g
id="g5"
transform="translate(2.5)"
style="stroke:#434343;stroke-opacity:1">
<path
style="fill:none;stroke:#434343;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 57,60 -4,4 4,4"
id="path3" />
<path
style="fill:none;stroke:#434343;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 61,68 5,-8"
id="path4" />
<path
style="fill:none;stroke:#434343;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 70,60 4,4 -4,4"
id="path5" />
</g>
</g>
<g
id="g14"
inkscape:transform-center-x="-7.5111916"
inkscape:transform-center-y="2.6415215"
transform="matrix(1.1647434,0,0,1.1647434,-10.506912,-10.543579)">
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40891602;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="m 75.224968,50.967861 c 0,-0.542977 2.520211,-2.301228 4.451793,-2.109964 1.931582,0.191264 2.893913,2.267985 4.451792,3.230807 1.55788,0.962823 4.451793,1.235924 4.451793,1.847019 0,0.611095 -2.828267,0.995112 -3.929854,0.735062 -1.101587,-0.26005 -2.00587,-0.735062 -2.00587,-0.735062 0,0 -0.992464,4.709936 -2.967861,4.451792 -1.975397,-0.258143 -1.877599,-5.084776 -1.483931,-7.419654 -0.959231,0.180342 -2.967862,0.542978 -2.967862,0 z"
id="path9"
sodipodi:nodetypes="zzzzzczcz" />
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40891602;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="M 48.202298,89.998339 C 46.622528,88.418569 54.521379,83.679258 64,83.679258 c 9.478621,0 17.377472,4.739311 15.797702,6.319081 -1.579771,1.57977 -4.048161,-3.15954 -15.797702,-3.15954 -11.749541,0 -14.217931,4.73931 -15.797702,3.15954 z"
id="path10"
sodipodi:nodetypes="zzzzz" />
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40892;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 64,90.410017 c 1.57977,0 4.73931,0 4.73931,1.57977 0,1.579771 -3.15954,1.579771 -4.73931,1.579771 -1.57977,0 -4.73931,0 -4.73931,-1.579771 0,-1.57977 3.15954,-1.57977 4.73931,-1.57977 z"
id="path11"
sodipodi:nodetypes="zzzzz" />
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40891602;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="m 70.683501,47.557279 c 0.05473,-1.03795 1.098384,-1.720698 2.191237,-3.200114 1.092852,-1.479415 3.628351,-4.908029 6.407696,-5.223061 2.779343,-0.315033 13.286146,0.908682 13.91289,1.810272 0.847011,1.218452 -12.113125,-2.037935 -15.945302,1.886694 -3.832177,3.924627 -6.621251,5.76416 -6.566521,4.726209 z"
id="path12"
sodipodi:nodetypes="zzzszz" />
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40891602;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="m 45,80 c 1,0 7,-9 9,-9 2,0 7,3 10,3 3,0 8,-3 10,-3 2,0 8,9 9,9 1,0 -6,-11 -7,-12 -1,-1 -11,4 -12,4 -1,0 -11,-5 -12,-4 -1,1 -8,12 -7,12 z"
id="path13"
sodipodi:nodetypes="zzzzzzzzz" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -0,0 +1,92 @@
<?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="metadata.svg"
inkscape:export-filename="icon3.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
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.6568543"
inkscape:cx="56.303377"
inkscape:cy="65.672542"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="g13">
<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="16"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g13"
transform="matrix(1.21875,0,0,1.21875,-13.728742,-14)">
<g
id="g6"
transform="matrix(1.7902099,0,0,1.7902099,-54.376425,-50.573434)"
inkscape:transform-center-x="-4.3636355"
style="stroke:#000000;stroke-opacity:1;fill:none;fill-opacity:1;stroke-width:0.91666661;stroke-dasharray:none">
<path
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.91666661;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="m 48,56 v 16 h 32 l 8,-8 -8,-8 z"
id="path2" />
<g
id="g5"
transform="translate(2.5)"
style="stroke:#000000;stroke-opacity:1;fill:none;fill-opacity:1;stroke-width:0.91666661;stroke-dasharray:none">
<path
style="fill:none;stroke:#000000;stroke-width:0.91666661;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;fill-opacity:1;stroke-dasharray:none"
d="m 57,60 -4,4 4,4"
id="path3" />
<path
style="fill:none;stroke:#000000;stroke-width:0.91666661;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;fill-opacity:1;stroke-dasharray:none"
d="m 61,68 5,-8"
id="path4" />
<path
style="fill:none;stroke:#000000;stroke-width:0.91666661;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;fill-opacity:1;stroke-dasharray:none"
d="m 70,60 4,4 -4,4"
id="path5" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,231 @@
let selected = []
let currentPath = []
const SIZES = ["", "K", "M", "G", "T"]
function formatSize(bytes) {
let order = Math.floor(Math.log10(bytes) / 3)
if (bytes > Math.pow(10, (order + 1) * 3) / 2) {
order += 1
}
let size = bytes / Math.pow(10, order * 3)
size = Math.round(size * 10) / 10
const prefix = SIZES[order]
return `${size}${prefix}B`
}
function makeFolder(meta) {
const file = document.getElementById("folder-template").cloneNode(true)
file.querySelector(".name").innerText = meta.filename
file.querySelector(".children .num").innerText = meta.elements
file.addEventListener("dblclick", () => {
currentPath.push(meta.filename)
navigate()
})
return file
}
function makeMedia(meta) {
const file = document.getElementById("media-template").cloneNode(true)
file.querySelector(".name").innerText = meta.filename.split(".").slice(0, -1).join(".")
file.querySelector(".size").innerText = formatSize(meta.size)
file.querySelector(".ext").innerText = meta.filename.split(".").slice(-1)[0].toUpperCase()
return file
}
function makeFile(meta) {
let file
switch (meta.type) {
case "folder":
file = makeFolder(meta)
break
case "media":
file = makeMedia(meta)
break
default:
throw new Error(`Invalid file type '${meta.type}'`)
}
file.title = meta.filename
file.id = null
file.dataset.path = meta.path
file.classList.remove("template")
if (selected.includes(meta.path)) {
file.classList.add("selected")
}
file.addEventListener("click", e => {
if (e.ctrlKey) {
e.preventDefault()
toggleSelectFile(meta)
}
})
return file
}
function makeAgent(meta) {
const agent = document.getElementById("agent-template").cloneNode(true)
agent.classList.remove("template")
agent.removeAttribute("id")
agent.dataset.uuid = meta.uuid
agent.querySelector(".name").innerText = meta.name
const input = agent.querySelector("input[type='radio']")
input.value = meta.uuid
input.addEventListener("change", () => updateConvertBtn())
return agent
}
function toggleSelectFile(meta) {
if (selected.includes(meta.path)) {
deselectFile(meta.path)
} else {
selectFile(meta)
}
}
function selectFile(meta) {
selected.push(meta.path)
document.querySelector(`.file[data-path='${meta.path}']`)?.classList?.add("selected")
const list = document.getElementById("selected")
const line = document.getElementById("selected-template").cloneNode(true)
line.classList.remove("template")
line.id = null
line.querySelector(".name").innerText = meta.path
line.dataset.path = meta.path
line.querySelector(".deselect").addEventListener("click", () => {
deselectFile(meta.path)
})
list.appendChild(line)
const continueBtn = document.getElementById("continue")
continueBtn.disabled = false
}
function deselectFile(path) {
selected = selected.filter(p => p !== path)
document.querySelector(`#files .file[data-path='${path}']`)?.classList?.remove("selected")
document.querySelector(`#selected .file[data-path='${path}']`)?.remove()
const continueBtn = document.getElementById("continue")
continueBtn.disabled = selected.length === 0
}
function deselectAll() {
selected.forEach(path => {
deselectFile(path)
})
showFiles()
}
/**
*
* @param {object[]} files
*/
function addFiles(files) {
const emptyMsg = document.getElementById("no-file")
if (files.length === 0) {
emptyMsg.classList.add("show")
} else {
emptyMsg.classList.remove("show")
}
const list = document.getElementById("files")
const list2 = document.createElement("div")
list2.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)
list2.appendChild(file)
})
list.replaceChildren(...list2.children)
const upBtn = document.getElementById("up")
if (currentPath.length === 0) {
upBtn.classList.remove("show")
} else {
upBtn.classList.add("show")
}
}
function navigate() {
const url = new URL("/api/files/to_convert", window.location.origin)
url.searchParams.set("f", currentPath.join("/"))
fetch(url.href).then(res => {
return res.json()
}).then(files => {
addFiles(files)
})
}
function showFiles() {
document.getElementById("show-files").classList.add("hidden")
document.getElementById("continue").classList.remove("hidden")
document.getElementById("agents-panel").classList.add("hidden")
document.getElementById("to-convert-panel").classList.remove("hidden")
}
function showAgents() {
document.getElementById("continue").classList.add("hidden")
document.getElementById("show-files").classList.remove("hidden")
document.getElementById("to-convert-panel").classList.add("hidden")
document.getElementById("agents-panel").classList.remove("hidden")
}
function updateConvertBtn() {
const agent = document.querySelector("#agents .agent input:checked")
const convertBtn = document.getElementById("convert")
if (agent) {
convertBtn.disabled = false
} else {
convertBtn.disabled = true
}
}
function addAgents(agents) {
const emptyMsg = document.getElementById("no-agent")
if (agents.length === 0) {
emptyMsg.classList.add("show")
} else {
emptyMsg.classList.remove("show")
}
const selectedAgent = document.querySelector("#agents .agent input:checked")?.parentElement
const list = document.getElementById("agents")
const list2 = document.createElement("div")
list2.innerHTML = ""
const agentNames = agents.map(meta => meta.name)
// Copy array because sort changes it in place
Array.from(agentNames).sort().forEach(name => {
const i = agentNames.indexOf(name)
const meta = agents[i]
const agent = makeAgent(meta)
if (selectedAgent?.dataset?.uuid === meta.uuid) {
agent.querySelector("input").checked = true
}
list2.appendChild(agent)
})
list.replaceChildren(...list2.children)
updateConvertBtn()
}
window.addEventListener("load", () => {
document.getElementById("up").addEventListener("dblclick", () => {
if (currentPath.length !== 0) {
currentPath = currentPath.slice(0, -1)
navigate()
}
})
document.getElementById("show-files").addEventListener("click", () => {
showFiles()
})
document.getElementById("deselect-all").addEventListener("click", () => {
deselectAll()
})
document.getElementById("continue").addEventListener("click", () => {
showAgents()
})
navigate()
})

View File

@ -1,107 +0,0 @@
let fileNodes = []
function makeFilm(meta) {
const file = document.getElementById("film-template").cloneNode(true)
file.querySelector(".title").innerText = meta.title
return file
}
function makeSeries(meta) {
const file = document.getElementById("series-template").cloneNode(true)
file.querySelector(".title").innerText = meta.title
file.querySelector(".episodes .num").innerText = meta.episodes
return file
}
function makeFile(meta) {
let file
switch (meta.type) {
case "film":
file = makeFilm(meta)
break
case "series":
file = makeSeries(meta)
break
default:
throw new Error(`Invalid file type '${meta.type}'`)
}
file.title = meta.filename
file.id = null
file.classList.remove("template")
const url = new URL("/edit/", window.location.origin)
url.searchParams.set("f", meta.filename)
file.href = url.href
return file
}
/**
*
* @param {object[]} files
*/
function addFiles(files) {
const list = document.getElementById("files")
list.innerHTML = ""
const filenames = files.map(meta => meta.filename)
// Copy array because sort changes it in place
Array.from(filenames).sort().forEach(filename => {
const i = filenames.indexOf(filename)
const meta = files[i]
const file = makeFile(meta)
list.appendChild(file)
fileNodes.push([meta, file])
})
}
function sortFiles() {
const sortBy = document.getElementById("sort-by").value
const sortDesc = document.getElementById("sort-desc").checked
const filter = document.getElementById("filter").value
fileNodes.forEach(([meta, node]) => {
if (node.classList.contains(filter) || filter === "all") {
node.classList.remove("hidden")
} else {
node.classList.add("hidden")
}
})
let changed = false
do {
changed = false
for (let i = 0; i < fileNodes.length - 1; i++) {
/** @type {[object, HTMLElement]} */
const pair1 = fileNodes[i]
/** @type {[object, HTMLElement]} */
const pair2 = fileNodes[i + 1]
const [meta1, node1] = pair1
const [meta2, node2] = pair2
let swap = false
if (sortDesc) {
swap = !(meta1[sortBy] >= meta2[sortBy])
} else {
swap = !(meta2[sortBy] >= meta1[sortBy])
}
if (swap) {
fileNodes[i] = pair2
fileNodes[i + 1] = pair1
node2.parentElement.insertBefore(node2, node1)
changed = true
}
}
} while (changed)
}
window.addEventListener("load", () => {
fetch("/api/files").then(res => {
return res.json()
}).then(files => {
addFiles(files)
sortFiles()
})
document.getElementById("sort-by").addEventListener("change", () => sortFiles())
document.getElementById("sort-desc").addEventListener("click", () => sortFiles())
document.getElementById("filter").addEventListener("change", () => sortFiles())
})

View File

@ -0,0 +1,107 @@
let fileNodes = []
function makeFilm(meta) {
const file = document.getElementById("film-template").cloneNode(true)
file.querySelector(".title").innerText = meta.title
return file
}
function makeSeries(meta) {
const file = document.getElementById("series-template").cloneNode(true)
file.querySelector(".title").innerText = meta.title
file.querySelector(".episodes .num").innerText = meta.episodes
return file
}
function makeFile(meta) {
let file
switch (meta.type) {
case "film":
file = makeFilm(meta)
break
case "series":
file = makeSeries(meta)
break
default:
throw new Error(`Invalid file type '${meta.type}'`)
}
file.title = meta.filename
file.id = null
file.classList.remove("template")
const url = new URL("/metadata/edit/", window.location.origin)
url.searchParams.set("f", meta.filename)
file.href = url.href
return file
}
/**
*
* @param {object[]} files
*/
function addFiles(files) {
const list = document.getElementById("files")
list.innerHTML = ""
const filenames = files.map(meta => meta.filename)
// Copy array because sort changes it in place
Array.from(filenames).sort().forEach(filename => {
const i = filenames.indexOf(filename)
const meta = files[i]
const file = makeFile(meta)
list.appendChild(file)
fileNodes.push([meta, file])
})
}
function sortFiles() {
const sortBy = document.getElementById("sort-by").value
const sortDesc = document.getElementById("sort-desc").checked
const filter = document.getElementById("filter").value
fileNodes.forEach(([meta, node]) => {
if (node.classList.contains(filter) || filter === "all") {
node.classList.remove("hidden")
} else {
node.classList.add("hidden")
}
})
let changed = false
do {
changed = false
for (let i = 0; i < fileNodes.length - 1; i++) {
/** @type {[object, HTMLElement]} */
const pair1 = fileNodes[i]
/** @type {[object, HTMLElement]} */
const pair2 = fileNodes[i + 1]
const [meta1, node1] = pair1
const [meta2, node2] = pair2
let swap = false
if (sortDesc) {
swap = !(meta1[sortBy] >= meta2[sortBy])
} else {
swap = !(meta2[sortBy] >= meta1[sortBy])
}
if (swap) {
fileNodes[i] = pair2
fileNodes[i + 1] = pair1
node2.parentElement.insertBefore(node2, node1)
changed = true
}
}
} while (changed)
}
window.addEventListener("load", () => {
fetch("/api/files/metadata").then(res => {
return res.json()
}).then(files => {
addFiles(files)
sortFiles()
})
document.getElementById("sort-by").addEventListener("change", () => sortFiles())
document.getElementById("sort-desc").addEventListener("click", () => sortFiles())
document.getElementById("filter").addEventListener("change", () => sortFiles())
})

View File

@ -1,5 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations
import argparse import argparse
import json import json
import logging import logging
@ -7,9 +9,14 @@ import os
import socketserver import socketserver
from http import HTTPStatus from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler from http.server import SimpleHTTPRequestHandler
import time
from typing import Optional from typing import Optional
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from watchdog.events import DirModifiedEvent, FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.api import BaseObserver
# https://stackoverflow.com/a/10551190/11109181 # https://stackoverflow.com/a/10551190/11109181
class EnvDefault(argparse.Action): class EnvDefault(argparse.Action):
@ -32,17 +39,23 @@ class EnvDefault(argparse.Action):
setattr(namespace, self.dest, values) setattr(namespace, self.dest, values)
class MyHandler(SimpleHTTPRequestHandler): class HTTPHandler(SimpleHTTPRequestHandler):
MAX_PAYLOAD_SIZE = 1e6 SERVER: MeliesServer = None
DATA_DIR = "metadata" METADATA_CACHE = {}
CACHE = {} TO_CONVERT_CACHE = {}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.MAX_PAYLOAD_SIZE: int = self.SERVER.max_payload_size
self.TO_CONVERT_DIR: str = self.SERVER.to_convert_dir
self.CONVERTED_DIR: str = self.SERVER.converted_dir
self.METADATA_DIR: str = self.SERVER.metadata_dir
super().__init__( super().__init__(
*args, *args,
directory=os.path.join(os.path.dirname(__file__), "public"), directory=os.path.join(os.path.dirname(__file__), "public"),
**kwargs **kwargs
) )
self.query: dict = {} self.query: dict = {}
self.data: Optional[dict|list] = None self.data: Optional[dict|list] = None
@ -68,16 +81,20 @@ class MyHandler(SimpleHTTPRequestHandler):
return True return True
def do_GET(self): def do_GET(self):
self.path = unquote(self.path) parsed = urlparse(unquote(self.path))
self.query = parse_qs(urlparse(self.path).query) self.path = parsed.path
self.query = parse_qs(parsed.query)
if self.path.startswith("/api/"): if self.path.startswith("/api/"):
self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/")) self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
return return
super().do_GET() super().do_GET()
def do_POST(self): def do_POST(self):
self.path = unquote(self.path) parsed = urlparse(unquote(self.path))
self.query = parse_qs(urlparse(self.path).query) self.path = parsed.path
self.query = parse_qs(parsed.query)
if self.path.startswith("/api/"): if self.path.startswith("/api/"):
self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/")) self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
return return
@ -85,9 +102,14 @@ class MyHandler(SimpleHTTPRequestHandler):
def handle_api_get(self, path: str): def handle_api_get(self, path: str):
self.log_message(f"API request at {path}") self.log_message(f"API request at {path}")
if path == "files": if path == "files/to_convert":
files: list[str] = self.get_files_meta() files: list[str] = self.get_to_convert_files_meta(self.query.get("f", [""])[0])
self.send_json(files) self.send_json(files)
elif path == "files/metadata":
files: list[str] = self.get_metadata_files_meta()
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)
@ -116,49 +138,78 @@ class MyHandler(SimpleHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8")) self.wfile.write(json.dumps(data).encode("utf-8"))
def get_files(self): def get_to_convert_files(self, base_path: str):
return os.listdir(self.DATA_DIR) root_path: str = os.path.abspath(self.TO_CONVERT_DIR)
full_path: str = os.path.join(root_path, base_path)
full_path = os.path.abspath(full_path)
common_prefix: str = os.path.commonprefix([full_path, root_path])
if common_prefix != root_path:
return []
return os.listdir(full_path)
def get_metadata_files(self):
return os.listdir(self.METADATA_DIR)
def read_file(self, filename: str) -> Optional[dict|list]: def read_file(self, filename: str) -> Optional[dict|list]:
if filename not in self.get_files(): if filename not in self.get_metadata_files():
return None return None
with open(os.path.join(self.DATA_DIR, filename), "r") as f: with open(os.path.join(self.METADATA_DIR, filename), "r") as f:
data = json.load(f) data = json.load(f)
return data return data
def write_file(self, filename: str, data: dict|list) -> bool: def write_file(self, filename: str, data: dict|list) -> bool:
if filename not in self.get_files(): if filename not in self.get_metadata_files():
self.send_error(HTTPStatus.NOT_FOUND) self.send_error(HTTPStatus.NOT_FOUND)
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.METADATA_DIR, filename), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False) 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): def get_to_convert_files_meta(self, base_path: str):
files: list[str] = self.get_files() files: list[str] = self.get_to_convert_files(base_path)
files = [os.path.join(self.TO_CONVERT_DIR, base_path, f) for f in files]
files_meta: list[dict] = [] files_meta: list[dict] = []
deleted = set(self.CACHE.keys()) - set(files) deleted = set(self.TO_CONVERT_CACHE.keys()) - set(files)
for path in deleted:
del self.TO_CONVERT_CACHE[path]
for path in files:
last_modified: float = os.path.getmtime(path)
if path not in self.TO_CONVERT_CACHE or self.TO_CONVERT_CACHE[path]["ts"] < last_modified:
self.update_to_convert_file_meta(path)
files_meta.append(self.TO_CONVERT_CACHE[path])
return files_meta
def get_metadata_files_meta(self):
files: list[str] = self.get_metadata_files()
files_meta: list[dict] = []
deleted = set(self.METADATA_CACHE.keys()) - set(files)
for filename in deleted: for filename in deleted:
del self.CACHE[deleted] del self.METADATA_CACHE[filename]
for filename in files: for filename in files:
path: str = os.path.join(self.DATA_DIR, filename) path: str = os.path.join(self.METADATA_DIR, filename)
last_modified: float = os.path.getmtime(path) last_modified: float = os.path.getmtime(path)
if filename not in self.CACHE or self.CACHE[filename]["ts"] < last_modified: if filename not in self.METADATA_CACHE or self.METADATA_CACHE[filename]["ts"] < last_modified:
self.update_file_meta(filename) self.update_metadata_file_meta(filename)
files_meta.append(self.CACHE[filename]) files_meta.append(self.METADATA_CACHE[filename])
return files_meta return files_meta
def update_file_meta(self, filename: str): def update_metadata_file_meta(self, filename: str):
path: str = os.path.join(self.DATA_DIR, filename) path: str = os.path.join(self.METADATA_DIR, filename)
meta = { meta = {
"filename": filename, "filename": filename,
@ -175,7 +226,85 @@ class MyHandler(SimpleHTTPRequestHandler):
else: else:
meta["title"] = data["title"] meta["title"] = data["title"]
self.CACHE[filename] = meta self.METADATA_CACHE[filename] = meta
def update_to_convert_file_meta(self, path: str):
filename: str = os.path.basename(path)
is_dir: bool = os.path.isdir(path)
meta = {
"path": os.path.relpath(path, self.TO_CONVERT_DIR),
"filename": filename,
"ts": os.path.getmtime(path),
"size": os.path.getsize(path),
"type": "folder" if is_dir else "media"
}
if is_dir:
meta["elements"] = len(os.listdir(path))
if not meta["path"].endswith("/"):
meta["path"] += "/"
self.TO_CONVERT_CACHE[path] = meta
class MeliesServer(FileSystemEventHandler):
def __init__(
self,
port: int,
to_convert_dir: str,
converted_dir: str,
metadata_dir: str,
max_payload_size: int):
super().__init__()
self.port: int = port
self.to_convert_dir: str = to_convert_dir
self.converted_dir: str = converted_dir
self.metadata_dir: str = metadata_dir
self.max_payload_size: int = max_payload_size
HTTPHandler.SERVER = self
if not os.path.exists(self.to_convert_dir):
os.mkdir(self.to_convert_dir)
if not os.path.exists(self.converted_dir):
os.mkdir(self.converted_dir)
if not os.path.exists(self.metadata_dir):
os.mkdir(self.metadata_dir)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt=r"%Y-%m-%d %H:%M:%S"
)
self.httpd: Optional[socketserver.TCPServer] = None
self.observer: BaseObserver = Observer()
self.observer.schedule(self, self.converted_dir, event_filter=[DirModifiedEvent])
self.last_event: float = time.time()
def start(self):
self.observer.start()
try:
with socketserver.TCPServer(("", self.port), HTTPHandler) as self.httpd:
logging.info(f"Serving on port {self.port}")
self.httpd.serve_forever()
except KeyboardInterrupt:
pass
self.stop()
def stop(self):
self.observer.stop()
self.observer.join()
def on_modified(self, event: DirModifiedEvent):
t: float = time.time()
logging.info(event)
if t - self.last_event > 1:
self.last_event = t
def main(): def main():
@ -199,6 +328,20 @@ def main():
type=int, type=int,
help="Maximum POST payload size in bytes that the server accepts" help="Maximum POST payload size in bytes that the server accepts"
) )
parser.add_argument(
"--to-convert-dir",
action=EnvDefault,
envvar="MELIES_TO_CONVERT_DIR",
default="to_convert",
help="Path to the directory containing medias to convert"
)
parser.add_argument(
"--converted-dir",
action=EnvDefault,
envvar="MELIES_CONVERTED_DIR",
default="converted",
help="Path to the directory containing converted medias"
)
parser.add_argument( parser.add_argument(
"--metadata-dir", "--metadata-dir",
action=EnvDefault, action=EnvDefault,
@ -208,26 +351,14 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
server = MeliesServer(
port = args.port args.port,
MyHandler.MAX_PAYLOAD_SIZE = args.max_payload_size args.to_convert_dir,
MyHandler.DATA_DIR = args.metadata_dir args.converted_dir,
args.metadata_dir,
if not os.path.exists(args.metadata_dir): args.max_payload_size
os.mkdir(args.metadata_dir)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt=r"%Y-%m-%d %H:%M:%S"
) )
server.start()
try:
with socketserver.TCPServer(("", port), MyHandler) as httpd:
logging.info(f"Serving on port {port}")
httpd.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__": if __name__ == "__main__":