Compare commits

25 Commits

Author SHA1 Message Date
04dd2309c6 fix: remove subtitle type from name unless specified 2025-05-12 15:50:22 +02:00
958a55ff17 feat: unify header bars and navigation 2025-05-12 15:46:58 +02:00
7061f8d64c chore: tidy up metadata writer 2025-05-12 14:55:12 +02:00
4b6576ec53 refactor: add mkvpropedit as alternative for in-place modification 2025-05-12 10:43:23 +02:00
7a49849ee9 refactor: adapt metadata writer 2025-05-05 21:53:24 +02:00
ffe847fb5e refactor: extract launch script + adapt metadata extractor 2025-05-05 00:53:31 +02:00
775d3da6ed refactor: extract file handlers + move EnvDefault 2025-05-04 23:29:24 +02:00
460ae94925 feat: add conversion page 2025-05-04 18:38:15 +02:00
4a4949b474 feat: add basic watchdog 2025-05-04 12:13:45 +02:00
371fb9042d refactor: move metadata editor to sub-directory 2025-05-04 12:13:04 +02:00
be91526c90 feat: add ffprobe and mkvmerge to docker image + requirements 2025-05-04 00:40:26 +02:00
7a3fa8c309 fix: improve logging for docker 2025-05-03 18:35:07 +02:00
6be8ece8d4 refactor: changed structure for containerization 2025-05-03 17:24:05 +02:00
770d3cd25b Merge pull request 'feat: Web metadata editor' (#3) from feat/web-editor into main
Reviewed-on: #3
2025-05-03 14:19:44 +00:00
658addae56 feat: add CLI arguments + env variables support 2025-05-03 16:07:24 +02:00
79cd7a32ed feat: add sorting and filtering in file list 2025-05-03 16:07:23 +02:00
02279b8c6f feat: rework home page file selector 2025-05-03 16:07:23 +02:00
ed3c6d7cc7 feat: add support for series 2025-05-03 16:07:23 +02:00
bc5371de71 feat: add toolbar + notifications 2025-05-03 16:07:22 +02:00
2afecd1c04 feat: add improve name button 2025-05-03 16:07:22 +02:00
8fbe5ae3c4 fix: add gitignore 2025-05-03 16:07:21 +02:00
acf7b5047f feat: add basic integrity checks + corrections 2025-05-03 16:07:21 +02:00
82d02cfe76 refactor: split editor in JS modules 2025-05-03 16:07:21 +02:00
d19ab90f38 feat: add saving 2025-05-03 16:07:20 +02:00
477e8951a9 feat: add hotone bool 2025-05-03 16:07:20 +02:00
55 changed files with 5239 additions and 830 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/

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM debian:bookworm-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/ \
&& cp $(which mkvpropedit) /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 \
&& ldd $(which mkvpropedit) | 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-bookworm
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", "-m", "scripts.server"]

0
__init__.py Normal file
View File

View File

@ -1,9 +0,0 @@
* {
/*padding: 0;*/
/*margin: 0;*/
box-sizing: border-box;
}
body {
font-family: Ubuntu;
}

View File

@ -1,13 +0,0 @@
#filename {
font-size: 80%;
font-style: italic;
}
#unsaved {
color: #9d4916;
font-size: 80%;
&:not(.show) {
display: none;
}
}

View File

@ -1,48 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit</title>
<link rel="stylesheet" href="/base.css">
<link rel="stylesheet" href="index.css">
<script src="index.js"></script>
</head>
<body>
<header id="topbar">
<nav>
</nav>
</header>
<aside id="toolbar">
</aside>
<main id="main">
<h1>Edit <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1>
<div class="fields">
<div class="field">
<label for="title">Title</label>
<input type="text" id="title" name="title">
</div>
</div>
<div class="audio">
<h3>Audio Tracks</h3>
<table id="audio-tracks">
<thead>
<tr></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="table">
<h3>Subtitle Tracks</h3>
<table id="subtitle-tracks">
<thead>
<tr></tr>
</thead>
<tbody></tbody>
</table>
</div>
</main>
</body>
</html>

View File

@ -1,157 +0,0 @@
/** @type TracksTable */
let audioTable
/** @type TracksTable */
let subtitleTable
function flattenObj(obj) {
const res = {}
Object.entries(obj).forEach(([key, value]) => {
if (typeof value === "object") {
value = flattenObj(value)
Object.entries(value).forEach(([key2, value2]) => {
res[key + "/" + key2] = value2
})
} else {
res[key] = value
}
})
return res
}
class TracksTable {
OPTIONS = {
"language": ["fre", "eng"]
}
constructor(table) {
this.table = table
this.headers = this.table.querySelector("thead tr")
this.body = this.table.querySelector("tbody")
this.fields = []
this.tracks = []
}
showTracks(tracks) {
this.tracks = tracks.map(flattenObj)
this.clear()
if (tracks.length === 0) {
return
}
this.detectFields()
this.addHeaders()
this.tracks.forEach((track, i) => {
const tr = document.createElement("tr")
tr.dataset.i = i
this.fields.forEach(field => {
const td = tr.insertCell(-1)
const input = this.makeInput(field, track[field.key])
td.appendChild(input)
})
this.body.appendChild(tr)
})
}
clear() {
this.headers.innerHTML = ""
this.body.innerHTML = ""
this.fields = []
}
detectFields() {
Object.entries(this.tracks[0]).forEach(([key, value]) => {
let type = {
boolean: "bool",
number: "num"
}[typeof value] ?? "str"
if (key === "language") {
type = "sel"
}
const name = key.split("/").slice(-1)[0]
this.fields.push({name, type, key})
})
}
addHeaders() {
this.fields.forEach(field => {
const th = document.createElement("th")
th.innerText = field.name
this.headers.appendChild(th)
})
}
makeInput(field, value) {
let input = document.createElement("input")
switch (field.type) {
case "num":
input.type = "number"
input.value = value
break
case "str":
input.type = "text"
input.value = value
break
case "bool":
input.type = "checkbox"
input.checked = value
break
case "sel":
input = document.createElement("select")
const options = this.OPTIONS[field.name]
options.forEach(option => {
const opt = document.createElement("option")
opt.innerText = option
opt.value = option
input.appendChild(opt)
})
input.value = value
default:
break
}
return input
}
}
function fetchData(filename) {
fetch("/api/file", {
method: "POST",
body: JSON.stringify({
file: filename
}),
headers: {
"Content-Type": "application/json"
}
}).then(res => {
if (res.ok) {
return res.json()
}
return null
}).then(res => {
if (res !== null) {
displayData(res)
}
})
}
function displayData(data) {
document.getElementById("title").value = data.title
audioTable.showTracks(data.audio_tracks)
subtitleTable.showTracks(data.subtitle_tracks)
}
window.addEventListener("load", () => {
audioTable = new TracksTable(document.getElementById("audio-tracks"))
subtitleTable = new TracksTable(document.getElementById("subtitle-tracks"))
const params = new URLSearchParams(window.location.search)
const file = params.get("f")
document.getElementById("filename").innerText = file
fetchData(file)
})

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metadata Editor</title>
<script src="index.js"></script>
</head>
<body>
<h1>Metadata Editor</h1>
<select name="file-sel" id="file-sel" oninput="selectFile(event)"></select>
</body>
</html>

View File

@ -1,31 +0,0 @@
function addOptions(files) {
const select = document.getElementById("file-sel")
select.innerHTML = ""
const defaultOpt = document.createElement("option")
defaultOpt.innerText = "----- Select a file -----"
defaultOpt.value = ""
select.appendChild(defaultOpt)
files.forEach(file => {
const option = document.createElement("option")
option.innerText = file
option.value = file
select.appendChild(option)
})
}
function selectFile(event) {
const file = event.target.value
if (file !== "") {
const url = new URL("/edit/", window.location.origin)
url.searchParams.set("f", file)
window.location.href = url.href
}
}
window.addEventListener("load", () => {
fetch("/api/files").then(res => {
return res.json()
}).then(files => {
addOptions(files)
})
})

View File

@ -1,90 +0,0 @@
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
import json
import os
import socketserver
from typing import Optional
from urllib.parse import urlparse, parse_qs
PORT = 8000
class MyHandler(SimpleHTTPRequestHandler):
DATA_DIR = "metadata"
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):
self.log_message("Reading body data")
try:
raw_data = self.rfile.read(int(self.headers["Content-Length"]))
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.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.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):
print(f"API request at {path}")
if path == "files":
files: list[str] = self.get_files()
self.send_json(files)
return
def handle_api_post(self, path: str):
if path == "file":
if self.read_body_data():
data = self.get_file(self.data["file"])
if data is None:
self.send_error(HTTPStatus.NOT_FOUND)
self.log_message("File not found")
else:
self.log_message("Got file")
self.send_json(data)
def send_json(self, data: dict|list):
self.send_response(200)
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 get_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 main():
with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
print(f"Serving on port {PORT}")
httpd.serve_forever()
if __name__ == "__main__":
main()

View File

