Compare commits
12 Commits
main
...
feat/docke
Author | SHA1 | Date | |
---|---|---|---|
04dd2309c6 | |||
958a55ff17 | |||
7061f8d64c | |||
4b6576ec53 | |||
7a49849ee9 | |||
ffe847fb5e | |||
775d3da6ed | |||
460ae94925 | |||
4a4949b474 | |||
371fb9042d | |||
be91526c90 | |||
7a3fa8c309 |
@ -2,4 +2,6 @@ __pycache__/
|
||||
*.pyc
|
||||
.git
|
||||
.env
|
||||
metadata
|
||||
/to_convert/
|
||||
/converted/
|
||||
/metadata/
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1 +1,3 @@
|
||||
metadata
|
||||
/to_convert/
|
||||
/converted/
|
||||
/metadata/
|
28
Dockerfile
28
Dockerfile
@ -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
0
__init__.py
Normal file
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
watchdog==6.0.0
|
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
47
scripts/extract_metadata.py
Executable file
47
scripts/extract_metadata.py
Executable 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
71
scripts/server.py
Executable 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
45
scripts/write_metadata.py
Normal 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
0
src/__init__.py
Normal file
25
src/env_default.py
Normal file
25
src/env_default.py
Normal 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
106
src/file_handlers.py
Normal 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
|
@ -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
191
src/metadata_extractor.py
Normal 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
286
src/metadata_writer.py
Normal 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
|
68
src/public/conversion/index.html
Normal file
68
src/public/conversion/index.html
Normal 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>
|
@ -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>
|
@ -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>
|
55
src/public/metadata/index.html
Normal file
55
src/public/metadata/index.html
Normal 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>
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
265
src/public/static/css/conversion.css
Normal file
265
src/public/static/css/conversion.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
96
src/public/static/css/metadata.css
Normal file
96
src/public/static/css/metadata.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
498
src/public/static/images/agent.svg
Normal file
498
src/public/static/images/agent.svg
Normal 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 |
107
src/public/static/images/conversion.svg
Normal file
107
src/public/static/images/conversion.svg
Normal 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 |
129
src/public/static/images/icon3.svg
Normal file
129
src/public/static/images/icon3.svg
Normal 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 |
92
src/public/static/images/metadata.svg
Normal file
92
src/public/static/images/metadata.svg
Normal 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 |
227
src/public/static/js/conversion.js
Normal file
227
src/public/static/js/conversion.js
Normal 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()
|
||||
})
|
@ -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())
|
||||
})
|
@ -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
|
||||
|
107
src/public/static/js/metadata.js
Normal file
107
src/public/static/js/metadata.js
Normal 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())
|
||||
})
|
@ -119,6 +119,7 @@ export class Track {
|
||||
|
||||
|
||||
input.value = value
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
|
250
src/server.py
Executable file → Normal file
250
src/server.py
Executable file → Normal 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 = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
class HTTPHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, server: MeliesServer, *args, **kwargs):
|
||||
self.server_: MeliesServer = server
|
||||
self.to_convert_files: ToConvertFileHandler = self.server_.to_convert_files
|
||||
self.metadata_files: MetadataFileHandler = self.server_.metadata_files
|
||||
|
||||
super().__init__(
|
||||
*args,
|
||||
directory="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()
|
||||
@ -108,108 +116,82 @@ class MyHandler(SimpleHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data).encode("utf-8"))
|
||||
|
||||
def get_files(self):
|
||||
return os.listdir(self.DATA_DIR)
|
||||
|
||||
def read_file(self, filename: str) -> Optional[dict|list]:
|
||||
if filename not in self.get_files():
|
||||
return None
|
||||
with open(os.path.join(self.DATA_DIR, filename), "r") as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
|
||||
def write_file(self, filename: str, data: dict|list) -> bool:
|
||||
if filename not in self.get_files():
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return False
|
||||
|
||||
class MeliesServer(FileSystemEventHandler):
|
||||
def __init__(
|
||||
self,
|
||||
port: int,
|
||||
to_convert_dir: str,
|
||||
converted_dir: str,
|
||||
metadata_dir: str,
|
||||
max_payload_size: int):
|
||||
|
||||
super().__init__()
|
||||
self.logger: Logger = logging.getLogger("MeliesServer")
|
||||
|
||||
self.port: int = port
|
||||
self.to_convert_dir: str = to_convert_dir
|
||||
self.converted_dir: str = converted_dir
|
||||
self.metadata_dir: str = metadata_dir
|
||||
self.max_payload_size: int = max_payload_size
|
||||
|
||||
if not os.path.exists(self.to_convert_dir):
|
||||
os.mkdir(self.to_convert_dir)
|
||||
if not os.path.exists(self.converted_dir):
|
||||
os.mkdir(self.converted_dir)
|
||||
if not os.path.exists(self.metadata_dir):
|
||||
os.mkdir(self.metadata_dir)
|
||||
|
||||
self.to_convert_files: ToConvertFileHandler = ToConvertFileHandler(self.to_convert_dir)
|
||||
self.metadata_files: MetadataFileHandler = MetadataFileHandler(self.metadata_dir)
|
||||
|
||||
self.httpd: Optional[socketserver.TCPServer] = None
|
||||
self.observer: BaseObserver = Observer()
|
||||
self.observer.schedule(
|
||||
self,
|
||||
self.converted_dir,
|
||||
recursive=True,
|
||||
event_filter=[FileDeletedEvent, FileMovedEvent, FileClosedEvent]
|
||||
)
|
||||
self.last_event: float = time.time()
|
||||
|
||||
self.http_handler_cls = partial(HTTPHandler, self)
|
||||
|
||||
def start(self):
|
||||
self.observer.start()
|
||||
try:
|
||||
with open(os.path.join(self.DATA_DIR, filename), "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
except:
|
||||
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_files_meta(self):
|
||||
files: list[str] = self.get_files()
|
||||
files_meta: list[dict] = []
|
||||
|
||||
deleted = set(self.CACHE.keys()) - set(files)
|
||||
for filename in deleted:
|
||||
del self.CACHE[deleted]
|
||||
|
||||
for filename in files:
|
||||
path: str = os.path.join(self.DATA_DIR, filename)
|
||||
last_modified: float = os.path.getmtime(path)
|
||||
if filename not in self.CACHE or self.CACHE[filename]["ts"] < last_modified:
|
||||
self.update_file_meta(filename)
|
||||
|
||||
files_meta.append(self.CACHE[filename])
|
||||
|
||||
return files_meta
|
||||
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 update_file_meta(self, filename: str):
|
||||
path: str = os.path.join(self.DATA_DIR, filename)
|
||||
|
||||
meta = {
|
||||
"filename": filename,
|
||||
"ts": os.path.getmtime(path)
|
||||
}
|
||||
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
is_series = "filename" not in data
|
||||
meta["type"] = "series" if is_series else "film"
|
||||
if is_series:
|
||||
meta["episodes"] = len(data)
|
||||
meta["title"] = filename.split("_metadata")[0]
|
||||
else:
|
||||
meta["title"] = data["title"]
|
||||
|
||||
self.CACHE[filename] = meta
|
||||
def stop(self):
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
|
||||
def on_deleted(self, event: FileDeletedEvent):
|
||||
self.logger.info(f"Converted media deleted: {event.src_path}")
|
||||
self.delete_metadata(event.src_path)
|
||||
return super().on_deleted(event)
|
||||
|
||||
def 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()
|
||||
def on_moved(self, event: FileMovedEvent):
|
||||
self.logger.info(f"Converted media moved: {event.src_path} -> {event.dest_path}")
|
||||
self.rename_metadata(event.src_path, event.dest_path)
|
||||
return super().on_moved(event)
|
||||
|
||||
def on_closed(self, event: FileClosedEvent):
|
||||
self.logger.info(f"Converted media created or modified: {event.src_path}")
|
||||
self.extract_metadata(event.src_path)
|
||||
return super().on_closed(event)
|
||||
|
||||
def extract_metadata(self, path: str):
|
||||
pass
|
||||
|
||||
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()
|
||||
def rename_metadata(self, src: str, dst: str):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
def delete_metadata(self, path: str):
|
||||
pass
|
||||
|
@ -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()
|
Loading…
x
Reference in New Issue
Block a user