7 Commits

40 changed files with 2011 additions and 223 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
__pycache__/
*.pyc
.git
.env
/to_convert/
/converted/
/metadata/

3
.gitignore vendored Normal file
View File

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

29
Dockerfile Normal file
View File

@ -0,0 +1,29 @@
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
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "src/server.py"]

1
editor/.gitignore vendored
View File

@ -1 +0,0 @@
metadata/

View File

@ -1,215 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import os
import socketserver
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
from typing import Optional
from urllib.parse import 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)
class MyHandler(SimpleHTTPRequestHandler):
MAX_PAYLOAD_SIZE = 1e6
DATA_DIR = "metadata"
CACHE = {}
def __init__(self, *args, **kwargs):
super().__init__(
*args,
directory="public",
**kwargs
)
self.query: dict = {}
self.data: Optional[dict|list] = None
def read_body_data(self):
try:
size: int = int(self.headers["Content-Length"])
if size > self.MAX_PAYLOAD_SIZE:
self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
self.log_error(f"Payload is too big ({self.MAX_PAYLOAD_SIZE=}B)")
return False
raw_data = self.rfile.read(size)
self.data = json.loads(raw_data)
except:
self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body")
self.log_error("Malformed JSON body")
return False
return True
def do_GET(self):
self.path = unquote(self.path)
self.query = parse_qs(urlparse(self.path).query)
if self.path.startswith("/api/"):
self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
return
super().do_GET()
def do_POST(self):
self.path = unquote(self.path)
self.query = parse_qs(urlparse(self.path).query)
if self.path.startswith("/api/"):
self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
return
self.send_error(HTTPStatus.NOT_FOUND)
def handle_api_get(self, path: str):
self.log_message(f"API request at {path}")
if path == "files":
files: list[str] = self.get_files_meta()
self.send_json(files)
elif path.startswith("file"):
filename: str = path.split("/", 1)[1]
data = self.read_file(filename)
if data is None:
self.send_error(HTTPStatus.NOT_FOUND)
else:
self.send_json(data)
else:
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
self.end_headers()
def handle_api_post(self, path: str):
if path.startswith("file"):
if self.read_body_data():
filename: str = path.split("/", 1)[1]
if self.write_file(filename, self.data):
self.send_response(HTTPStatus.OK)
self.end_headers()
else:
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
self.end_headers()
def send_json(self, data: dict|list):
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8"))
def get_files(self):
return os.listdir(self.DATA_DIR)
def read_file(self, filename: str) -> Optional[dict|list]:
if filename not in self.get_files():
return None
with open(os.path.join(self.DATA_DIR, filename), "r") as f:
data = json.load(f)
return data
def write_file(self, filename: str, data: dict|list) -> bool:
if filename not in self.get_files():
self.send_error(HTTPStatus.NOT_FOUND)
return False
try:
with open(os.path.join(self.DATA_DIR, filename), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except:
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
return False
return True
def get_files_meta(self):
files: list[str] = self.get_files()
files_meta: list[dict] = []
deleted = set(self.CACHE.keys()) - set(files)
for filename in deleted:
del self.CACHE[deleted]
for filename in files:
path: str = os.path.join(self.DATA_DIR, filename)
last_modified: float = os.path.getmtime(path)
if filename not in self.CACHE or self.CACHE[filename]["ts"] < last_modified:
self.update_file_meta(filename)
files_meta.append(self.CACHE[filename])
return files_meta
def update_file_meta(self, filename: str):
path: str = os.path.join(self.DATA_DIR, filename)
meta = {
"filename": filename,
"ts": os.path.getmtime(path)
}
with open(path, "r") as f:
data = json.load(f)
is_series = "filename" not in data
meta["type"] = "series" if is_series else "film"
if is_series:
meta["episodes"] = len(data)
meta["title"] = filename.split("_metadata")[0]
else:
meta["title"] = data["title"]
self.CACHE[filename] = meta
def main():
parser = argparse.ArgumentParser(
description="Starts the Melies server",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument(
"-p", "--port",
action=EnvDefault,
envvar="MELIES_PORT",
default=8000,
type=int,
help="Port on which the server listens"
)
parser.add_argument(
"--max-payload-size",
action=EnvDefault,
envvar="MELIES_MAX_PAYLOAD_SIZE",
default=1e6,
type=int,
help="Maximum POST payload size in bytes that the server accepts"
)
parser.add_argument(
"--metadata-dir",
action=EnvDefault,
envvar="MELIES_METADATA_DIR",
default="metadata",
help="Path to the directory containing metadata files"
)
args = parser.parse_args()
port = args.port
MyHandler.MAX_PAYLOAD_SIZE = args.max_payload_size
MyHandler.DATA_DIR = args.metadata_dir
with socketserver.TCPServer(("", port), MyHandler) as httpd:
print(f"Serving on port {port}")
httpd.serve_forever()
if __name__ == "__main__":
main()

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>

30
src/public/index.html Normal file
View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Melies</title>
<link rel="stylesheet" href="/static/css/base.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>
</head>
<body>
<header>
<img src="/static/images/icon3.svg">
<h1>Melies</h1>
</header>
<main>
<nav id="pages">
<a class="page" href="/conversion/">
<img src="/static/images/conversion.svg">
<div class="name">Conversion</div>
</a>
<a class="page" href="/metadata/">
<img src="/static/images/metadata.svg">
<div class="name">Metadata</div>
</a>
</nav>
</main>
</body>
</html>

View File

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

View File

@ -3,10 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metadata Editor</title>
<title>Melies - Metadata Editor</title>
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/index.css">
<script src="/static/js/index.js"></script>
<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>

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

@ -0,0 +1,50 @@
header {
align-items: center;
img {
width: 4em;
height: 4em;
object-fit: contain;
}
}
#pages {
display: grid;
max-width: calc(max(50%, 20em));
margin: 0 auto;
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
grid-auto-rows: 15em;
gap: 0.8em;
place-items: center;
padding: 0.8em 0;
.page {
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;
&:hover {
background-color: #f8f8f8;
}
img {
width: 10em;
height: 10em;
}
.name {
overflow-wrap: anywhere;
text-align: center;
font-weight: bold;
font-size: 150%;
}
}
}

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,128 @@
<?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="collection.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.6568543"
inkscape:cx="69.384853"
inkscape:cy="46.050329"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="g8-2">
<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-effect10"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,1,1,0,5,0,1 @ F,0,1,1,0,5,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-effect9"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,0,0,1 @ F,0,0,1,0,8.0000004,0,1 @ F,0,1,1,0,8.0000004,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-effect8"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,5,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,8,0,1 @ F,0,1,1,0,5,0,1 @ F,0,1,1,0,5,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="g8-2"
transform="rotate(7.9490233,62.107327,266.90842)">
<g
id="g10"
transform="translate(-12.771862,-9.814157)">
<path
style="fill:none;stroke:#000000;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 83.459997,59.049888 82.353661,51.126757 C 81.742649,46.750932 77.700018,43.698948 73.324193,44.30996 L 38.66049,49.15018 C 35.925599,49.532063 33.398955,47.624573 33.017073,44.889682 32.39989,42.466468 29.93516,41.002392 27.511947,41.619576 L 8.6945086,44.247124 C 4.3186836,44.858137 1.2667,48.900767 1.8777122,53.276592 L 9.760356,109.72891 c 0.611016,4.37582 4.653645,7.4278 9.029468,6.81679 l 63.385056,-8.85068 c 3.853961,-0.53814 6.681016,-3.7382 6.883076,-7.48335"
id="path8"
sodipodi:nodetypes="ccccccsccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 9.6220641,108.73852 11.643471,76.990746 a 9.8076021,9.8076021 132.84706 0 1 8.431469,-9.09017 l 63.385056,-8.850688 a 6.5830057,6.5830057 42.600843 0 1 7.483432,6.881572 l -1.886976,34.28013"
id="path9"
sodipodi:nodetypes="cccc" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 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

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 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

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 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

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 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

View File

@ -29,7 +29,7 @@ function makeFile(meta) {
file.title = meta.filename
file.id = null
file.classList.remove("template")
const url = new URL("/edit/", window.location.origin)
const url = new URL("/metadata/edit/", window.location.origin)
url.searchParams.set("f", meta.filename)
file.href = url.href
return file
@ -94,7 +94,7 @@ function sortFiles() {
}
window.addEventListener("load", () => {
fetch("/api/files").then(res => {
fetch("/api/files/metadata").then(res => {
return res.json()
}).then(files => {
addFiles(files)

365
src/server.py Executable file
View File

@ -0,0 +1,365 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import logging
import os
import socketserver
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
import time
from typing import Optional
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
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)
class HTTPHandler(SimpleHTTPRequestHandler):
SERVER: MeliesServer = None
METADATA_CACHE = {}
TO_CONVERT_CACHE = {}
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__(
*args,
directory=os.path.join(os.path.dirname(__file__), "public"),
**kwargs
)
self.query: dict = {}
self.data: Optional[dict|list] = None
def log_message(self, format, *args):
logging.info("%s - %s" % (
self.client_address[0],
format % args
))
def read_body_data(self):
try:
size: int = int(self.headers["Content-Length"])
if size > self.MAX_PAYLOAD_SIZE:
self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
self.log_error(f"Payload is too big ({self.MAX_PAYLOAD_SIZE=}B)")
return False
raw_data = self.rfile.read(size)
self.data = json.loads(raw_data)
except:
self.send_error(HTTPStatus.NOT_ACCEPTABLE, "Malformed JSON body")
self.log_error("Malformed JSON body")
return False
return True
def do_GET(self):
parsed = urlparse(unquote(self.path))
self.path = parsed.path
self.query = parse_qs(parsed.query)
if self.path.startswith("/api/"):
self.handle_api_get(self.path.removeprefix("/api/").removesuffix("/"))
return
super().do_GET()
def do_POST(self):
parsed = urlparse(unquote(self.path))
self.path = parsed.path
self.query = parse_qs(parsed.query)
if self.path.startswith("/api/"):
self.handle_api_post(self.path.removeprefix("/api/").removesuffix("/"))
return
self.send_error(HTTPStatus.NOT_FOUND)
def handle_api_get(self, path: str):
self.log_message(f"API request at {path}")
if path == "files/to_convert":
files: list[str] = self.get_to_convert_files_meta(self.query.get("f", [""])[0])
self.send_json(files)
elif path == "files/metadata":
files: list[str] = self.get_metadata_files_meta()
self.send_json(files)
elif path.startswith("file"):
filename: str = path.split("/", 1)[1]
data = self.read_file(filename)
if data is None:
self.send_error(HTTPStatus.NOT_FOUND)
else:
self.send_json(data)
else:
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
self.end_headers()
def handle_api_post(self, path: str):
if path.startswith("file"):
if self.read_body_data():
filename: str = path.split("/", 1)[1]
if self.write_file(filename, self.data):
self.send_response(HTTPStatus.OK)
self.end_headers()
else:
self.send_response(HTTPStatus.NOT_FOUND, f"Unknown path {path}")
self.end_headers()
def send_json(self, data: dict|list):
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8"))
def get_to_convert_files(self, base_path: str):
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]:
if filename not in self.get_metadata_files():
return None
with open(os.path.join(self.METADATA_DIR, filename), "r") as f:
data = json.load(f)
return data
def write_file(self, filename: str, data: dict|list) -> bool:
if filename not in self.get_metadata_files():
self.send_error(HTTPStatus.NOT_FOUND)
return False
try:
with open(os.path.join(self.METADATA_DIR, filename), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except:
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
return False
return True
def get_to_convert_files_meta(self, base_path: str):
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] = []
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:
del self.METADATA_CACHE[filename]
for filename in files:
path: str = os.path.join(self.METADATA_DIR, filename)
last_modified: float = os.path.getmtime(path)
if filename not in self.METADATA_CACHE or self.METADATA_CACHE[filename]["ts"] < last_modified:
self.update_metadata_file_meta(filename)
files_meta.append(self.METADATA_CACHE[filename])
return files_meta
def update_metadata_file_meta(self, filename: str):
path: str = os.path.join(self.METADATA_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.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():
parser = argparse.ArgumentParser(
description="Starts the Melies server",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument(
"-p", "--port",
action=EnvDefault,
envvar="MELIES_PORT",
default=8000,
type=int,
help="Port on which the server listens"
)
parser.add_argument(
"--max-payload-size",
action=EnvDefault,
envvar="MELIES_MAX_PAYLOAD_SIZE",
default=1e6,
type=int,
help="Maximum POST payload size in bytes that the server accepts"
)
parser.add_argument(
"--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(
"--metadata-dir",
action=EnvDefault,
envvar="MELIES_METADATA_DIR",
default="metadata",
help="Path to the directory containing metadata files"
)
args = parser.parse_args()
server = MeliesServer(
args.port,
args.to_convert_dir,
args.converted_dir,
args.metadata_dir,
args.max_payload_size
)
server.start()
if __name__ == "__main__":
main()