17 Commits

Author SHA1 Message Date
6be8ece8d4 refactor: changed structure for containerization 2025-05-03 17:24:05 +02:00
770d3cd25b Merge pull request 'feat: Web metadata editor' (#3) from feat/web-editor into main
Reviewed-on: #3
2025-05-03 14:19:44 +00:00
658addae56 feat: add CLI arguments + env variables support 2025-05-03 16:07:24 +02:00
79cd7a32ed feat: add sorting and filtering in file list 2025-05-03 16:07:23 +02:00
02279b8c6f feat: rework home page file selector 2025-05-03 16:07:23 +02:00
ed3c6d7cc7 feat: add support for series 2025-05-03 16:07:23 +02:00
bc5371de71 feat: add toolbar + notifications 2025-05-03 16:07:22 +02:00
2afecd1c04 feat: add improve name button 2025-05-03 16:07:22 +02:00
8fbe5ae3c4 fix: add gitignore 2025-05-03 16:07:21 +02:00
acf7b5047f feat: add basic integrity checks + corrections 2025-05-03 16:07:21 +02:00
82d02cfe76 refactor: split editor in JS modules 2025-05-03 16:07:21 +02:00
d19ab90f38 feat: add saving 2025-05-03 16:07:20 +02:00
477e8951a9 feat: add hotone bool 2025-05-03 16:07:20 +02:00
5110f41152 Merge pull request 'fix/improve-gpt-script' (#1) from fix/improve-gpt-script into main
Reviewed-on: Klagarge/medias-migration#1
2025-05-03 13:24:47 +00:00
2de5044dfe feat: output directory path by output flag
BREAKING-CHANGE: output flag use now directory path instead of output file
2025-05-03 15:22:31 +02:00
9d4f4319c2 fix: remove unnecessary flags from metadata extraction 2025-05-03 15:19:43 +02:00
ca24665574 fix: remove useless cq
The constant quality parameter is already present in the command
2025-05-03 15:19:00 +02:00
32 changed files with 499 additions and 163 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
__pycache__/
*.pyc
.git
.env
metadata

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
metadata

7
Dockerfile Normal file
View File

@ -0,0 +1,7 @@
FROM python:3.13.3-alpine
WORKDIR /app
COPY . .
CMD ["python3.13", "src/server.py"]

1
editor/.gitignore vendored
View File

@ -1 +0,0 @@
metadata/

View File

@ -1,28 +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 id="files"></div>
</main>
</body>
</html>

View File

@ -1,37 +0,0 @@
#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;
}
}
}

View File

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

View File

@ -1,5 +0,0 @@
export default class MediaFile {
constructor(data) {
}
}

View File

@ -55,7 +55,6 @@ 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",

View File

@ -55,8 +55,6 @@ 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
@ -73,7 +71,6 @@ 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
} }
@ -86,13 +83,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_file=None): def process_file(file_path, output_dir=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_file (str, optional): Path to output JSON file output_dir (str, optional): Directory where the output JSON file will be saved
""" """
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}")
@ -106,27 +103,34 @@ def process_file(file_path, output_file=None):
metadata = get_video_metadata(file_path) metadata = get_video_metadata(file_path)
if metadata: if metadata:
if not output_file:
# Generate output filename based on input file # 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] base_name = os.path.splitext(file_path)[0]
output_file = f"{base_name}_metadata.json" output_path = f"{base_name}_metadata.json"
# Write metadata to JSON file # Write metadata to JSON file
with open(output_file, 'w', encoding='utf-8') as f: with open(output_path, '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_file}") print(f"✅ Metadata saved to {output_path}")
return True return True
return False return False
def process_directory(directory_path, output_file=None): def process_directory(directory_path, output_dir=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_file (str, optional): Path to output JSON file output_dir (str, optional): Directory where the output JSON file will be saved
""" """
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}")
@ -152,31 +156,38 @@ def process_directory(directory_path, output_file=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
if not output_file:
# Generate output filename based on directory name # Generate output filename based on directory name
dir_name = os.path.basename(os.path.normpath(directory_path)) dir_name = os.path.basename(os.path.normpath(directory_path))
output_file = f"{dir_name}_metadata.json" 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 # Write all metadata to a single JSON file
with open(output_file, 'w', encoding='utf-8') as f: with open(output_path, '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_file}") print(f"✅ Metadata for {file_count} files saved to {output_path}")
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="Path to output JSON file") parser.add_argument("-o", "--output", help="Directory path where output JSON files will be saved")
args = parser.parse_args() args = parser.parse_args()
input_path = args.input input_path = args.input
output_file = args.output output_dir = args.output
if os.path.isfile(input_path): if os.path.isfile(input_path):
process_file(input_path, output_file) process_file(input_path, output_dir)
elif os.path.isdir(input_path): elif os.path.isdir(input_path):
process_directory(input_path, output_file) process_directory(input_path, output_dir)
else: else:
print(f"❌ Path not found: {input_path}") print(f"❌ Path not found: {input_path}")
sys.exit(1) sys.exit(1)

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

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

View File

@ -338,9 +338,11 @@ 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) {
display: none; transform: translateX(100%);
} }
#close-notifs { #close-notifs {

View File