@ -1,196 +0,0 @@
#!/usr/bin/env python3
import argparse
import os
import subprocess
import json
import sys
SUPPORTED_EXTENSIONS = (".mp4", ".mkv", ".mov", ".avi")
def get_video_metadata(file_path):
"""
Extract metadata from a video file using ffprobe.
Args:
file_path (str): Path to the video file
Returns:
dict: Metadata information
"""
# Get general file info
cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", file_path
]
try:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
print(f"❌ Error processing {file_path}: {result.stderr}")
return None
data = json.loads(result.stdout)
# Extract filename and title
filename = os.path.basename(file_path)
title = data.get("format", {}).get("tags", {}).get("title", filename)
# Initialize metadata structure
metadata = {
"filename": filename,
"title": title,
"audio_tracks": [],
"subtitle_tracks": []
}
# Process streams
for stream in data.get("streams", []):
codec_type = stream.get("codec_type")
if codec_type == "audio":
track = {
"index": stream.get("index"),
"language": stream.get("tags", {}).get("language", "und"),
"name": stream.get("tags", {}).get("title", ""),
"channels": stream.get("channels", 0),
"flags": {
"default": stream.get("disposition", {}).get("default", 0) == 1,
"visual_impaired": stream.get("disposition", {}).get("visual_impaired", 0) == 1,
"original": stream.get("disposition", {}).get("original", 0) == 1,
"commentary": stream.get("disposition", {}).get("comment", 0) == 1
}
}
metadata["audio_tracks"].append(track)
elif codec_type == "subtitle":
track = {
"index": stream.get("index"),
"language": stream.get("tags", {}).get("language", "und"),
"name": stream.get("tags", {}).get("title", ""),
"flags": {
"default": stream.get("disposition", {}).get("default", 0) == 1,
"forced": stream.get("disposition", {}).get("forced", 0) == 1,
"hearing_impaired": stream.get("disposition", {}).get("hearing_impaired", 0) == 1,
"original": stream.get("disposition", {}).get("original", 0) == 1,
"commentary": stream.get("disposition", {}).get("comment", 0) == 1
}
}
metadata["subtitle_tracks"].append(track)
return metadata
except Exception as e:
print(f"❌ Error processing {file_path}: {str(e)}")
return None
def process_file(file_path, output_dir=None):
"""
Process a single video file and write metadata to JSON.
Args:
file_path (str): Path to the video file
output_dir (str, optional): Directory where the output JSON file will be saved
"""
if not os.path.isfile(file_path):
print(f"❌ File not found: {file_path}")
return False
if not file_path.lower().endswith(SUPPORTED_EXTENSIONS):
print(f"❌ Unsupported file format: {file_path}")
return False
print(f"📊 Extracting metadata from {os.path.basename(file_path)}")
metadata = get_video_metadata(file_path)
if metadata:
# Generate output filename based on input file
filename = os.path.basename(os.path.splitext(file_path)[0]) + "_metadata.json"
if output_dir:
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, filename)
else:
# If no output directory specified, save in the same directory as the input file
base_name = os.path.splitext(file_path)[0]
output_path = f"{base_name}_metadata.json"
# Write metadata to JSON file
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
print(f"✅ Metadata saved to {output_path}")
return True
return False
def process_directory(directory_path, output_dir=None):
"""
Process all video files in a directory and write metadata to JSON.
Args:
directory_path (str): Path to the directory
output_dir (str, optional): Directory where the output JSON file will be saved
"""
if not os.path.isdir(directory_path):
print(f"❌ Directory not found: {directory_path}")
return False
all_metadata = {}
file_count = 0
for root, _, files in os.walk(directory_path):
for file in files:
if file.lower().endswith(SUPPORTED_EXTENSIONS):
file_path = os.path.join(root, file)
print(f"📊 Extracting metadata from {file}")
metadata = get_video_metadata(file_path)
if metadata:
# Use relative path as key
rel_path = os.path.relpath(file_path, directory_path)
all_metadata[rel_path] = metadata
file_count += 1
if file_count == 0:
print(f"❌ No supported video files found in {directory_path}")
return False
# Generate output filename based on directory name
dir_name = os.path.basename(os.path.normpath(directory_path))
filename = f"{dir_name}_metadata.json"
if output_dir:
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, filename)
else:
# If no output directory specified, save in the current directory
output_path = filename
# Write all metadata to a single JSON file
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_metadata, f, indent=2, ensure_ascii=False)
print(f"✅ Metadata for {file_count} files saved to {output_path}")
return True
def main():
parser = argparse.ArgumentParser(description="Extract metadata from video files and save as JSON.")
parser.add_argument("input", help="Path to input video file or directory")
parser.add_argument("-o", "--output", help="Directory path where output JSON files will be saved")
args = parser.parse_args()
input_path = args.input
output_dir = args.output
if os.path.isfile(input_path):
process_file(input_path, output_dir)
elif os.path.isdir(input_path):
process_directory(input_path, output_dir)
else:
print(f"❌ Path not found: {input_path}")
sys.exit(1)
if __name__ == "__main__":
main()

1
requirements.txt Normal file
View File

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

0
scripts/__init__.py Normal file
View File

47
scripts/extract_metadata.py Executable file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env python3
import argparse
import logging
import os
import sys
from src.metadata_extractor import MetadataExtractor
def main():
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(message)s"
)
parser = argparse.ArgumentParser(
description="Extract metadata from video files and save as JSON"
)
parser.add_argument(
"input",
help="Path to input video file or directory"
)
parser.add_argument(
"-o", "--output",
help="Directory path where the output JSON files will be saved"
)
args = parser.parse_args()
input_path = args.input
output_dir = args.output
extractor: MetadataExtractor = MetadataExtractor()
success = False
if os.path.isfile(input_path):
success = extractor.process_file(input_path, output_dir)
elif os.path.isdir(input_path):
success = extractor.process_directory(input_path, output_dir)
else:
logging.error(f"Path not found: {input_path}")
if not success:
sys.exit(1)
if __name__ == "__main__":
main()

71
scripts/server.py Executable file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
import argparse
import logging
from src.env_default import EnvDefault
from src.server import MeliesServer
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt=r"%Y-%m-%d %H:%M:%S"
)
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()

45
scripts/write_metadata.py Normal file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
import argparse
import logging
import sys
from src.metadata_writer import MetadataWriter
def main():
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(message)s"
)
parser = argparse.ArgumentParser(
description="Write metadata from JSON to video files"
)
parser.add_argument(
"json_file",
help="Path to input JSON metadata file"
)
parser.add_argument(
"-o", "--output",
help="Path of the output directory"
)
parser.add_argument(
"-s", "--source",
help="Source directory (overrides automatic detection)"
)
args = parser.parse_args()
json_file = args.json_file
output_dir = args.output
source_dir = args.source
writer: MetadataWriter = MetadataWriter()
success: bool = writer.process_metadata(json_file, source_dir, output_dir)
if not success:
sys.exit(1)
if __name__ == "__main__":
main()

0
src/__init__.py Normal file
View File

25
src/env_default.py Normal file
View File

@ -0,0 +1,25 @@
from __future__ import annotations
import argparse
import os
# 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)

106
src/file_handlers.py Normal file
View File

