Compare commits
9 Commits
main
...
51e01139ed
Author | SHA1 | Date | |
---|---|---|---|
51e01139ed
|
|||
6a0669df4a
|
|||
bdf4d4810e
|
|||
a7c8bc8690
|
|||
4777ede23a
|
|||
e7e928b25f
|
|||
496390ef9b
|
|||
d87298e0b3
|
|||
ecefc7931a
|
@ -1,5 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.git
|
|
||||||
.env
|
|
||||||
metadata
|
|
1
.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
metadata
|
|
@ -1,7 +0,0 @@
|
|||||||
FROM python:3.13.3-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
CMD ["python3.13", "src/server.py"]
|
|
1
editor/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
metadata/
|
28
editor/public/index.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Metadata Editor</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/index.css">
|
||||||
|
<script src="/static/js/index.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Metadata Editor</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<a id="film-template" class="template file film">
|
||||||
|
<img src="/static/images/film.svg">
|
||||||
|
<div class="title"></div>
|
||||||
|
</a>
|
||||||
|
<a id="series-template" class="template file series">
|
||||||
|
<img src="/static/images/series.svg">
|
||||||
|
<div class="title"></div>
|
||||||
|
<div class="episodes"><span class="num"></span> episode(s)</div>
|
||||||
|
</a>
|
||||||
|
<div id="files"></div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -338,11 +338,9 @@ button.improve {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 30em;
|
max-width: 30em;
|
||||||
transform: translateX(0%);
|
|
||||||
transition: transform 0.5s cubic-bezier(0.22, 0.61, 0.36, 1);
|
|
||||||
|
|
||||||
&:not(.show) {
|
&:not(.show) {
|
||||||
transform: translateX(100%);
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#close-notifs {
|
#close-notifs {
|
37
editor/public/static/css/index.css
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
#files {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
|
||||||
|
grid-auto-rows: 15em;
|
||||||
|
gap: 0.8em;
|
||||||
|
place-items: center;
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 10em;
|
||||||
|
height: 10em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
59
editor/public/static/js/index.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
fetch("/api/files").then(res => {
|
||||||
|
return res.json()
|
||||||
|
}).then(files => {
|
||||||
|
addFiles(files)
|
||||||
|
})
|
||||||
|
})
|
5
editor/public/static/js/media_file.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default class MediaFile {
|
||||||
|
constructor(data) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
75
src/server.py → editor/server.py
Executable file → Normal file
@ -1,38 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
from http import HTTPStatus
|
||||||
|
from http.server import SimpleHTTPRequestHandler
|
||||||
import argparse
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socketserver
|
import socketserver
|
||||||
from http import HTTPStatus
|
|
||||||
from http.server import SimpleHTTPRequestHandler
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
from urllib.parse import urlparse, parse_qs, unquote
|
||||||
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
PORT = 8000
|
||||||
|
MAX_SIZE = 10e6
|
||||||
|
|
||||||
class MyHandler(SimpleHTTPRequestHandler):
|
class MyHandler(SimpleHTTPRequestHandler):
|
||||||
MAX_PAYLOAD_SIZE = 1e6
|
|
||||||
DATA_DIR = "metadata"
|
DATA_DIR = "metadata"
|
||||||
CACHE = {}
|
CACHE = {}
|
||||||
|
|
||||||
@ -48,9 +25,9 @@ class MyHandler(SimpleHTTPRequestHandler):
|
|||||||
def read_body_data(self):
|
def read_body_data(self):
|
||||||
try:
|
try:
|
||||||
size: int = int(self.headers["Content-Length"])
|
size: int = int(self.headers["Content-Length"])
|
||||||
if size > self.MAX_PAYLOAD_SIZE:
|
if size > MAX_SIZE:
|
||||||
self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
|
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 ({MAX_SIZE=}B)")
|
||||||
return False
|
return False
|
||||||
raw_data = self.rfile.read(size)
|
raw_data = self.rfile.read(size)
|
||||||
self.data = json.loads(raw_data)
|
self.data = json.loads(raw_data)
|
||||||
@ -172,42 +149,8 @@ class MyHandler(SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
|
||||||
description="Starts the Melies server",
|
print(f"Serving on port {PORT}")
|
||||||
formatter_class=argparse.RawTextHelpFormatter
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-p", "--port",
|
|
||||||
action=EnvDefault,
|
|
||||||
envvar="MELIES_PORT",
|
|
||||||
default=8000,
|
|
||||||
type=int,
|
|
||||||
help="Port on which the server listens"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--max-payload-size",
|
|
||||||
action=EnvDefault,
|
|
||||||
envvar="MELIES_MAX_PAYLOAD_SIZE",
|
|
||||||
default=1e6,
|
|
||||||
type=int,
|
|
||||||
help="Maximum POST payload size in bytes that the server accepts"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--metadata-dir",
|
|
||||||
action=EnvDefault,
|
|
||||||
envvar="MELIES_METADATA_DIR",
|
|
||||||
default="metadata",
|
|
||||||
help="Path to the directory containing metadata files"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
port = args.port
|
|
||||||
MyHandler.MAX_PAYLOAD_SIZE = args.max_payload_size
|
|
||||||
MyHandler.DATA_DIR = args.metadata_dir
|
|
||||||
|
|
||||||
with socketserver.TCPServer(("", port), MyHandler) as httpd:
|
|
||||||
print(f"Serving on port {port}")
|
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
@ -55,6 +55,7 @@ def encode(input_file, codec, remove_source=False, save_log=False):
|
|||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-i", input_file,
|
"-i", input_file,
|
||||||
"-map", "0",
|
"-map", "0",
|
||||||
|
"-cq", str(cq),
|
||||||
] + extra_params + [
|
] + extra_params + [
|
||||||
"-c:v", ffmpeg_codec,
|
"-c:v", ffmpeg_codec,
|
||||||
"-preset", "p4",
|
"-preset", "p4",
|
@ -55,6 +55,8 @@ def get_video_metadata(file_path):
|
|||||||
"channels": stream.get("channels", 0),
|
"channels": stream.get("channels", 0),
|
||||||
"flags": {
|
"flags": {
|
||||||
"default": stream.get("disposition", {}).get("default", 0) == 1,
|
"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,
|
||||||
"visual_impaired": stream.get("disposition", {}).get("visual_impaired", 0) == 1,
|
"visual_impaired": stream.get("disposition", {}).get("visual_impaired", 0) == 1,
|
||||||
"original": stream.get("disposition", {}).get("original", 0) == 1,
|
"original": stream.get("disposition", {}).get("original", 0) == 1,
|
||||||
"commentary": stream.get("disposition", {}).get("comment", 0) == 1
|
"commentary": stream.get("disposition", {}).get("comment", 0) == 1
|
||||||
@ -71,6 +73,7 @@ def get_video_metadata(file_path):
|
|||||||
"default": stream.get("disposition", {}).get("default", 0) == 1,
|
"default": stream.get("disposition", {}).get("default", 0) == 1,
|
||||||
"forced": stream.get("disposition", {}).get("forced", 0) == 1,
|
"forced": stream.get("disposition", {}).get("forced", 0) == 1,
|
||||||
"hearing_impaired": stream.get("disposition", {}).get("hearing_impaired", 0) == 1,
|
"hearing_impaired": stream.get("disposition", {}).get("hearing_impaired", 0) == 1,
|
||||||
|
"visual_impaired": stream.get("disposition", {}).get("visual_impaired", 0) == 1,
|
||||||
"original": stream.get("disposition", {}).get("original", 0) == 1,
|
"original": stream.get("disposition", {}).get("original", 0) == 1,
|
||||||
"commentary": stream.get("disposition", {}).get("comment", 0) == 1
|
"commentary": stream.get("disposition", {}).get("comment", 0) == 1
|
||||||
}
|
}
|
||||||
@ -83,13 +86,13 @@ def get_video_metadata(file_path):
|
|||||||
print(f"❌ Error processing {file_path}: {str(e)}")
|
print(f"❌ Error processing {file_path}: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def process_file(file_path, output_dir=None):
|
def process_file(file_path, output_file=None):
|
||||||
"""
|
"""
|
||||||
Process a single video file and write metadata to JSON.
|
Process a single video file and write metadata to JSON.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path (str): Path to the video file
|
file_path (str): Path to the video file
|
||||||
output_dir (str, optional): Directory where the output JSON file will be saved
|
output_file (str, optional): Path to output JSON file
|
||||||
"""
|
"""
|
||||||
if not os.path.isfile(file_path):
|
if not os.path.isfile(file_path):
|
||||||
print(f"❌ File not found: {file_path}")
|
print(f"❌ File not found: {file_path}")
|
||||||
@ -103,34 +106,27 @@ def process_file(file_path, output_dir=None):
|
|||||||
metadata = get_video_metadata(file_path)
|
metadata = get_video_metadata(file_path)
|
||||||
|
|
||||||
if metadata:
|
if metadata:
|
||||||
# Generate output filename based on input file
|
if not output_file:
|
||||||
filename = os.path.basename(os.path.splitext(file_path)[0]) + "_metadata.json"
|
# Generate output filename based on input file
|
||||||
|
|
||||||
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]
|
base_name = os.path.splitext(file_path)[0]
|
||||||
output_path = f"{base_name}_metadata.json"
|
output_file = f"{base_name}_metadata.json"
|
||||||
|
|
||||||
# Write metadata to JSON file
|
# Write metadata to JSON file
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
print(f"✅ Metadata saved to {output_path}")
|
print(f"✅ Metadata saved to {output_file}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def process_directory(directory_path, output_dir=None):
|
def process_directory(directory_path, output_file=None):
|
||||||
"""
|
"""
|
||||||
Process all video files in a directory and write metadata to JSON.
|
Process all video files in a directory and write metadata to JSON.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory_path (str): Path to the directory
|
directory_path (str): Path to the directory
|
||||||
output_dir (str, optional): Directory where the output JSON file will be saved
|
output_file (str, optional): Path to output JSON file
|
||||||
"""
|
"""
|
||||||
if not os.path.isdir(directory_path):
|
if not os.path.isdir(directory_path):
|
||||||
print(f"❌ Directory not found: {directory_path}")
|
print(f"❌ Directory not found: {directory_path}")
|
||||||
@ -156,38 +152,31 @@ def process_directory(directory_path, output_dir=None):
|
|||||||
print(f"❌ No supported video files found in {directory_path}")
|
print(f"❌ No supported video files found in {directory_path}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Generate output filename based on directory name
|
if not output_file:
|
||||||
dir_name = os.path.basename(os.path.normpath(directory_path))
|
# Generate output filename based on directory name
|
||||||
filename = f"{dir_name}_metadata.json"
|
dir_name = os.path.basename(os.path.normpath(directory_path))
|
||||||
|
output_file = 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
|
# Write all metadata to a single JSON file
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
with open(output_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump(all_metadata, f, indent=2, ensure_ascii=False)
|
json.dump(all_metadata, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
print(f"✅ Metadata for {file_count} files saved to {output_path}")
|
print(f"✅ Metadata for {file_count} files saved to {output_file}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Extract metadata from video files and save as JSON.")
|
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("input", help="Path to input video file or directory")
|
||||||
parser.add_argument("-o", "--output", help="Directory path where output JSON files will be saved")
|
parser.add_argument("-o", "--output", help="Path to output JSON file")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
input_path = args.input
|
input_path = args.input
|
||||||
output_dir = args.output
|
output_file = args.output
|
||||||
|
|
||||||
if os.path.isfile(input_path):
|
if os.path.isfile(input_path):
|
||||||
process_file(input_path, output_dir)
|
process_file(input_path, output_file)
|
||||||
elif os.path.isdir(input_path):
|
elif os.path.isdir(input_path):
|
||||||
process_directory(input_path, output_dir)
|
process_directory(input_path, output_file)
|
||||||
else:
|
else:
|
||||||
print(f"❌ Path not found: {input_path}")
|
print(f"❌ Path not found: {input_path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
@ -1,53 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Metadata Editor</title>
|
|
||||||
<link rel="stylesheet" href="/static/css/base.css">
|
|
||||||
<link rel="stylesheet" href="/static/css/index.css">
|
|
||||||
<script src="/static/js/index.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>Metadata Editor</h1>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<a id="film-template" class="template file film">
|
|
||||||
<img src="/static/images/film.svg">
|
|
||||||
<div class="title"></div>
|
|
||||||
</a>
|
|
||||||
<a id="series-template" class="template file series">
|
|
||||||
<img src="/static/images/series.svg">
|
|
||||||
<div class="title"></div>
|
|
||||||
<div class="episodes"><span class="num"></span> episode(s)</div>
|
|
||||||
</a>
|
|
||||||
<div class="toolbar">
|
|
||||||
<div class="tool">
|
|
||||||
<label for="sort-by">Sort by</label>
|
|
||||||
<select id="sort-by">
|
|
||||||
<option value="title">Title</option>
|
|
||||||
<option value="ts">Last modified</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="tool">
|
|
||||||
<label for="sort-desc">Order</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" id="sort-desc">
|
|
||||||
<div class="off">ASC</div>
|
|
||||||
<div class="on">DESC</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="tool">
|
|
||||||
<label for="filter">Filter</label>
|
|
||||||
<select id="filter">
|
|
||||||
<option value="all">All</option>
|
|
||||||
<option value="film">Films</option>
|
|
||||||
<option value="series">Series</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="files"></div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,96 +0,0 @@
|
|||||||
#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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,128 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
||||||
|
|
||||||
<svg
|
|
||||||
width="128"
|
|
||||||
height="128"
|
|
||||||
viewBox="0 0 128 128"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1"
|
|
||||||
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
|
||||||
sodipodi:docname="collection.svg"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview1"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#000000"
|
|
||||||
borderopacity="0.25"
|
|
||||||
inkscape:showpageshadow="2"
|
|
||||||
inkscape:pageopacity="0.0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
inkscape:deskcolor="#d1d1d1"
|
|
||||||
inkscape:document-units="mm"
|
|
||||||
showgrid="true"
|
|
||||||
inkscape:zoom="5.6568543"
|
|
||||||
inkscape:cx="69.384853"
|
|
||||||
inkscape:cy="46.050329"
|
|
||||||
inkscape:window-width="1920"
|
|
||||||
inkscape:window-height="1016"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="27"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="g8-2">
|
|
||||||
<inkscape:grid
|
|
||||||
id="grid1"
|
|
||||||
units="px"
|
|
||||||
originx="0"
|
|
||||||
originy="0"
|
|
||||||
spacingx="1"
|
|
||||||
spacingy="0.99999998"
|
|
||||||
empcolor="#0099e5"
|
|
||||||
empopacity="0.30196078"
|
|
||||||
color="#0099e5"
|
|
||||||
opacity="0.14901961"
|
|
||||||
empspacing="16"
|
|
||||||
enabled="true"
|
|
||||||
visible="true" />
|
|
||||||
</sodipodi:namedview>
|
|
||||||
<defs
|
|
||||||
id="defs1">
|
|
||||||
<inkscape:path-effect
|
|
||||||
effect="fillet_chamfer"
|
|
||||||
id="path-effect10"
|
|
||||||
is_visible="true"
|
|
||||||
lpeversion="1"
|
|
||||||
nodesatellites_param="F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,1,1,0,5,0,1 @ F,0,1,1,0,5,0,1"
|
|
||||||
radius="0"
|
|
||||||
unit="px"
|
|
||||||
method="auto"
|
|
||||||
mode="F"
|
|
||||||
chamfer_steps="1"
|
|
||||||
flexible="false"
|
|
||||||
use_knot_distance="true"
|
|
||||||
apply_no_radius="true"
|
|
||||||
apply_with_radius="true"
|
|
||||||
only_selected="false"
|
|
||||||
hide_knots="false" />
|
|
||||||
<inkscape:path-effect
|
|
||||||
effect="fillet_chamfer"
|
|
||||||
id="path-effect9"
|
|
||||||
is_visible="true"
|
|
||||||
lpeversion="1"
|
|
||||||
nodesatellites_param="F,0,1,1,0,0,0,1 @ F,0,0,1,0,8.0000004,0,1 @ F,0,1,1,0,8.0000004,0,1 @ F,0,1,1,0,0,0,1"
|
|
||||||
radius="0"
|
|
||||||
unit="px"
|
|
||||||
method="auto"
|
|
||||||
mode="F"
|
|
||||||
chamfer_steps="1"
|
|
||||||
flexible="false"
|
|
||||||
use_knot_distance="true"
|
|
||||||
apply_no_radius="true"
|
|
||||||
apply_with_radius="true"
|
|
||||||
only_selected="false"
|
|
||||||
hide_knots="false" />
|
|
||||||
<inkscape:path-effect
|
|
||||||
effect="fillet_chamfer"
|
|
||||||
id="path-effect8"
|
|
||||||
is_visible="true"
|
|
||||||
lpeversion="1"
|
|
||||||
nodesatellites_param="F,0,1,1,0,5,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,8,0,1 @ F,0,1,1,0,5,0,1 @ F,0,1,1,0,5,0,1"
|
|
||||||
radius="0"
|
|
||||||
unit="px"
|
|
||||||
method="auto"
|
|
||||||
mode="F"
|
|
||||||
chamfer_steps="1"
|
|
||||||
flexible="false"
|
|
||||||
use_knot_distance="true"
|
|
||||||
apply_no_radius="true"
|
|
||||||
apply_with_radius="true"
|
|
||||||
only_selected="false"
|
|
||||||
hide_knots="false" />
|
|
||||||
</defs>
|
|
||||||
<g
|
|
||||||
inkscape:label="Layer 1"
|
|
||||||
inkscape:groupmode="layer"
|
|
||||||
id="layer1">
|
|
||||||
<g
|
|
||||||
id="g8-2"
|
|
||||||
transform="rotate(7.9490233,62.107327,266.90842)">
|
|
||||||
<g
|
|
||||||
id="g10"
|
|
||||||
transform="translate(-12.771862,-9.814157)">
|
|
||||||
<path
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
|
||||||
d="M 83.459997,59.049888 82.353661,51.126757 C 81.742649,46.750932 77.700018,43.698948 73.324193,44.30996 L 38.66049,49.15018 C 35.925599,49.532063 33.398955,47.624573 33.017073,44.889682 32.39989,42.466468 29.93516,41.002392 27.511947,41.619576 L 8.6945086,44.247124 C 4.3186836,44.858137 1.2667,48.900767 1.8777122,53.276592 L 9.760356,109.72891 c 0.611016,4.37582 4.653645,7.4278 9.029468,6.81679 l 63.385056,-8.85068 c 3.853961,-0.53814 6.681016,-3.7382 6.883076,-7.48335"
|
|
||||||
id="path8"
|
|
||||||
sodipodi:nodetypes="ccccccsccccc" />
|
|
||||||
<path
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
|
||||||
d="M 9.6220641,108.73852 11.643471,76.990746 a 9.8076021,9.8076021 132.84706 0 1 8.431469,-9.09017 l 63.385056,-8.850688 a 6.5830057,6.5830057 42.600843 0 1 7.483432,6.881572 l -1.886976,34.28013"
|
|
||||||
id="path9"
|
|
||||||
sodipodi:nodetypes="cccc" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 4.5 KiB |
@ -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())
|
|
||||||
})
|
|