@ -0,0 +1,96 @@
#files {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
grid-auto-rows: 15em;
gap: 0.8em;
place-items: center;
padding: 0.8em 0;
.file {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 100%;
height: 100%;
text-decoration: none;
color: black;
font-family: inherit;
font-size: inherit;
padding: 0.4em;
border-radius: 1.2em;
&.hidden {
display: none;
}
&:hover {
background-color: #f8f8f8;
}
img {
width: 10em;
height: 10em;
}
.title {
overflow-wrap: anywhere;
text-align: center;
font-weight: bold;
}
}
}
.toolbar {
display: flex;
gap: 1.2em;
border-bottom: solid black 1px;
padding: 0.4em 0;
.tool {
display: flex;
flex-direction: column;
gap: 0.2em;
label[for] {
font-weight: bold;
}
input, select {
font-family: inherit;
font-size: inherit;
height: 100%;
}
.toggle {
height: 2em;
border-radius: 1em;
display: grid;
grid-template-columns: 1fr 1fr;
user-select: none;
cursor: pointer;
input {
display: none;
&:not(:checked) ~ .off, &:checked ~ .on {
background-color: #6ee74a;
}
}
div {
padding: 0 0.4em;
display: grid;
place-items: center;
&.off {
border-radius: 1em 0 0 1em;
}
&.on {
border-radius: 0 1em 1em 0;
}
}
}
}
}

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="collection.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="5.6568543"
inkscape:cx="69.384853"
inkscape:cy="46.050329"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="g8-2">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="0.99999998"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="16"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect10"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,0,1,0,8.0000005,0,1 @ F,0,1,1,0,5,0,1 @ F,0,1,1,0,5,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect9"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,0,0,1 @ F,0,0,1,0,8.0000004,0,1 @ F,0,1,1,0,8.0000004,0,1 @ F,0,1,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect8"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,5,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,8,0,1 @ F,0,1,1,0,5,0,1 @ F,0,1,1,0,5,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g8-2"
transform="rotate(7.9490233,62.107327,266.90842)">
<g
id="g10"
transform="translate(-12.771862,-9.814157)">
<path
style="fill:none;stroke:#000000;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 83.459997,59.049888 82.353661,51.126757 C 81.742649,46.750932 77.700018,43.698948 73.324193,44.30996 L 38.66049,49.15018 C 35.925599,49.532063 33.398955,47.624573 33.017073,44.889682 32.39989,42.466468 29.93516,41.002392 27.511947,41.619576 L 8.6945086,44.247124 C 4.3186836,44.858137 1.2667,48.900767 1.8777122,53.276592 L 9.760356,109.72891 c 0.611016,4.37582 4.653645,7.4278 9.029468,6.81679 l 63.385056,-8.85068 c 3.853961,-0.53814 6.681016,-3.7382 6.883076,-7.48335"
id="path8"
sodipodi:nodetypes="ccccccsccccc" />
<path
style="fill:none;stroke:#000000;stroke-width:1.99937;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 9.6220641,108.73852 11.643471,76.990746 a 9.8076021,9.8076021 132.84706 0 1 8.431469,-9.09017 l 63.385056,-8.850688 a 6.5830057,6.5830057 42.600843 0 1 7.483432,6.881572 l -1.886976,34.28013"
id="path9"
sodipodi:nodetypes="cccc" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,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("/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())
})

75
editor/server.py → src/server.py Normal file → Executable file
View File

@ -1,15 +1,38 @@
from http import HTTPStatus #!/usr/bin/env python3
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 urlparse, parse_qs, unquote from urllib.parse import parse_qs, unquote, urlparse
# https://stackoverflow.com/a/10551190/11109181
class EnvDefault(argparse.Action):
def __init__(self, envvar, required=True, default=None, help=None, **kwargs):
if envvar:
if envvar in os.environ:
default = os.environ[envvar]
if required and default is not None:
required = False
if default is not None and help is not None:
help += f" (default: {default})"
if envvar and help is not None:
help += f"\nCan also be specified through the {envvar} environment variable"
super(EnvDefault, self).__init__(default=default, required=required, help=help,
**kwargs)
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, values)
PORT = 8000
MAX_SIZE = 10e6
class MyHandler(SimpleHTTPRequestHandler): class MyHandler(SimpleHTTPRequestHandler):
MAX_PAYLOAD_SIZE = 1e6
DATA_DIR = "metadata" DATA_DIR = "metadata"
CACHE = {} CACHE = {}
@ -25,9 +48,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 > MAX_SIZE: if size > self.MAX_PAYLOAD_SIZE:
self.send_error(HTTPStatus.CONTENT_TOO_LARGE) self.send_error(HTTPStatus.CONTENT_TOO_LARGE)
self.log_error(f"Payload is too big ({MAX_SIZE=}B)") self.log_error(f"Payload is too big ({self.MAX_PAYLOAD_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)
@ -149,8 +172,42 @@ class MyHandler(SimpleHTTPRequestHandler):
def main(): def main():
with socketserver.TCPServer(("", PORT), MyHandler) as httpd: parser = argparse.ArgumentParser(
print(f"Serving on port {PORT}") description="Starts the Melies server",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument(
"-p", "--port",
action=EnvDefault,
envvar="MELIES_PORT",
default=8000,
type=int,
help="Port on which the server listens"
)
parser.add_argument(
"--max-payload-size",
action=EnvDefault,
envvar="MELIES_MAX_PAYLOAD_SIZE",
default=1e6,
type=int,
help="Maximum POST payload size in bytes that the server accepts"
)
parser.add_argument(
"--metadata-dir",
action=EnvDefault,
envvar="MELIES_METADATA_DIR",
default="metadata",
help="Path to the directory containing metadata files"
)
args = parser.parse_args()
port = args.port
MyHandler.MAX_PAYLOAD_SIZE = args.max_payload_size
MyHandler.DATA_DIR = args.metadata_dir
with socketserver.TCPServer(("", port), MyHandler) as httpd:
print(f"Serving on port {port}")
httpd.serve_forever() httpd.serve_forever()