@ -0,0 +1,106 @@
import json
import os
from typing import Optional
class FileHandler:
def __init__(self, directory: str):
self.cache: dict[str, dict] = {}
self.directory: str = directory
def get_files(self, base_path: str = ""):
root_path: str = os.path.abspath(self.directory)
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.path.join(base_path, f)
for f in os.listdir(full_path)
]
def get_files_meta(self, base_path: str = ""):
files: list[str] = self.get_files(base_path)
files = [
os.path.join(self.directory, f)
for f in files
]
files_meta: list[dict] = []
deleted = set(self.cache.keys()) - set(files)
for path in deleted:
del self.cache[path]
for path in files:
last_modified: float = os.path.getmtime(path)
if path not in self.cache or self.cache[path]["ts"] < last_modified:
self.update_meta(path)
files_meta.append(self.cache[path])
return files_meta
def update_meta(self, path: str) -> None:
self.cache[path] = self.get_meta(path)
def get_meta(self, path: str) -> dict:
return {
"path": os.path.relpath(path, self.directory),
"filename": os.path.basename(path),
"ts": os.path.getmtime(path)
}
class JsonFileHandler(FileHandler):
def read(self, path: str) -> Optional[dict|list]:
if path not in self.get_files():
return None
with open(os.path.join(self.directory, path), "r") as f:
data = json.load(f)
return data
def write(self, path: str, data: dict|list) -> bool:
if path not in self.get_files():
return False
try:
with open(os.path.join(self.directory, path), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except:
return False
return True
class MetadataFileHandler(JsonFileHandler):
def get_meta(self, path: str) -> dict:
meta: dict = super().get_meta(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"] = meta["filename"].split("_metadata")[0]
else:
meta["title"] = data["title"]
return meta
class ToConvertFileHandler(FileHandler):
def get_meta(self, path: str) -> dict:
meta: dict = super().get_meta(path)
is_dir: bool = os.path.isdir(path)
meta["size"] = os.path.getsize(path)
meta["type"] = "folder" if is_dir else "media"
if is_dir:
meta["elements"] = len(os.listdir(path))
if not meta["path"].endswith("/"):
meta["path"] += "/"
return meta

191
src/metadata_extractor.py Normal file
View File

@ -0,0 +1,191 @@
import json
import logging
import os
import subprocess
from typing import Optional
class MetadataExtractor:
SUPPORTED_EXTENSIONS = (".mp4", ".mkv", ".mov", ".avi")
def __init__(self):
self.logger: logging.Logger = logging.getLogger("MetadataExtractor")
def analyze_file(self, path: str) -> Optional[dict]:
"""
Extracts metadata from a video file using ffprobe
:param path: Path to the video file
:return: Metadata information or ``None`` if an error occurred
"""
# Get general file info in JSON format
cmd: list[str] = [
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
path
]
try:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
self.logger.error(f"Error processing {path}: {result.stderr}")
return None
data: dict = json.loads(result.stdout)
# Extract filename and title
filename: str = os.path.basename(path)
title: str = data.get("format", {}).get("tags", {}).get("title", filename)
# Initialize metadata structure
metadata: dict = {
"filename": filename,
"title": title,
"audio_tracks": [],
"subtitle_tracks": []
}
# Process streams
for stream in data.get("streams", []):
codec_type = stream.get("codec_type")
tags = stream.get("tags", {})
disposition = stream.get("disposition", {})
track = {
"index": stream.get("index"),
"language": tags.get("language", "und"),
"name": tags.get("title", ""),
"flags": {
"default": disposition.get("default", 0) == 1,
"original": disposition.get("original", 0) == 1,
"commentary": disposition.get("commentary", 0) == 1
}
}
if codec_type == "audio":
track |= {
"channels": stream.get("channels", 0)
}
track["flags"] |= {
"visual_impaired": disposition.get("visual_impaired", 0) == 1
}
metadata["audio_tracks"].append(track)
elif codec_type == "subtitle":
track["flags"] |= {
"forced": disposition.get("forced", 0) == 1,
"hearing_impaired": disposition.get("hearing_impaired", 0) == 1
}
metadata["subtitle_tracks"].append(track)
elif codec_type == "video":
pass
elif codec_type == "button":
pass
else:
self.logger.warning(f"Unknown track codec type '{codec_type}'")
return metadata
except Exception as e:
self.logger.error(f"Error processing {path}: {str(e)}")
return None
def process_file(self, file_path: str, output_dir: str) -> bool:
"""
Processes a single video file and writes metadata to a JSON file
:param file_path: Path of the video file
:param output_dir: Path of the directory where the output JSON file will be saved
:return: True if successful, False otherwise
"""
if not os.path.isfile(file_path):
self.logger.error(f"File not found: {file_path}")
return False
if not file_path.lower().endswith(self.SUPPORTED_EXTENSIONS):
self.logger.error(f"Unsupported file format: {file_path}")
return False
self.logger.debug(f"Extracting metadata from {os.path.basename(file_path)}")
metadata: Optional[dict] = self.analyze_file(file_path)
if metadata:
# Generate output filename based on input file
filename = os.path.basename(os.path.splitext(file_path)[0]) + "_metadata.json"
if output_dir:
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, filename)
else:
# If no output directory specified, save in the same directory as the input file
base_name = os.path.splitext(file_path)[0]
output_path = f"{base_name}_metadata.json"
# Write metadata to JSON file
with open(output_path, "w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
self.logger.debug(f"Metadata saved to {output_path}")
return True
return False
def process_directory(self, directory_path: str, output_dir: Optional[str] = None) -> bool:
"""
Processes all video files in a directory and writes metadata to a JSON file
:param directory_path: Path of the directory
:param output_dir: Path of the directory where the output JSON file will be saved
:return: True if successful, False otherwise
"""
if not os.path.isdir(directory_path):
self.logger.error(f"Directory not found: {directory_path}")
return False
all_metadata: dict[str, dict] = {}
file_count: int = 0
for root, _, files in os.walk(directory_path):
for file in files:
if file.lower().endswith(self.SUPPORTED_EXTENSIONS):
file_path: str = os.path.join(root, file)
self.logger.debug(f"Extracting metadata from {file}")
metadata: Optional[dict] = self.analyze_file(file_path)
if metadata:
# Use relative path as key
rel_path: str = os.path.relpath(file_path, directory_path)
all_metadata[rel_path] = metadata
file_count += 1
if file_count == 0:
self.logger.error(f"No supported video files found in {directory_path}")
return False
# Generate output filename based on directory name
dir_name: str = os.path.basename(os.path.normpath(directory_path))
filename: str = f"{dir_name}_metadata.json"
if output_dir is not None:
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, filename)
else:
# If no output directory specified, save in the current directory
output_path = filename
# Write all metadata to a single JSON file
with open(output_path, "w", encoding="utf-8") as f:
json.dump(all_metadata, f, indent=2, ensure_ascii=False)
self.logger.debug(f"Metadata for {file_count} files saved to {output_path}")
return True

286
src/metadata_writer.py Normal file
View File

@ -0,0 +1,286 @@
import json
import logging
import os
import subprocess
from typing import Optional
class MetadataWriter:
SUPPORTED_EXTENSIONS = (".mp4", ".mkv", ".mov", ".avi")
def __init__(self):
self.logger: logging.Logger = logging.getLogger("MetadataWriter")
@staticmethod
def get_mkvmerge_cmd(metadata: dict, in_path: str, out_path: str) -> list[str]:
cmd: list[str] = [
"mkvmerge",
"-o", out_path
]
# Add global metadata (title)
if "title" in metadata:
cmd.extend(["--title", metadata["title"]])
# Process audio + subtitle tracks
tracks: list[dict] = metadata.get("audio_tracks", []) + metadata.get("subtitle_tracks", [])
for track in tracks:
# Use the actual track index from the metadata
track_id = track.get("index", 0)
# Set language
if "language" in track:
cmd.extend(["--language", f"{track_id}:{track["language"]}"])
# Set title/name
if "name" in track and track["name"]:
cmd.extend(["--track-name", f"{track_id}:{track["name"]}"])
# Set disposition flags
flags = track.get("flags", {})
def yes_no(flag: str):
return f"{track_id}:{"yes" if flags.get(flag, False) else "no"}"
cmd.extend(["--default-track", yes_no("default")])
cmd.extend(["--forced-track", yes_no("forced")])
cmd.extend(["--original-flag", yes_no("original")])
# Add input file
cmd.append(in_path)
return cmd
@staticmethod
def get_mkvpropedit_cmd(metadata: dict, path: str) -> list[str]:
cmd: list[str] = [
"mkvpropedit",
path
]
# Add global metadata (title)
if "title" in metadata:
cmd.extend(["--edit", "info", "--set", f"title={metadata["title"]}"])
# Process audio + subtitle tracks
tracks: list[dict] = metadata.get("audio_tracks", []) + metadata.get("subtitle_tracks", [])
for track in tracks:
# Use the actual track index from the metadata
track_id = track.get("index", 0)
cmd.extend(["--edit", f"track:{track_id}"])
# Set language
if "language" in track:
cmd.extend(["--set", f"language={track["language"]}"])
# Set title/name
if "name" in track and track["name"]:
cmd.extend(["--set", f"name={track["name"]}"])
# Set disposition flags
flags = track.get("flags", {})
cmd.extend(["--set", f"flag-default={int(flags.get("default", False))}"])
cmd.extend(["--set", f"flag-forced={int(flags.get("forced", False))}"])
cmd.extend(["--set", f"flag-original={int(flags.get("original", False))}"])
return cmd
def apply_metadata(self, metadata: dict, in_path: str, out_path: Optional[str] = None) -> bool:
"""
Writes metadata to a video file using mkvmerge or mkvpropedit
:param metadata: Metadata information
:param in_path: Path of the input video file
:param out_path: Path of the output video file. If None, ``"_modified"`` is appended to ``in_path`` instead
:return: True if successful, False otherwise
"""
if not os.path.isfile(in_path):
self.logger.error(f"Input file not found: {in_path}")
return False
if out_path is None:
# Create a temporary output file
base_name, ext = os.path.splitext(in_path)
out_path: str = f"{base_name}_modified{ext}"
# Build the command
overwriting: bool = os.path.abspath(in_path) == os.path.abspath(out_path)
cmd: list[str] = (
self.get_mkvpropedit_cmd(metadata, in_path)
if overwriting else
self.get_mkvmerge_cmd(metadata, in_path, out_path)
)
# Execute the command
self.logger.debug(f"Writing metadata to {os.path.basename(out_path)}")
try:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
self.logger.error(f"Error writing metadata: {result.stderr}")
return False
self.logger.debug(f"Metadata written to {out_path}")
return True
except Exception as e:
self.logger.error(f"Error executing {cmd[0]}: {str(e)}")
return False
@staticmethod
def read_metadata(path: str) -> Optional[dict]:
try:
with open(path, "r") as f:
metadata: dict = json.load(f)
return metadata
except:
return None
def process_file(self, metadata_or_path: str|dict, file_path: str, output_dir: Optional[str] = None) -> bool:
"""
Processes a single video file with the given metadata
:param metadata_or_path: Metadata dict or path of the metadata file
:param file_path: Path of the video file
:param output_dir: Directory to save the output file to
:return: True if successful, False otherwise
"""
metadata: dict
if isinstance(metadata_or_path, str):
metadata = self.read_metadata(metadata_or_path)
if metadata is None:
return False
else:
metadata = metadata_or_path
# Create output file path
if output_dir is not None:
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Use the same filename in the output directory
output_file = os.path.join(output_dir, os.path.basename(file_path))
else:
output_file = None
# Write metadata to video
return self.apply_metadata(metadata, file_path, output_file)
def process_directory(self, metadata_or_path: str|dict, source_dir: str, output_dir: Optional[str] = None) -> bool:
"""
Processes all video files in the metadata dictionary
:param metadata_or_path: Dictionary of metadata keyed by filename
:param source_dir: Directory containing the video files
:param output_dir: Directory to save the output files to
:return: True if all files were processed successfully, False otherwise
"""
metadata: dict
if isinstance(metadata_or_path, str):
metadata = self.read_metadata(metadata_or_path)
if metadata is None:
return False
else:
metadata = metadata_or_path
if not os.path.isdir(source_dir):
self.logger.error(f"Source directory not found: {source_dir}")
return False
# Create output directory if specified
if output_dir:
os.makedirs(output_dir, exist_ok=True)
success: bool = True
processed_count: int = 0
# Process each file in the metadata dictionary
for filename, file_metadata in metadata.items():
# Construct the full path to the video file
video_file: str = os.path.join(source_dir, filename)
if not os.path.isfile(video_file):
self.logger.error(f"Video file not found: {video_file}")
success = False
continue
# Process the file
if self.process_file(file_metadata, video_file, output_dir):
processed_count += 1
else:
success = False
self.logger.debug(f"Processed {processed_count} out of {len(metadata)} files")
return success
def process_metadata(self, metadata_or_path: str|dict, source_dir: Optional[str] = None, output_dir: Optional[str] = None) -> bool:
metadata_as_path: bool = isinstance(metadata_or_path, str)
metadata: dict
if metadata_as_path:
metadata = self.read_metadata(metadata_or_path)
if metadata is None:
return False
else:
metadata = metadata_or_path
# Determine if the JSON contains metadata for multiple files or a single file
is_multi_file = isinstance(metadata, dict) and all(isinstance(metadata[key], dict) for key in metadata)
# If source directory is not specified, try to determine it from the JSON filename
if source_dir is None and is_multi_file and metadata_as_path:
# Extract folder name from JSON filename (e.g., "Millenium" from "Millenium_metadata.json")
json_basename: str = os.path.basename(metadata_or_path)
if json_basename.endswith("_metadata.json"):
folder_name: str = json_basename.split("_metadata.json")[0]
potential_source_dir: str = os.path.join(
os.path.dirname(os.path.abspath(metadata_or_path)),
folder_name
)
if os.path.isdir(potential_source_dir):
source_dir: str = potential_source_dir
self.logger.debug(f"Using source directory: {source_dir}")
# If no output directory is specified, create one based on the source directory
if output_dir is None and source_dir is not None:
output_dir = os.path.join("ready", os.path.basename(source_dir))
self.logger.debug(f"Using output directory: {output_dir}")
# Process files based on the metadata format
if is_multi_file:
if source_dir is None:
self.logger.error(
"Source directory not specified and could not be determined automatically. " +
"Please specify a source directory with --source or use a JSON filename like 'FolderName_metadata.json'"
)
return False
success = self.process_directory(metadata, source_dir, output_dir)
else:
# Single file metadata
if "filename" not in metadata:
self.logger.error("Invalid metadata format: missing 'filename' field")
return False
# If source directory is specified, look for the file there
video_file: str
if source_dir is not None:
video_file = os.path.join(source_dir, metadata["filename"])
elif metadata_as_path:
# Look for the file in the same directory as the JSON
video_file = os.path.join(os.path.dirname(metadata_or_path), metadata["filename"])
else:
self.logger.error(
"Source directory not specified and video path could not be determined automatically. " +
"Please specify a source directory with --source or use JSON filename like 'VideoName_metadata.json'"
)
return False
success = self.process_file(metadata, video_file, output_dir)
return success

View File

@ -0,0 +1,68 @@
<!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 id="header">
<a href="/"><img class="logo" src="/static/images/icon3.svg"></a>
<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 id="header">
<a href="/"><img class="logo" src="/static/images/icon3.svg"></a>
<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

@ -0,0 +1,121 @@
<!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/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>
<button id="improve-btn" class="template improve">
<img src="/static/images/improve.svg">
<img class="clicked" src="/static/images/improve_clicked.svg">
</button>
<header id="header">
<a href="/"><img class="logo" src="/static/images/icon3.svg"></a>
<h1>Metadata Editor</h1>
</header>
<header id="toolbar">
<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>
<button id="reload">Reload</button>
<button id="toggle-notifs">Notifications</button>
</header>
<main id="main">
<h1>Editing <code id="filename"></code><span id="unsaved"> - Unsaved</span></h1>
<div id="series-toolbar">
<button id="prev-episode"></button>
<div id="cur-episode"></div>
<button id="next-episode"></button>
</div>
<div class="sep"></div>
<div class="fields">
<div class="field">
<label for="title">Title</label>
<input type="text" id="title" name="title" size="50">
</div>
</div>
<div class="audio">
<h3>Audio Tracks</h3>
<table id="audio-tracks">
<thead>
<tr></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="table">
<h3>Subtitle Tracks</h3>
<table id="subtitle-tracks">
<thead>
<tr></tr>
</thead>
<tbody></tbody>
</table>
</div>
</main>
<div class="popup" id="integrity-popup">
<form class="container">
<h2 class="title">Integrity Error</h2>
<p class="description">
There seem to be a mismatch between the name of <span id="int-track-type"></span> track <span id="int-track-idx"></span> and its '<span id="int-field-name"></span>' field
</p>
<div class="original">
<h3>Current values</h3>
<div class="values">
<div class="name">
<label for="int-og-name">name</label>
<input type="text" readonly id="int-og-name">
</div>
<div class="field">
<label for="int-og-field" id="int-og-field-name"></label>
<input type="text" readonly id="int-og-field">
</div>
</div>
</div>
<div class="correction">
<h3>Correction</h3>
<div class="options">
<div class="option">
<input type="radio" value="nothing" name="int-corr-type" id="int-corr-nothing" checked>
<label for="int-corr-nothing">Do nothing</label>
</div>
<div class="option">
<input type="radio" value="name" name="int-corr-type" id="int-corr-name">
<label for="int-corr-name">Adapt name to field</label>
<div class="value">
<label for="int-corr-new-name">name</label>
<div class="arrow"></div>
<input type="text" readonly id="int-corr-new-name">
</div>
</div>
<div class="option">
<input type="radio" value="field" name="int-corr-type" id="int-corr-field">
<label for="int-corr-field">Adapt field to name</label>
<div class="value">
<label for="int-corr-new-field" id="int-corr-new-field-name"></label>
<div class="arrow"></div>
<input type="text" readonly id="int-corr-new-field">
</div>
</div>
</div>
</div>
<div class="buttons">
<button type="button" id="int-apply">Apply</button>
</div>
</form>
</div>
<aside id="notifs-hist">
<button id="close-notifs">Close</button>
<h2>Notifications</h2>
<div class="list"></div>
</aside>
<div id="notifs"></div>
</body>
</html>

View File

@ -0,0 +1,55 @@
<!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 id="header">
<a href="/"><img class="logo" src="/static/images/icon3.svg"></a>
<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,115 @@
@keyframes moon-pulse {
from {
filter: drop-shadow(0px 0px 0px #e7d7a8);
}
to {
filter: drop-shadow(0px 0px 12px white);
}
}
* {
margin: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
font-family: Ubuntu;
margin: 0;
padding: 0;
}
.template {
display: none !important;
}
main {
padding: 1.2em;
}
header {
background-color: #2b2b2b;
padding: 1.2em;
grid-area: header;
display: flex;
gap: 0.8em;
color: white;
&#header {
align-items: center;
img.logo {
width: 4em;
height: 4em;
object-fit: contain;
&:hover {
animation: moon-pulse 2s alternate infinite linear;
}
}
}
}
aside {
position: fixed;
right: 0;
top: 0;
bottom: 0;
background-color: white;
border-left: solid black 2px;
}
.notif {
--bg: #f0f0f0;
--border: #727272;
--fg: black;
--col: #f0f0f0;
&[data-type="success"] {
--bg: #e0ffe0;
--border: #727f72;
--fg: black;
--col: #8dff8d;
}
&[data-type="error"] {
--bg: #ffe0e0;
--border: #7f7272;
--fg: black;
--col: #ff8d8d;
}
&[data-type="warning"] {
--bg: #ffefe0;
--border: #7f7f72;
--fg: black;
--col: #ffc36a;
}
}
#notifs {
position: fixed;
top: 0;
left: 0;
right: 0;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.4em;
padding-top: 0.4em;
width: max-content;
.notif {
padding: 0.4em 0.8em;
border-radius: 0.6em;
background-color: var(--bg);
border: solid var(--border) 2px;
color: var(--fg);
max-width: 30em;
cursor: pointer;
}
}

