Compare commits

...

12 Commits

35 changed files with 2638 additions and 829 deletions

View File

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

4
.gitignore vendored
View File

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

View File

@ -1,7 +1,31 @@
FROM python:3.13.3-alpine
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 . .
CMD ["python3.13", "src/server.py"]
EXPOSE 8000
CMD ["python", "-m", "scripts.server"]

0
__init__.py Normal file
View File

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

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()

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>

View File

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

View File

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

View File

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

@ -1,3 +1,13 @@
@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;
@ -29,19 +39,17 @@ header {
gap: 0.8em;
color: white;
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;
&#header {
align-items: center;
&:hover {
background-color: #dbdbdb;
img.logo {
width: 4em;
height: 4em;
object-fit: contain;
&:hover {
animation: moon-pulse 2s alternate infinite linear;
}
}
}
}

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

@ -4,6 +4,27 @@ main {
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;
}

View File

@ -1,12 +1,14 @@
#files {
#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;
.file {
.page {
display: flex;
flex-direction: column;
align-items: center;
@ -20,10 +22,6 @@
padding: 0.4em;
border-radius: 1.2em;
&.hidden {
display: none;
}
&:hover {
background-color: #f8f8f8;
}
@ -33,64 +31,11 @@
height: 10em;
}
.title {
.name {
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;
}
}
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,107 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="conversion.svg"
inkscape:export-filename="icon3.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="8"
inkscape:cx="52.5625"
inkscape:cy="60.0625"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="16"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect18"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,2.2320508,0,1 @ F,0,1,1,0,2.2320508,0,1 @ F,0,1,1,0,2.2320508,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g13"
transform="matrix(1.21875,0,0,1.21875,-13.728742,-14)">
<path
id="path14"
style="fill:none;stroke:#000000;stroke-width:1.64103;stroke-linecap:round;stroke-linejoin:round"
d="m 30.956917,64 c 0,-18.126268 14.694243,-32.820511 32.820511,-32.820511 M 96.597939,64 c 0,18.126268 -14.694243,32.820511 -32.820511,32.820511"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:1.64103;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 57.213327,24.615384 6.564102,6.564103 -6.564102,6.564102"
id="path15" />
<path
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:1.64103;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 70.341532,103.38461 63.777429,96.820512 70.341532,90.25641"
id="path16" />
</g>
<path
id="path17"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 40 39 L 40 89 L 48 89 L 48 85 L 80 85 L 80 89 L 88 89 L 88 39 L 80 39 L 80 44 L 48 44 L 48 39 L 40 39 z M 42 44.5 L 46 44.5 L 46 47.5 L 42 47.5 L 42 44.5 z M 82 44.5 L 86 44.5 L 86 47.5 L 82 47.5 L 82 44.5 z M 48 48 L 80 48 L 80 80 L 48 80 L 48 48 z M 42 50.5 L 46 50.5 L 46 53.5 L 42 53.5 L 42 50.5 z M 82 50.5 L 86 50.5 L 86 53.5 L 82 53.5 L 82 50.5 z M 42 56.5 L 46 56.5 L 46 59.5 L 42 59.5 L 42 56.5 z M 82 56.5 L 86 56.5 L 86 59.5 L 82 59.5 L 82 56.5 z M 42 62.5 L 46 62.5 L 46 65.5 L 42 65.5 L 42 62.5 z M 82 62.5 L 86 62.5 L 86 65.5 L 82 65.5 L 82 62.5 z M 42 68.5 L 46 68.5 L 46 71.5 L 42 71.5 L 42 68.5 z M 82 68.5 L 86 68.5 L 86 71.5 L 82 71.5 L 82 68.5 z M 42 74.5 L 46 74.5 L 46 77.5 L 42 77.5 L 42 74.5 z M 82 74.5 L 86 74.5 L 86 77.5 L 82 77.5 L 82 74.5 z M 42 80.5 L 46 80.5 L 46 83.5 L 42 83.5 L 42 80.5 z M 82 80.5 L 86 80.5 L 86 83.5 L 82 83.5 L 82 80.5 z " />
<path
id="path18"
style="stroke-width:2;stroke-linecap:round;stroke-linejoin:round"
inkscape:transform-center-x="-2.25"
d="m 71.066987,65.116025 -9.633974,5.562179 A 1.2886751,1.2886751 29.999999 0 1 59.5,69.562178 V 58.437822 a 1.2886751,1.2886751 150 0 1 1.933013,-1.116026 l 9.633974,5.562179 a 1.2886752,1.2886752 90 0 1 0,2.23205 z"
inkscape:path-effect="#path-effect18"
inkscape:original-d="M 73,64 59.5,71.794229 V 56.205771 Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="icon3.svg"
inkscape:export-filename="icon3.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="4"
inkscape:cx="100.625"
inkscape:cy="71.125"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="g13">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="16"
enabled="true"
visible="false" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g13"
transform="matrix(1.21875,0,0,1.21875,-13.728742,-14)">
<circle
style="fill:#d7d7d7;fill-opacity:1;stroke:none;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
id="path8"
cx="64"
cy="64"
r="48" />
<g
id="g6"
transform="matrix(0.94549245,0.64625754,-0.64625754,0.94549245,16.322685,-59.513608)"
inkscape:transform-center-x="21.678799"
inkscape:transform-center-y="-15.571769">
<path
style="fill:#cb5739;fill-opacity:1;stroke:#434343;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 48,56 v 16 h 32 l 8,-8 -8,-8 z"
id="path2" />
<g
id="g5"
transform="translate(2.5)"
style="stroke:#434343;stroke-opacity:1">
<path
style="fill:none;stroke:#434343;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 57,60 -4,4 4,4"
id="path3" />
<path
style="fill:none;stroke:#434343;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 61,68 5,-8"
id="path4" />
<path
style="fill:none;stroke:#434343;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 70,60 4,4 -4,4"
id="path5" />
</g>
</g>
<g
id="g14"
inkscape:transform-center-x="-7.5111916"
inkscape:transform-center-y="2.6415215"
transform="matrix(1.1647434,0,0,1.1647434,-10.506912,-10.543579)">
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40891602;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="m 75.224968,50.967861 c 0,-0.542977 2.520211,-2.301228 4.451793,-2.109964 1.931582,0.191264 2.893913,2.267985 4.451792,3.230807 1.55788,0.962823 4.451793,1.235924 4.451793,1.847019 0,0.611095 -2.828267,0.995112 -3.929854,0.735062 -1.101587,-0.26005 -2.00587,-0.735062 -2.00587,-0.735062 0,0 -0.992464,4.709936 -2.967861,4.451792 -1.975397,-0.258143 -1.877599,-5.084776 -1.483931,-7.419654 -0.959231,0.180342 -2.967862,0.542978 -2.967862,0 z"
id="path9"
sodipodi:nodetypes="zzzzzczcz" />
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40891602;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="M 48.202298,89.998339 C 46.622528,88.418569 54.521379,83.679258 64,83.679258 c 9.478621,0 17.377472,4.739311 15.797702,6.319081 -1.579771,1.57977 -4.048161,-3.15954 -15.797702,-3.15954 -11.749541,0 -14.217931,4.73931 -15.797702,3.15954 z"
id="path10"
sodipodi:nodetypes="zzzzz" />
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40892;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 64,90.410017 c 1.57977,0 4.73931,0 4.73931,1.57977 0,1.579771 -3.15954,1.579771 -4.73931,1.579771 -1.57977,0 -4.73931,0 -4.73931,-1.579771 0,-1.57977 3.15954,-1.57977 4.73931,-1.57977 z"
id="path11"
sodipodi:nodetypes="zzzzz" />
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40891602;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="m 70.683501,47.557279 c 0.05473,-1.03795 1.098384,-1.720698 2.191237,-3.200114 1.092852,-1.479415 3.628351,-4.908029 6.407696,-5.223061 2.779343,-0.315033 13.286146,0.908682 13.91289,1.810272 0.847011,1.218452 -12.113125,-2.037935 -15.945302,1.886694 -3.832177,3.924627 -6.621251,5.76416 -6.566521,4.726209 z"
id="path12"
sodipodi:nodetypes="zzzszz" />
<path
style="fill:#434343;fill-opacity:1;stroke:#434343;stroke-width:1.40891602;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="m 45,80 c 1,0 7,-9 9,-9 2,0 7,3 10,3 3,0 8,-3 10,-3 2,0 8,9 9,9 1,0 -6,-11 -7,-12 -1,-1 -11,4 -12,4 -1,0 -11,-5 -12,-4 -1,1 -8,12 -7,12 z"
id="path13"
sodipodi:nodetypes="zzzzzzzzz" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="metadata.svg"
inkscape:export-filename="icon3.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="5.6568543"
inkscape:cx="56.303377"
inkscape:cy="65.672542"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="g13">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="16"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g13"
transform="matrix(1.21875,0,0,1.21875,-13.728742,-14)">
<g
id="g6"
transform="matrix(1.7902099,0,0,1.7902099,-54.376425,-50.573434)"
inkscape:transform-center-x="-4.3636355"
style="stroke:#000000;stroke-opacity:1;fill:none;fill-opacity:1;stroke-width:0.91666661;stroke-dasharray:none">
<path
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.91666661;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;stroke-dasharray:none"
d="m 48,56 v 16 h 32 l 8,-8 -8,-8 z"
id="path2" />
<g
id="g5"
transform="translate(2.5)"
style="stroke:#000000;stroke-opacity:1;fill:none;fill-opacity:1;stroke-width:0.91666661;stroke-dasharray:none">
<path
style="fill:none;stroke:#000000;stroke-width:0.91666661;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;fill-opacity:1;stroke-dasharray:none"
d="m 57,60 -4,4 4,4"
id="path3" />
<path
style="fill:none;stroke:#000000;stroke-width:0.91666661;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;fill-opacity:1;stroke-dasharray:none"
d="m 61,68 5,-8"
id="path4" />
<path
style="fill:none;stroke:#000000;stroke-width:0.91666661;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1;fill-opacity:1;stroke-dasharray:none"
d="m 70,60 4,4 -4,4"
id="path5" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,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

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

View File

@ -224,7 +224,7 @@ export default class IntegrityManager {
if (parts.includes("pgs")) {
fields.type = "PGS"
} else {
} else if (parts.includes("srt")) {
fields.type = "SRT"
}
break
@ -316,7 +316,9 @@ export default class IntegrityManager {
if (fields.flags.hearing_impaired) {
name += " SDH"
}
name += " | " + fields.type
if (fields.type) {
name += " | " + fields.type
}
break
}
return 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

@ -119,6 +119,7 @@ export class Track {
input.value = value
break
default:
break

242
src/server.py Executable file → Normal file
View File

@ -1,56 +1,52 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
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
# 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)
from src.file_handlers import ToConvertFileHandler, MetadataFileHandler
class MyHandler(SimpleHTTPRequestHandler):
MAX_PAYLOAD_SIZE = 1e6
DATA_DIR = "metadata"
CACHE = {}
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
def __init__(self, *args, **kwargs):
super().__init__(
*args,
directory="public",
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.MAX_PAYLOAD_SIZE:
if size > self.server_.max_payload_size:
self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
self.log_error(f"Payload is too big ({self.MAX_PAYLOAD_SIZE=}B)")
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)
@ -61,16 +57,20 @@ class MyHandler(SimpleHTTPRequestHandler):
return True
def do_GET(self):
self.path = unquote(self.path)
self.query = parse_qs(urlparse(self.path).query)
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):
self.path = unquote(self.path)
self.query = parse_qs(urlparse(self.path).query)
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
@ -78,12 +78,18 @@ class MyHandler(SimpleHTTPRequestHandler):
def handle_api_get(self, path: str):
self.log_message(f"API request at {path}")
if path == "files":
files: list[str] = self.get_files_meta()
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.read_file(filename)
data = self.metadata_files.read(filename)
if data is None:
self.send_error(HTTPStatus.NOT_FOUND)
else:
@ -96,9 +102,11 @@ class MyHandler(SimpleHTTPRequestHandler):
if path.startswith("file"):
if self.read_body_data():
filename: str = path.split("/", 1)[1]
if self.write_file(filename, self.data):
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()
@ -109,107 +117,81 @@ class MyHandler(SimpleHTTPRequestHandler):
self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8"))
def get_files(self):
return os.listdir(self.DATA_DIR)
def read_file(self, filename: str) -> Optional[dict|list]:
if filename not in self.get_files():
return None
with open(os.path.join(self.DATA_DIR, filename), "r") as f:
data = json.load(f)
return data
class MeliesServer(FileSystemEventHandler):
def __init__(
self,
port: int,
to_convert_dir: str,
converted_dir: str,
metadata_dir: str,
max_payload_size: int):
def write_file(self, filename: str, data: dict|list) -> bool:
if filename not in self.get_files():
self.send_error(HTTPStatus.NOT_FOUND)
return False
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 open(os.path.join(self.DATA_DIR, filename), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except:
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
return False
return True
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 get_files_meta(self):
files: list[str] = self.get_files()
files_meta: list[dict] = []
def stop(self):
self.observer.stop()
self.observer.join()
deleted = set(self.CACHE.keys()) - set(files)
for filename in deleted:
del self.CACHE[deleted]
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)
for filename in files:
path: str = os.path.join(self.DATA_DIR, filename)
last_modified: float = os.path.getmtime(path)
if filename not in self.CACHE or self.CACHE[filename]["ts"] < last_modified:
self.update_file_meta(filename)
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)
files_meta.append(self.CACHE[filename])
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)
return files_meta
def extract_metadata(self, path: str):
pass
def update_file_meta(self, filename: str):
path: str = os.path.join(self.DATA_DIR, filename)
def rename_metadata(self, src: str, dst: str):
pass
meta = {
"filename": filename,
"ts": os.path.getmtime(path)
}
with open(path, "r") as f:
data = json.load(f)
is_series = "filename" not in data
meta["type"] = "series" if is_series else "film"
if is_series:
meta["episodes"] = len(data)
meta["title"] = filename.split("_metadata")[0]
else:
meta["title"] = data["title"]
self.CACHE[filename] = meta
def main():
parser = argparse.ArgumentParser(
description="Starts the Melies server",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument(
"-p", "--port",
action=EnvDefault,
envvar="MELIES_PORT",
default=8000,
type=int,
help="Port on which the server listens"
)
parser.add_argument(
"--max-payload-size",
action=EnvDefault,
envvar="MELIES_MAX_PAYLOAD_SIZE",
default=1e6,
type=int,
help="Maximum POST payload size in bytes that the server accepts"
)
parser.add_argument(
"--metadata-dir",
action=EnvDefault,
envvar="MELIES_METADATA_DIR",
default="metadata",
help="Path to the directory containing metadata files"
)
args = parser.parse_args()
port = args.port
MyHandler.MAX_PAYLOAD_SIZE = args.max_payload_size
MyHandler.DATA_DIR = args.metadata_dir
with socketserver.TCPServer(("", port), MyHandler) as httpd:
print(f"Serving on port {port}")
httpd.serve_forever()
if __name__ == "__main__":
main()
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()