View File

@ -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,436 @@
main {
display: flex;
flex-direction: column;
gap: 1.2em;
}
header#toolbar {
padding: 0.8em;
background-color: #4b4b4b;
a, button {
padding: 0.4em 0.8em;
border: none;
color: black;
background-color: #e4e4e4;
font-size: inherit;
font-family: inherit;
text-decoration: none;
border-radius: 0.2em;
cursor: pointer;
&:hover {
background-color: #dbdbdb;
}
}
}
#toggle-notifs {
margin-left: auto;
}
#filename {
font-size: 80%;
font-style: italic;
}
#unsaved {
color: #9d4916;
font-size: 80%;
&:not(.show) {
display: none;
}
}
table {
border-collapse: collapse;
width: 100%;
th, td {
padding: 0.4em 0.8em;
}
tbody {
tr {
&:nth-child(even) {
background-color: #ececec;
}
td {
text-align: center;
position: relative;
}
}
}
}
input[type="text"], select {
font-size: inherit;
font-family: inherit;
}
input[type="checkbox"] {
--size: 0.6em;
--pad: 0.2em;
width: calc((var(--size) * 2 + var(--pad)) * 2);
height: calc((var(--size) + var(--pad)) * 2);
border-radius: calc(var(--size) + var(--pad));
appearance: none;
background-color: #e1e1e1;
position: relative;
cursor: pointer;
&::after {
content: "";
width: calc(var(--size) * 2);
height: calc(var(--size) * 2);
border-radius: calc(var(--size));
background-color: #9b9b9b;
position: absolute;
top: 50%;
left: var(--pad);
transform: translateY(-50%);
}
&:checked {
background-color: #daf0d1;
&::after {
left: auto;
right: var(--pad);
background-color: #6ee74a;
}
}
}
input[type="radio"] {
--size: 0.5em;
--pad: 0.3em;
width: calc((var(--size) + var(--pad)) * 2);
height: calc((var(--size) + var(--pad)) * 2);
border-radius: calc(var(--size) + var(--pad));
appearance: none;
background-color: #e1e1e1;
position: relative;
cursor: pointer;
&:checked {
background-color: #daf0d1;
&::after {
content: "";
width: calc(var(--size) * 2);
height: calc(var(--size) * 2);
border-radius: calc(var(--size));
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #6ee74a;
}
}
}
label {
cursor: pointer;
}
button.improve {
width: 2em;
height: 2em;
border: none;
margin: 0;
padding: 0;
cursor: pointer;
position: absolute;
top: 50%;
transform: translateY(-50%);
margin-left: 0.4em;
border-radius: 0.4em;
background: none;
&:hover{
background-color: #8d8d8d42;
}
img {
position: absolute;
inset: 0;
width: inherit;
height: inherit;
object-fit: contain;
}
.clicked {
opacity: 0;
transition: opacity 0.2s;
}
&.clicked {
.clicked {
opacity: 1;
}
}
/*background: url("/static/images/improve.svg");
background-size: contain;
width: 2em;
height: 2em;
border: none;
margin: 0;
padding: 0;
cursor: pointer;
position: absolute;
top: 50%;
transform: translateY(-50%);
margin-left: 0.4em;
border-radius: 0.4em;
&:hover{
background-color: #8d8d8d42;
}
&::after {
content: "";
position: absolute;
background: url("/static/images/improve_clicked.svg");
background-size: contain;
width: inherit;
height: inherit;
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.2s;
}
&.clicked {
&::after {
opacity: 1;
}
}*/
}
.popup {
display: grid;
place-items: center;
position: fixed;
inset: 0;
background-color: #5c5c5c5c;
&:not(.show) {
display: none;
}
.container {
padding: 1.2em;
border-radius: 0.8em;
background-color: white;
display: flex;
flex-direction: column;
gap: 0.8em;
.title {
text-align: center;
margin: 0.4em 0;
}
h2, h3 {
margin: 0;
margin-bottom: 0.4em;
}
.buttons {
display: flex;
justify-content: center;
gap: 0.8em;
button {
padding: 0.8em 1.6em;
background-color: #dfdfdf;
border: none;
cursor: pointer;
border-radius: 0.4em;
font-family: inherit;
font-size: inherit;
font-weight: bold;
&:hover {
background-color: #e7e7e7;
}
&:active {
background-color: #d7d7d7;
}
}
}
}
}
#integrity-popup {
h3 {
margin-bottom: 0.4em;
}
label {
font-weight: bold;
}
.description {
span {
font-weight: bold;
}
}
input[type="text"] {
width: 100%;
}
.original {
.values {
display: flex;
flex-direction: column;
gap: 0.2em;
padding-left: 1.2em;
}
.name, .field {
display: flex;
gap: 0.4em;
align-items: center;
}
}
.correction {
.options {
display: flex;
flex-direction: column;
gap: 0.4em;
.option {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2em 0.4em;
align-items: center;
padding: 0.4em;
border: solid black 2px;
cursor: pointer;
border-radius: 0.2em;
&.selected {
border-color: #6ee74a;
}
input[type="radio"] {
grid-column: 1;
grid-row: 1;
}
label {
grid-column: 2;
grid-row: 1;
}
.arrow {
background: url("/static/images/arrow.svg");
width: 2em;
height: 2em;
background-size: contain;
}
.value {
grid-column: 2;
grid-row: 2;
display: flex;
align-items: center;
gap: 0.2em;
}
}
}
}
.select {
border: solid black 1px;
padding: 0.2em 0.4em;
background-color: #fafafa;
}
}
#notifs-hist {
padding: 0.8em;
height: 100%;
display: flex;
flex-direction: column;
max-width: 30em;
transform: translateX(0%);
transition: transform 0.5s cubic-bezier(0.22, 0.61, 0.36, 1);
&:not(.show) {
transform: translateX(100%);
}
#close-notifs {
align-self: flex-end;
background: none;
border: none;
padding: 0.4em 0.8em;
border-radius: 0.2em;
font-family: inherit;
font-size: inherit;
cursor: pointer;
&:hover {
background-color: #ebebeb;
}
}
.list {
display: flex;
flex-direction: column;
gap: 0.2em;
overflow-y: auto;
margin-top: 0.6em;
.notif {
border-left: solid var(--col) 4px;
padding: 0.4em;
}
}
}
.sep {
border-bottom: solid black 1px;
}
#series-toolbar {
display: flex;
gap: 0.4em;
padding: 0.4em;
align-items: center;
&:not(.show) {
display: none;
}
button {
background: var(--img);
width: 2.4em;
height: 2.4em;
background-color: transparent;
border: none;
background-size: 80%;
background-position: center;
background-repeat: no-repeat;
cursor: pointer;
border-radius: 0.4em;
&:hover {
background-color: #f1f1f1;
}
&#prev-episode {
--img: url("/static/images/prev.svg");
}
&#next-episode {
--img: url("/static/images/next.svg");
}
}
}

View File

@ -0,0 +1,41 @@
#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,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,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg857"
inkscape:version="1.3.2 (1:1.3.2+202404261509+091e20ef0f)"
sodipodi:docname="after_icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs851" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="6.9603479"
inkscape:cx="17.025011"
inkscape:cy="32.900654"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:window-width="1366"
inkscape:window-height="716"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1">
<inkscape:grid
type="xygrid"
id="grid1402"
empspacing="4"
originx="0"
originy="0"
spacingy="1.0583333"
spacingx="1.0583333"
units="px"
visible="true" />
</sodipodi:namedview>
<metadata
id="metadata854">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-280.06665)">
<path
style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 2.1166666,288.53332 H 14.816666"
id="path1406"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 10.583333,285.35832 4.233333,3.175 -4.233333,3.175"
id="path1"
sodipodi:nodetypes="ccc" />
</g>
</svg>

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

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="film.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="5.7798541"
inkscape:cx="54.932183"
inkscape:cy="64.274979"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="0.99999998"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="16"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g12"
transform="translate(1.7078686e-5,-8)">
<g
id="g11">
<g
id="g10">
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path1"
cx="56"
cy="72"
r="40" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path1-5"
cx="56"
cy="72"
r="32" />
</g>
<g
id="g7">
<g
id="g6">
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2"
cx="56"
cy="53"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-2"
cx="72.454483"
cy="62.5"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-7"
cx="72.454483"
cy="81.5"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-6"
cx="56"
cy="91"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-61"
cx="39.545517"
cy="81.5"
r="6" />
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path2-20"
cx="39.545517"
cy="62.5"
r="6" />
</g>
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path3"
cx="56"
cy="72"
r="3" />
</g>
</g>
<g
id="g9">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 70.704221,109.19927 C 78.261106,107.5993 90,108 98,103 c 8,-5 12,-6 14,-7 l -7,-14 c -5,2 -6,7 -9,11 -3,4 -10.113506,5.585663 -10.113506,5.585663"
id="path4"
sodipodi:nodetypes="czcczc" />
<g
id="g8">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 97.649449,90.439824 5.231401,9.711746"
id="path5" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 90.543116,96.977586 -0.626729,9.286354"
id="path6"
sodipodi:nodetypes="cc" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,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,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.4.1 (1:1.4.1+202503302257+93de688d07)"
sodipodi:docname="improve.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="8.8461329"
inkscape:cx="18.652218"
inkscape:cy="27.808761"
inkscape:window-width="2048"
inkscape:window-height="1228"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
transform="matrix(2,0,0,2,-30,-24)">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 24,32 c 3,0 8,-5 8,-8 0,3 5,8 8,8 -3,0 -8,5 -8,8 0,-3 -5,-8 -8,-8 z"
id="path1"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.7;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 19,22 c 1.875,0 5,-3.125 5,-5 0,1.875 3.125,5 5,5 -1.875,0 -5,3.125 -5,5 0,-1.875 -3.125,-5 -5,-5 z"
id="path1-3"
sodipodi:nodetypes="ccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 35,19 c 1.125,0 3,-1.875 3,-3 0,1.125 1.875,3 3,3 -1.125,0 -3,1.875 -3,3 0,-1.125 -1.875,-3 -3,-3 z"
id="path1-1"
sodipodi:nodetypes="ccccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.4.1 (1:1.4.1+202503302257+93de688d07)"
sodipodi:docname="improve_clicked.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="8.8461329"
inkscape:cx="18.652218"
inkscape:cy="27.808761"
inkscape:window-width="2048"
inkscape:window-height="1228"
inkscape:window-x="0"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
transform="matrix(2,0,0,2,-30,-24)"
style="fill:#efe348;fill-opacity:1">
<path
style="fill:#efe348;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="m 24,32 c 3,0 8,-5 8,-8 0,3 5,8 8,8 -3,0 -8,5 -8,8 0,-3 -5,-8 -8,-8 z"
id="path1"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#efe348;stroke:#000000;stroke-width:0.7;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="m 19,22 c 1.875,0 5,-3.125 5,-5 0,1.875 3.125,5 5,5 -1.875,0 -5,3.125 -5,5 0,-1.875 -3.125,-5 -5,-5 z"
id="path1-3"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#efe348;stroke:#000000;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
d="m 35,19 c 1.125,0 3,-1.875 3,-3 0,1.125 1.875,3 3,3 -1.125,0 -3,1.875 -3,3 0,-1.125 -1.875,-3 -3,-3 z"
id="path1-1"
sodipodi:nodetypes="ccccc" />
</g>
</g>
</svg>

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

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
sodipodi:docname="next_icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="2.0560856"
inkscape:cx="8.7544994"
inkscape:cy="178.49452"
inkscape:window-width="1858"
inkscape:window-height="1016"
inkscape:window-x="1982"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="0.26458333"
spacingy="0.26458333"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
dotted="false"
gridanglex="30"
gridanglez="30"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="color:#000000;fill:#000000;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
d="m 19.698341,10.884614 a 4.2333331,4.2333331 0 0 0 -2.763672,1.691406 4.2333331,4.2333331 0 0 0 0.933594,5.914062 L 36.444435,31.999848 17.868263,45.509614 a 4.2333331,4.2333331 0 0 0 -0.933594,5.914062 4.2333331,4.2333331 0 0 0 5.914063,0.933594 L 46.131935,35.423676 a 4.2337564,4.2337564 0 0 0 0,-6.847656 L 22.848732,11.642426 a 4.2333331,4.2333331 0 0 0 -3.150391,-0.757812 z"
id="path1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
sodipodi:docname="prev_icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="2.0560856"
inkscape:cx="8.7544994"
inkscape:cy="178.49452"
inkscape:window-width="1858"
inkscape:window-height="1016"
inkscape:window-x="1982"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="0.26458333"
spacingy="0.26458333"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
dotted="false"
gridanglex="30"
gridanglez="30"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="color:#000000;fill:#000000;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
d="m 44.301659,10.884614 a 4.2333331,4.2333331 0 0 1 2.763672,1.691406 4.2333331,4.2333331 0 0 1 -0.933594,5.914062 L 27.555565,31.999848 46.131737,45.509614 a 4.2333331,4.2333331 0 0 1 0.933594,5.914062 4.2333331,4.2333331 0 0 1 -5.914063,0.933594 L 17.868065,35.423676 a 4.2337564,4.2337564 0 0 1 0,-6.847656 L 41.151268,11.642426 a 4.2333331,4.2333331 0 0 1 3.150391,-0.757812 z"
id="path1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,188 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="series.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="4.086974"
inkscape:cx="58.845493"
inkscape:cy="81.356034"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="0.99999998"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="16"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect15"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect14"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,2.1961523,0,1 @ F,0,1,1,0,2.1961523,0,1 @ F,0,0,1,0,2.1961523,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect15-4"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect15-8"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect15-9"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,1,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g4"
transform="translate(-8,-8)">
<g
id="g3">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 24,56 v 48 a 8,8 45 0 0 8,8 h 64 a 8,8 135 0 0 8,-8 V 56 A 8,8 45 0 0 96,48 H 32 a 8,8 135 0 0 -8,8 z"
id="path12"
inkscape:path-effect="#path-effect15"
inkscape:original-d="m 24,48 v 64 h 80 V 48 Z" />
<g
id="g1">
<circle
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path13"
cx="64"
cy="80"
r="16" />
<path
id="path14"
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"
inkscape:transform-center-x="-2"
transform="translate(-1)"
d="m 70.098076,81.098076 -8.196152,4.732051 A 1.2679491,1.2679491 30 0 1 60,84.732051 v -9.464102 a 1.2679491,1.2679491 150 0 1 1.901924,-1.098076 l 8.196152,4.732051 a 1.2679491,1.2679491 90 0 1 0,2.196152 z"
inkscape:path-effect="#path-effect14"
inkscape:original-d="M 72,80 60,86.928203 V 73.071797 Z" />
</g>
</g>
<g
id="g2">
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 99.289904,111.29439 C 102.06725,110.03979 104,107.2456 104,104 V 56 c 0,-4.418278 -3.58172,-8 -8,-8 H 32 c -3.244751,0 -6.038321,1.93174 -7.293397,4.707908"
id="path12-9"
transform="translate(8,-8)"
sodipodi:nodetypes="cssssc" />
<path
style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 115.2899,95.29439 C 118.06725,94.03979 120,91.2456 120,88 V 40 c 0,-4.418278 -3.58172,-8 -8,-8 H 48 c -3.244751,0 -6.038321,1.93174 -7.293397,4.707908"
id="path12-9-5"
sodipodi:nodetypes="cssssc" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,227 @@
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")
convertBtn.disabled = !agent
}
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

@ -0,0 +1,8 @@
import Editor from "./editor.mjs";
import * as utils from "./utils.mjs"
window.addEventListener("load", () => {
const editor = new Editor()
window.editor = editor
window.utils = utils
})

View File

@ -0,0 +1,170 @@
import TracksTable from "./tracks_table.mjs"
import IntegrityManager from "./integrity_manager.mjs"
import { updateObjectFromJoinedKey } from "./utils.mjs"
import { loadMetadata, SeriesMetadata } from "./metadata.mjs"
export default class Editor {
constructor() {
const params = new URLSearchParams(window.location.search)
this.filename = params.get("f")
window.addEventListener("keydown", e => {
if (e.key === "s" && e.ctrlKey) {
e.preventDefault()
this.save()
}
})
this.tables = {
audio: new TracksTable(this, "audio", "audio-tracks", "audio_tracks"),
subtitle: new TracksTable(this, "subtitle", "subtitle-tracks", "subtitle_tracks")
}
this.metadata = null
this.data = {}
this.dirty = false
this.integrityMgr = new IntegrityManager(this)
document.getElementById("check-integrity").addEventListener("click", () => this.checkIntegrity())
document.getElementById("improve-all").addEventListener("click", () => this.improveAllNames())
document.getElementById("save").addEventListener("click", () => this.save())
document.getElementById("reload").addEventListener("click", () => window.location.reload())
document.getElementById("toggle-notifs").addEventListener("click", () => this.toggleNotifications())
document.getElementById("close-notifs").addEventListener("click", () => this.closeNotifications())
document.getElementById("prev-episode").addEventListener("click", () => this.prevEpisode())
document.getElementById("next-episode").addEventListener("click", () => this.nextEpisode())
this.titleInput = document.getElementById("title")
this.titleInput.addEventListener("change", () => {
this.data.title = this.titleInput.value
this.setDirty()
})
this.setup()
}
setup() {
document.getElementById("filename").innerText = this.filename
this.fetchData()
}
fetchData() {
fetch(`/api/file/${this.filename}`).then(res => {
if (res.ok) {
return res.json()
}
return null
}).then(res => {
if (res !== null) {
this.metadata = loadMetadata(res)
this.displayData()
}
})
}
displayData() {
const seriesToolbar = document.getElementById("series-toolbar")
if (this.metadata instanceof SeriesMetadata) {
seriesToolbar.classList.add("show")
const cur = this.metadata.episodeIdx + 1
const tot = this.metadata.episodes.length
const epMeta = this.metadata.getCurrentEpisode()
const season = epMeta.season
const episode = epMeta.episode
seriesToolbar.querySelector("#cur-episode").innerText = `S${season}E${episode} (${cur} / ${tot})`
} else {
seriesToolbar.classList.remove("show")
}
this.data = this.metadata.getData()
this.titleInput.value = this.data.title
this.tables.audio.loadTracks(this.data.audio_tracks)
this.tables.subtitle.loadTracks(this.data.subtitle_tracks)
}
save() {
fetch(`/api/file/${this.filename}`, {
method: "POST",
body: JSON.stringify(this.metadata.data),
headers: {
"Content-Type": "application/json"
}
}).then(res => {
if (res.ok) {
this.dirty = false
document.getElementById("unsaved").classList.remove("show")
this.notify("Saved successfully !", "success")
} else {
this.notify(`Error ${res.status}: ${res.statusText}`, "error", 10000)
}
})
}
setDirty() {
this.dirty = true
document.getElementById("unsaved").classList.add("show")
}
editTrack(listKey, trackIdx, key, value) {
updateObjectFromJoinedKey(this.data[listKey][trackIdx], key, value)
this.setDirty()
}
notify(text, type, duration=5000) {
const list = document.getElementById("notifs")
const hist = document.getElementById("notifs-hist").querySelector(".list")
const notif = document.createElement("div")
notif.classList.add("notif")
notif.dataset.type = type
notif.innerText = text
list.appendChild(notif)
setTimeout(() => notif.remove(), duration)
notif.addEventListener("click", () => notif.remove())
hist.prepend(notif.cloneNode(true))
}
checkIntegrity() {
if (this.integrityMgr.checkIntegrity()) {
this.notify("No integrity error detected !", "success")
}
}
improveAllNames() {
this.integrityMgr.improveAllNames()
this.notify("Improved all names !", "success")
}
toggleNotifications() {
const hist = document.getElementById("notifs-hist")
if (hist.classList.contains("show")) {
this.closeNotifications()
} else {
this.openNotifications()
}
}
openNotifications() {
const hist = document.getElementById("notifs-hist")
hist.classList.add("show")
}
closeNotifications() {
const hist = document.getElementById("notifs-hist")
hist.classList.remove("show")
}
prevEpisode() {
if (this.metadata instanceof SeriesMetadata) {
if (this.metadata.prev()) {
this.displayData()
}
}
}
nextEpisode() {
if (this.metadata instanceof SeriesMetadata) {
if (this.metadata.next()) {
this.displayData()
}
}
}
}

View File

View File

@ -0,0 +1,380 @@
import { Track } from "./tracks_table.mjs"
import { findLanguage, isLanguageTagAlias, flattenObj, updateObjectFromJoinedKey, LANGUAGES } from "./utils.mjs"
class Mismatch {
constructor(track, key, value) {
/** @type {Track} */
this.track = track
/** @type {string} */
this.key = key
this.value = value
}
}
const CorrectionType = {
NOTHING: "nothing",
NAME: "name",
FIELD: "field"
}
const WORDS = {
forced: {
default: "Forced",
fre: "Forcés",
"fre-ca": "Forcés"
},
full: {
default: "Full",
fre: "Complets",
"fre-ca": "Complets"
},
}
function containsWord(parts, word) {
return Object.values(WORDS[word]).some(w => parts.includes(w))
}
function getWord(word, lang) {
const words = WORDS[word]
return words[lang] ?? words.default
}
class MismatchCorrection {
/**
*
* @param {Mismatch} mismatch
* @param {string} oldName
* @param {string} newName
*/
constructor(mismatch, oldName, newName) {
this.track = mismatch.track
this.key = mismatch.key
this.oldName = oldName
this.newName = newName
this.oldValue = this.track.fields[mismatch.key]
this.newValue = mismatch.value
}
apply(correctionType) {
switch (correctionType) {
case CorrectionType.NOTHING:
break
case CorrectionType.NAME:
this.track.editValue("name", this.newName)
break
case CorrectionType.FIELD:
this.track.editValue(this.key, this.newValue)
break
default:
throw new Error(
`Invalid correction type, must be one of [${Object.values(CorrectionType)}], got ${correctionType}`
)
}
}
}
export default class IntegrityManager {
IGNORE_KEYS = [
"type", "channels_details"
]
/**
*
* @param {import('./editor.mjs').default} editor
*/
constructor(editor) {
this.editor = editor
/** @type {Mismatch[]} */
this.mismatches = []
/** @type {?MismatchCorrection} */
this.correction = null
this.ignoreList = []
this.popup = document.getElementById("integrity-popup")
this.popup.querySelectorAll(".correction .option").forEach(opt => {
const radio = opt.querySelector("input[type='radio']")
opt.addEventListener("click", e => {
radio.click()
})
radio.addEventListener("input", () => {
if (radio.checked) {
const prev = this.popup.querySelector(".option.selected input[type='radio']:not(:checked)")
prev.parentElement.classList.remove("selected")
opt.classList.add("selected")
} else {
opt.classList.remove("selected")
}
})
if (radio.checked) {
opt.classList.add("selected")
}
})
this.popup.querySelector("#int-apply").addEventListener("click", () => {
const fd = new FormData(this.popup.querySelector("form"))
const correctionType = fd.get("int-corr-type")
this.correct(correctionType)
})
}
nextError() {
if (this.mismatches.length === 0) {
this.hideMismatchPopup()
return
}
const mismatch = this.mismatches.shift()
console.log("Next mismatch:", mismatch)
this.mismatches = this.mismatches.filter(m => m.track !== mismatch.track)
this.showMismatchPopup(mismatch)
}
checkIntegrity() {
this.ignoreList = []
this.mismatches = []
for (const table of Object.values(this.editor.tables)) {
this.checkTableIntegrity(table)
}
if (this.mismatches.length === 0) {
return true
}
this.nextError()
return false
}
/**
*
* @param {import('./tracks_table.mjs').default} table
*/
checkTableIntegrity(table) {
for (const track of table.tracks) {
this.checkTrackIntegrity(track)
}
}
/**
*
* @param {Track} track
*/
checkTrackIntegrity(track, prepend=false) {
let fields = this.parseName(track.table.type, track.fields["name"])
fields = flattenObj(fields)
Object.entries(fields).map(([key, value]) => {
if (this.IGNORE_KEYS.includes(key)) {
return
}
if (this.ignoreList.some(o => o.track === track && o.key === key)) {
return
}
let equal = track.fields[key] === value
if (key === "language") {
equal = isLanguageTagAlias(value, track.fields[key])
}
if (!equal) {
this.addMismatchField(track, key, value, prepend)
//console.error(`Mismatch for field ${key}:\n- name: ${value}\n- track: ${track.fields[key]}`)
} else {
track.fields[key] = value
}
})
}
parseName(trackType, name) {
/** @type {string} */
const lower = name.toLowerCase()
const parts = lower.split(/\b/)
const fields = {flags: {}}
switch (trackType) {
case "audio":
const audioLang = findLanguage(lower)
if (audioLang !== null) {fields.language = audioLang}
const original = parts.includes("vo")
if (original) {fields.flags.original = original}
const ad = parts.includes("ad")
if (ad) {fields.flags.visual_impaired = ad}
const channels = lower.match(/\d+\.\d+/)
if (channels) {
fields.channels_details = channels[0]
}
break
case "subtitle":
const stLang = findLanguage(lower)
if (stLang !== null) {fields.language = stLang}
const isForced = containsWord(parts, "forced")
const isFull = containsWord(parts, "full")
if (isForced) {
fields.flags.forced = true
} else if (isFull) {
fields.flags.forced = false
}
const sdh = parts.includes("sdh")
if (sdh) {fields.flags.hearing_impaired = sdh}
if (parts.includes("pgs")) {
fields.type = "PGS"
} else if (parts.includes("srt")) {
fields.type = "SRT"
}
break
}
return fields
}
addMismatchField(track, key, value, prepend=false) {
const mismatch = new Mismatch(track, key, value)
if (prepend) {
this.mismatches.splice(0, 0, mismatch)
} else {
this.mismatches.push(mismatch)
}
}
/**
*
* @param {Mismatch} mismatch
*/
showMismatchPopup(mismatch) {
const q = sel => this.popup.querySelector(sel)
const track = mismatch.track
const table = track.table
const fieldProps = table.getFieldProps(mismatch.key)
const ogName = track.fields["name"]
const ogValue = track.fields[mismatch.key]
let fields = this.parseName(table.type, ogName)
let keys = Object.keys(flattenObj(fields))
Object.entries(flattenObj(track.fields)).forEach(([key, val]) => {
if (!keys.includes(key) || key == mismatch.key) {
updateObjectFromJoinedKey(fields, key, val)
}
})
const newName = this.reconstructName(table.type, fields)
this.correction = new MismatchCorrection(
mismatch, ogName, newName
)
let ogField = track.makeInput(fieldProps, ogValue, false)
let newField = track.makeInput(fieldProps, mismatch.value, false)
ogField = this.lockInput(ogField)
newField = this.lockInput(newField)
ogField.id = "int-og-field"
newField.id = "int-corr-new-field"
q("#int-track-type").innerText = table.type
q("#int-track-idx").innerText = track.idx
q("#int-field-name").innerText = fieldProps.name
q("#int-og-name").value = ogName
q("#int-og-field-name").innerText = fieldProps.name
q("#int-og-field").replaceWith(ogField)
q("#int-corr-new-name").value = newName
q("#int-corr-new-field-name").innerText = fieldProps.name
q("#int-corr-new-field").replaceWith(newField)
this.popup.classList.add("show")
}
reconstructName(tableType, fields) {
let name = LANGUAGES[fields.language].display
switch (tableType) {
case "audio":
if (fields.flags.original) {
name += " VO"
}/* else if (fields.language === "fre") {
name += " VFF"
} else if (fields.language === "fre-ca") {
name += " VFQ"
}*/
if (fields.flags.visual_impaired) {
name += " AD"
}
if (fields.channels_details) {
name += " / " + fields.channels_details
}
break
case "subtitle":
if (fields.flags.forced) {
name += " " + getWord("forced", fields.language)
} else {
name += " " + getWord("full", fields.language)
}
if (fields.flags.hearing_impaired) {
name += " SDH"
}
if (fields.type) {
name += " | " + fields.type
}
break
}
return name
}
hideMismatchPopup() {
this.popup.classList.remove("show")
}
correct(correctionType) {
if (correctionType === CorrectionType.NOTHING) {
this.ignoreList.push({
track: this.correction.track,
key: this.correction.key
})
}
this.correction.apply(correctionType)
this.checkTrackIntegrity(this.correction.track, true)
this.nextError()
}
lockInput(input) {
input.readOnly = true
if (input.type === "checkbox") {
input.disabled = true
} else if (input.tagName === "SELECT") {
let text = document.createElement("div")
text.classList.add("select")
text.innerText = input.value
text.dataset.key = input.dataset.key
input = text
}
return input
}
improveAllNames() {
for (const table of Object.values(this.editor.tables)) {
for (const track of table.tracks) {
this.improveName(track)
}
}
}
/**
*
* @param {Track} track
*/
improveName(track) {
let nameFields = this.parseName(track.table.type, track.fields["name"])
const keys = Object.keys(flattenObj(nameFields))
Object.entries(flattenObj(track.fields)).forEach(([key, val]) => {
if (!keys.includes(key)) {
updateObjectFromJoinedKey(nameFields, key, val)
}
})
const name = this.reconstructName(track.table.type, nameFields)
track.editValue("name", name)
}
}

View File

@ -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

@ -0,0 +1,85 @@
export default class Metadata {
constructor(data) {
this.data = data
}
getData() {
return this.data
}
}
export class MediaMetadata extends Metadata {
constructor(data) {
super(data)
}
}
export class EpisodeMetadata extends MediaMetadata {
REGEXP = /s(?<season>\d+)e(?<episode>\d+)/i
/**
*
* @param {object} data
* @param {string} episodeKey
*/
constructor(data, episodeKey) {
super(data)
this.key = episodeKey
let m = this.key.match(this.REGEXP) ?? this.data.filename.match(this.REGEXP)
this.season = "xx"
this.episode = "xx"
if (m) {
this.season = m.groups.season
this.episode = m.groups.episode
}
}
}
export class SeriesMetadata extends Metadata {
constructor(data) {
super(data)
const episodeKeys = Object.keys(data).sort()
this.episodes = episodeKeys.map(key => {
return new EpisodeMetadata(data[key], key)
})
this.episodeIdx = 0
}
getCurrentEpisode() {
return this.episodes[this.episodeIdx]
}
getData() {
return this.getCurrentEpisode().getData()
}
prev() {
if (this.episodeIdx === 0) {
return false
}
this.episodeIdx -= 1
return true
}
next() {
if (this.episodeIdx === this.episodes.length - 1) {
return false
}
this.episodeIdx += 1
return true
}
}
/**
*
* @param {object} data
* @returns {Metadata}
*/
export function loadMetadata(data) {
if ("filename" in data) {
return new MediaMetadata(data)
}
return new SeriesMetadata(data)
}

View File

@ -0,0 +1,249 @@
import { findLanguage, flattenObj, getLanguageOptions } from "./utils.mjs"
export class Track {
constructor(table, idx, fields) {
/** @type {TracksTable} */
this.table = table
/** @type {number} */
this.idx = idx
/** @type {object} */
this.fields = flattenObj(fields)
this.row = null
}
makeRow() {
this.row = document.createElement("tr")
this.row.dataset.i = this.idx
this.table.fields.forEach(field => {
const td = this.row.insertCell(-1)
const input = this.makeInput(field, this.fields[field.key])
td.appendChild(input)
if (field.key === "name") {
const btn = document.getElementById("improve-btn").cloneNode(true)
btn.id = null
btn.classList.remove("template")
btn.addEventListener("click", () => {
this.improveName()
btn.classList.add("clicked")
setTimeout(() => btn.classList.remove("clicked"), 1000)
})
td.appendChild(btn)
}
})
return this.row
}
makeInput(field, value, listeners=true) {
let input = document.createElement("input")
let getValue = () => input.value
switch (field.type) {
case "num":
input.type = "number"
input.value = value
getValue = () => +input.value
break
case "str":
input.type = "text"
input.value = value
break
case "bool":
input.type = "checkbox"
getValue = () => input.checked
const onehot = this.table.CONSTRAINTS[field.key]?.type == "onehot"
if (listeners && onehot) {
if (value) {
if (field.key in this.table.onehots) {
this.table.editor.notify(
`Error in metadata file: field '${field.name}' is onehot but multiple tracks are enabled. Only the first one will be enabled`,
"error",
20000
)
value = false
} else {
this.table.onehots[field.key] = input
}
}
input.addEventListener("click", e => {
if (!input.checked) {
e.preventDefault()
} else {
if (field.key in this.table.onehots) {
this.table.onehots[field.key].checked = false
this.table.onehots[field.key].dispatchEvent(new Event("change"))
}
this.table.onehots[field.key] = input
}
})
}
input.checked = value
break
case "sel":
input = document.createElement("select")
let options = this.table.OPTIONS[field.key]
if (typeof options === "function") {
options = options()
}
options.forEach(option => {
const opt = document.createElement("option")
opt.innerText = option.display
opt.value = option.value
input.appendChild(opt)
})
if (field.key === "language") {
const lang = findLanguage(value)
if (lang === null) {
this.table.editor.notify(
`Unknown language '${value}' for ${this.table.type} track ${this.idx}`,
"error",
20000
)
} else if (lang !== value) {
this.table.editor.notify(
`Language of ${this.table.type} track ${this.idx} was corrected (${value} -> ${lang})`,
"warning"
)
value = lang
this.editValue(field.key, value)
}
}
input.value = value
break
default:
break
}
input.name = field.key + "[]"
input.dataset.key = field.key
if (this.table.CONSTRAINTS[field.key]?.type === "readonly") {
input.disabled = true
}
if (listeners) {
input.addEventListener("change", () => {
this.editValue(field.key, getValue())
})
}
return input
}
editValue(key, value) {
this.fields[key] = value
this.table.editTrack(this.idx, key, value)
const input = this.row.querySelector(`[data-key='${key}']`)
if (input) {
const fieldType = this.table.getFieldProps(key).type
switch (fieldType) {
case "bool":
input.checked = value
break
default:
input.value = value
break
}
}
}
improveName() {
this.table.editor.integrityMgr.improveName(this)
}
}
export default class TracksTable {
OPTIONS = {
"language": getLanguageOptions
}
CONSTRAINTS = {
"flags/default": {
type: "onehot"
},
"index": {
type: "readonly"
},
"channels": {
type: "readonly"
}
}
/**
* @param {import('./editor.mjs').default} editor The parent editor
* @param {string} type The type of tracks. One of `['audio', 'subtitle']`
* @param {string} tableId The id of the table element
* @param {string} dataKey The key of the tracks list inside of the data object
*/
constructor(editor, type, tableId, dataKey) {
this.editor = editor
this.type = type
this.table = document.getElementById(tableId)
this.headers = this.table.querySelector("thead tr")
this.body = this.table.querySelector("tbody")
this.dataKey = dataKey
this.tracks = []
this.fields = []
this.onehots = {}
}
getFieldProps(key) {
return this.fields.find(f => f.key == key)
}
loadTracks(tracks) {
this.clear()
this.tracks = tracks.map((t, i) => new Track(this, i, t))
if (tracks.length === 0) {
return
}
this.detectFields()
this.addHeaders()
this.tracks.forEach(track => {
this.body.appendChild(track.makeRow())
})
}
clear() {
this.tracks = []
this.fields = []
this.onehots = {}
this.headers.innerHTML = ""
this.body.innerHTML = ""
}
detectFields() {
Object.entries(this.tracks[0].fields).forEach(([key, value]) => {
let type = {
boolean: "bool",
number: "num"
}[typeof value] ?? "str"
if (key === "language") {
type = "sel"
}
const name = key.split("/").slice(-1)[0]
this.fields.push({name, type, key})
})
}
addHeaders() {
this.fields.forEach(field => {
const th = document.createElement("th")
th.innerText = field.name
this.headers.appendChild(th)
})
}
editTrack(trackIdx, fieldKey, fieldValue) {
this.editor.editTrack(this.dataKey, trackIdx, fieldKey, fieldValue)
}
}

View File

@ -0,0 +1,121 @@
/**
* Flattens an object recursively. Nested keys are joined with slashes ('/')
* @param {object} obj The object to flatten
* @returns {object} The flattened object
*/
export function flattenObj(obj) {
const res = {}
Object.entries(obj).forEach(([key, value]) => {
if (typeof value === "object") {
value = flattenObj(value)
Object.entries(value).forEach(([key2, value2]) => {
res[key + "/" + key2] = value2
})
} else {
res[key] = value
}
})
return res
}
// Code (Flag): https://en.wikipedia.org/wiki/Regional_indicator_symbol
// Tag: https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes
export const LANGUAGES = {
"fre-ca": {
display: "Français CA",
code: "ca",
aliases: ["fr-ca", "vfq", "quebec", "québec", "ca", "canada"]
},
"fre": {
display: "Français FR",
code: "fr",
aliases: ["fr", "fra", "french", "francais", "français", "vf", "vff", "france"]
},
"eng": {
display: "English",
code: "gb",
aliases: ["en", "ang", "english", "anglais", "uk", "gb", "usa", "british", "american", "amérique", "amerique", "angleterre", "royaume-uni"]
},
"deu": {
display: "Deutsch",
code: "de",
aliases: ["de", "ger", "german", "allemand", "deutsch", "germany", "allemagne"]
},
"ita": {
display: "Italiano",
code: "it",
aliases: ["it", "ita", "italian", "italien", "italiano", "italy", "italie"]
},
"kor": {
display: "Korean",
code: "kr",
aliases: ["ko", "kr", "cor", "korean", "coreen", "coréen", "corée", "coree", "korea"]
},
"jpn": {
display: "Japanese",
code: "jp",
aliases: ["ja", "jp", "jap", "japanese", "japonais", "japon", "japan"]
},
"tur": {
display: "Turkish",
code: "tr",
aliases: ["tu", "tr", "tür", "turkish", "turc", "turquie"]
},
"und": {
display: "Undefined",
code: "",
aliases: []
}
}
export function getLanguageAliases(langTag) {
return [langTag].concat(LANGUAGES[langTag].aliases)
}
/**
* Tries to find a language name in the given string
* @param {string} value The string in which to search for a language
* @returns {?string} The language key if it could be determined, null otherwise
*/
export function findLanguage(value) {
for (const lang in LANGUAGES) {
const aliases = getLanguageAliases(lang)
const matches = aliases.some(a => {
return new RegExp("\\b" + a + "\\b").test(value)
})
if (matches) {
return lang
}
}
return null
}
export function isLanguageTagAlias(langTag, value) {
return getLanguageAliases(langTag).includes(value)
}
export function updateObjectFromJoinedKey(obj, joinedKey, value) {
const keyParts = joinedKey.split("/")
for (const part of keyParts.slice(0, -1)) {
obj = obj[part]
}
obj[keyParts[keyParts.length - 1]] = value
}
/**
* @param {string} code
*/
export function makeFlag(code) {
return code.split("")
.map(c => String.fromCodePoint(
0x1f1e6 + c.codePointAt(0) - 97
))
.join("")
}
export function getLanguageOptions() {
return Object.entries(LANGUAGES).map(([tag, props]) => {
const flag = makeFlag(props.code)
return {value: tag, display: `${flag} ${props.display}`}
})
}

197
src/server.py Normal file
View File

@ -0,0 +1,197 @@
from __future__ import annotations
import json
import logging
import os
import socketserver
import time
from functools import partial
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
from logging import Logger
from typing import Optional
from urllib.parse import parse_qs, unquote, urlparse
from watchdog.events import (FileClosedEvent, FileDeletedEvent, FileMovedEvent,
FileSystemEventHandler)
from watchdog.observers import Observer
from watchdog.observers.api import BaseObserver
from src.file_handlers import ToConvertFileHandler, MetadataFileHandler
class HTTPHandler(SimpleHTTPRequestHandler):
def __init__(self, server: MeliesServer, *args, **kwargs):
self.server_: MeliesServer = server
self.to_convert_files: ToConvertFileHandler = self.server_.to_convert_files
self.metadata_files: MetadataFileHandler = self.server_.metadata_files
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):
self.server_.logger.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.server_.max_payload_size:
self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
self.log_error(f"Payload is too big ({self.server_.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":
base_path: str = self.query.get("f", [""])[0]
files: list[dict] = self.to_convert_files.get_files_meta(base_path)
self.send_json(files)
elif path == "files/metadata":
files: list[dict] = self.metadata_files.get_files_meta()
self.send_json(files)
elif path.startswith("file"):
filename: str = path.split("/", 1)[1]
data = self.metadata_files.read(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.metadata_files.write(filename, self.data):
self.send_response(HTTPStatus.OK)
self.end_headers()
else:
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
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"))
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.logger: Logger = logging.getLogger("MeliesServer")
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
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)
self.to_convert_files: ToConvertFileHandler = ToConvertFileHandler(self.to_convert_dir)
self.metadata_files: MetadataFileHandler = MetadataFileHandler(self.metadata_dir)
self.httpd: Optional[socketserver.TCPServer] = None
self.observer: BaseObserver = Observer()
self.observer.schedule(
self,
self.converted_dir,
recursive=True,
event_filter=[FileDeletedEvent, FileMovedEvent, FileClosedEvent]
)
self.last_event: float = time.time()
self.http_handler_cls = partial(HTTPHandler, self)
def start(self):
self.observer.start()
try:
with socketserver.TCPServer(("", self.port), self.http_handler_cls) as self.httpd:
self.logger.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_deleted(self, event: FileDeletedEvent):
self.logger.info(f"Converted media deleted: {event.src_path}")
self.delete_metadata(event.src_path)
return super().on_deleted(event)
def on_moved(self, event: FileMovedEvent):
self.logger.info(f"Converted media moved: {event.src_path} -> {event.dest_path}")
self.rename_metadata(event.src_path, event.dest_path)
return super().on_moved(event)
def on_closed(self, event: FileClosedEvent):
self.logger.info(f"Converted media created or modified: {event.src_path}")
self.extract_metadata(event.src_path)
return super().on_closed(event)
def extract_metadata(self, path: str):
pass
def rename_metadata(self, src: str, dst: str):
pass
def delete_metadata(self, path: str):
pass

View File

@ -1,273 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import os
import subprocess
import sys
def read_metadata_json(json_file):
"""
Read metadata from a JSON file.
Args:
json_file (str): Path to the JSON file
Returns:
dict: Metadata information
"""
try:
with open(json_file, 'r', encoding='utf-8') as f:
metadata = json.load(f)
return metadata
except Exception as e:
print(f"❌ Error reading JSON file: {str(e)}")
return None
def write_metadata_to_video(metadata, input_file, output_file=None):
"""
Write metadata to a video file using mkvmerge.
Args:
metadata (dict): Metadata information
input_file (str): Path to the input video file
output_file (str, optional): Path to the output video file
Returns:
bool: True if successful, False otherwise
"""
if not os.path.isfile(input_file):
print(f"❌ Input file not found: {input_file}")
return False
if not output_file:
# Create a temporary output file
base_name, ext = os.path.splitext(input_file)
output_file = f"{base_name}_modified{ext}"
# Start building the mkvmerge command
cmd = ["mkvmerge", "-o", output_file]
# Add global metadata (title)
if "title" in metadata:
cmd.extend(["--title", metadata["title"]])
# Process audio tracks
for track in metadata.get("audio_tracks", []):
# Use the actual track index from the metadata
track_id = track.get("index", 0)
# Set language
if "language" in track:
cmd.extend([f"--language", f"{track_id}:{track['language']}"])
# Set title/name
if "name" in track and track["name"]:
cmd.extend([f"--track-name", f"{track_id}:{track['name']}"])
# Set disposition flags
flags = track.get("flags", {})
if flags.get("default", False):
cmd.extend([f"--default-track", f"{track_id}:yes"])
else:
cmd.extend([f"--default-track", f"{track_id}:no"])
if flags.get("forced", False):
cmd.extend([f"--forced-track", f"{track_id}:yes"])
else:
cmd.extend([f"--forced-track", f"{track_id}:no"])
if flags.get("original", False):
cmd.extend([f"--original-flag", f"{track_id}:yes"])
else:
cmd.extend([f"--original-flag", f"{track_id}:no"])
# Process subtitle tracks
for track in metadata.get("subtitle_tracks", []):
# Use the actual track index from the metadata
track_id = track.get("index", 0)
# Set language
if "language" in track:
cmd.extend([f"--language", f"{track_id}:{track['language']}"])
# Set title/name
if "name" in track and track["name"]:
cmd.extend([f"--track-name", f"{track_id}:{track['name']}"])
# Set disposition flags
flags = track.get("flags", {})
if flags.get("default", False):
cmd.extend([f"--default-track", f"{track_id}:yes"])
else:
cmd.extend([f"--default-track", f"{track_id}:no"])
if flags.get("forced", False):
cmd.extend([f"--forced-track", f"{track_id}:yes"])
else:
cmd.extend([f"--forced-track", f"{track_id}:no"])
if flags.get("original", False):
cmd.extend([f"--original-flag", f"{track_id}:yes"])
else:
cmd.extend([f"--original-flag", f"{track_id}:no"])
# Add input file
cmd.append(input_file)
# Execute the mkvmerge command
print(f"🔄 Writing metadata to {os.path.basename(output_file)}")
print(f"Command: {' '.join(cmd)}")
try:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
print(f"❌ Error writing metadata: {result.stderr}")
return False
print(f"✅ Metadata written to {output_file}")
return True
except Exception as e:
print(f"❌ Error executing mkvmerge: {str(e)}")
return False
def process_single_file(metadata, video_file, output_dir=None):
"""
Process a single video file with the given metadata.
Args:
metadata (dict): Metadata for the video file
video_file (str): Path to the video file
output_dir (str, optional): Directory to save the output file
Returns:
bool: True if successful, False otherwise
"""
if not os.path.isfile(video_file):
print(f"❌ Video file not found: {video_file}")
return False
# Create output file path
if output_dir:
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Use the same filename in the output directory
output_file = os.path.join(output_dir, os.path.basename(video_file))
else:
output_file = None # Let write_metadata_to_video create a default output file
# Write metadata to video
return write_metadata_to_video(metadata, video_file, output_file)
def process_directory(metadata_dict, source_dir, output_dir=None):
"""
Process all video files in the metadata dictionary.
Args:
metadata_dict (dict): Dictionary of metadata keyed by filename
source_dir (str): Directory containing the video files
output_dir (str, optional): Directory to save the output files
Returns:
bool: True if all files were processed successfully, False otherwise
"""
if not os.path.isdir(source_dir):
print(f"❌ Source directory not found: {source_dir}")
return False
# Create output directory if specified
if output_dir:
os.makedirs(output_dir, exist_ok=True)
success = True
processed_count = 0
# Process each file in the metadata dictionary
for filename, file_metadata in metadata_dict.items():
# Construct the full path to the video file
video_file = os.path.join(source_dir, filename)
if not os.path.isfile(video_file):
print(f"❌ Video file not found: {video_file}")
success = False
continue
# Process the file
if process_single_file(file_metadata, video_file, output_dir):
processed_count += 1
else:
success = False
print(f"✅ Processed {processed_count} out of {len(metadata_dict)} files")
return success
def main():
parser = argparse.ArgumentParser(description="Write metadata from JSON to video files.")
parser.add_argument("json_file", help="Path to input JSON metadata file")
parser.add_argument("-o", "--output", help="Path to output directory")
parser.add_argument("-s", "--source", help="Source directory (overrides automatic detection)")
args = parser.parse_args()
json_file = args.json_file
output_dir = args.output
source_dir = args.source
if not os.path.isfile(json_file):
print(f"❌ JSON file not found: {json_file}")
sys.exit(1)
# Read metadata from JSON
metadata = read_metadata_json(json_file)
if not metadata:
sys.exit(1)
# Determine if the JSON contains metadata for multiple files or a single file
is_multi_file = isinstance(metadata, dict) and all(isinstance(metadata[key], dict) for key in metadata)
# If source directory is not specified, try to determine it from the JSON filename
if not source_dir and is_multi_file:
# Extract folder name from JSON filename (e.g., "Millenium" from "Millenium_metadata.json")
json_basename = os.path.basename(json_file)
if "_metadata.json" in json_basename:
folder_name = json_basename.split("_metadata.json")[0]
potential_source_dir = os.path.join(os.path.dirname(os.path.abspath(json_file)), folder_name)
if os.path.isdir(potential_source_dir):
source_dir = potential_source_dir
print(f"📂 Using source directory: {source_dir}")
# If no output directory is specified, create one based on the source directory
if not output_dir and source_dir:
output_dir = os.path.join("ready", os.path.basename(source_dir))
print(f"📂 Using output directory: {output_dir}")
# Process files based on the metadata format
if is_multi_file:
if not source_dir:
print("❌ Source directory not specified and could not be determined automatically.")
print(" Please specify a source directory with --source or use a JSON filename like 'FolderName_metadata.json'")
sys.exit(1)
success = process_directory(metadata, source_dir, output_dir)
else:
# Single file metadata
if "filename" not in metadata:
print("❌ Invalid metadata format: missing 'filename' field")
sys.exit(1)
# If source directory is specified, look for the file there
if source_dir:
video_file = os.path.join(source_dir, metadata["filename"])
else:
# Look for the file in the same directory as the JSON
video_file = os.path.join(os.path.dirname(json_file), metadata["filename"])
success = process_single_file(metadata, video_file, output_dir)
if not success:
sys.exit(1)
if __name__ == "__main__":
main()