mifpy 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mifpy/__init__.py +13 -0
- mifpy/cli.py +80 -0
- mifpy/core.py +162 -0
- mifpy/exceptions.py +9 -0
- mifpy/parser.py +119 -0
- mifpy/srt.py +75 -0
- mifpy/utils.py +25 -0
- mifpy/writer.py +44 -0
- mifpy-1.0.0.dist-info/METADATA +88 -0
- mifpy-1.0.0.dist-info/RECORD +12 -0
- mifpy-1.0.0.dist-info/WHEEL +4 -0
- mifpy-1.0.0.dist-info/entry_points.txt +3 -0
mifpy/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .core import MIFPackage
|
|
2
|
+
from .exceptions import MIFValidationError
|
|
3
|
+
from .srt import parse_srt, format_srt
|
|
4
|
+
from .utils import ms_to_timestamp, timestamp_to_ms
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"MIFPackage",
|
|
8
|
+
"MIFValidationError",
|
|
9
|
+
"parse_srt",
|
|
10
|
+
"format_srt",
|
|
11
|
+
"ms_to_timestamp",
|
|
12
|
+
"timestamp_to_ms",
|
|
13
|
+
]
|
mifpy/cli.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .core import MIFPackage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def inspect_command(path: Path) -> int:
|
|
12
|
+
package = MIFPackage.load(path)
|
|
13
|
+
print(f"MIF package: {path}")
|
|
14
|
+
print("Metadata:")
|
|
15
|
+
for key, value in package.get_metadata().items():
|
|
16
|
+
print(f" {key}: {value}")
|
|
17
|
+
print(f"Subtitles length: {len(package.get_subtitles())} characters")
|
|
18
|
+
print(f"Appearances: {len(package.get_appearances().get('appearances', []))}")
|
|
19
|
+
print(f"Content files: {len(package.list_content_files())}")
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_command(source: Path, destination: Path) -> int:
|
|
24
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
with zipfile.ZipFile(source, "r") as archive:
|
|
26
|
+
archive.extractall(destination)
|
|
27
|
+
print(f"Extracted {source} to {destination}")
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_command(path: Path) -> int:
|
|
32
|
+
meta = {"title": "Example Movie", "year": 2025, "runtime_ms": 90_000}
|
|
33
|
+
subtitles = "1\n00:00:00,000 --> 00:00:03,000\nHello from MIF!\n"
|
|
34
|
+
appearances = {
|
|
35
|
+
"actors": {
|
|
36
|
+
"example_actor": {
|
|
37
|
+
"name": "Example Actor",
|
|
38
|
+
"image": "content/example.png",
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"appearances": [
|
|
42
|
+
{"actor": "example_actor", "start_ms": 0, "end_ms": 3000}
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
package = MIFPackage.create(meta=meta, subtitles=subtitles, appearances=appearances)
|
|
46
|
+
package.add_content_file("example.png", b"")
|
|
47
|
+
package.save(path)
|
|
48
|
+
print(f"Created example MIF file at {path}")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> int:
|
|
53
|
+
parser = argparse.ArgumentParser(prog="mifpy", description="Movie Information Format toolkit")
|
|
54
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
inspect_parser = subparsers.add_parser("inspect", help="Display MIF metadata and structure")
|
|
57
|
+
inspect_parser.add_argument("source", type=Path, help="Path to the .mif file")
|
|
58
|
+
|
|
59
|
+
extract_parser = subparsers.add_parser("extract", help="Extract the contents of a MIF archive")
|
|
60
|
+
extract_parser.add_argument("source", type=Path, help="Path to the .mif file")
|
|
61
|
+
extract_parser.add_argument("destination", type=Path, help="Output folder")
|
|
62
|
+
|
|
63
|
+
create_parser = subparsers.add_parser("create", help="Create a template MIF file")
|
|
64
|
+
create_parser.add_argument("target", type=Path, help="Path to create the .mif file")
|
|
65
|
+
|
|
66
|
+
args = parser.parse_args(argv)
|
|
67
|
+
|
|
68
|
+
if args.command == "inspect":
|
|
69
|
+
return inspect_command(args.source)
|
|
70
|
+
if args.command == "extract":
|
|
71
|
+
return extract_command(args.source, args.destination)
|
|
72
|
+
if args.command == "create":
|
|
73
|
+
return create_command(args.target)
|
|
74
|
+
|
|
75
|
+
parser.print_help()
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
raise SystemExit(main())
|
mifpy/core.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import zipfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .exceptions import MIFValidationError
|
|
9
|
+
from .parser import parse_appearances, parse_manifest, parse_meta
|
|
10
|
+
from .srt import parse_srt
|
|
11
|
+
from .writer import build_manifest_text, build_meta_text, build_appearances_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MIFPackage:
|
|
15
|
+
MANIFEST_NAME = "manifest.ini"
|
|
16
|
+
META_NAME = "meta.ini"
|
|
17
|
+
SUBTITLES_NAME = "subtitles.srt"
|
|
18
|
+
APPEARANCES_NAME = "appearances.json"
|
|
19
|
+
DEFAULT_CONTENT_DIR = "content/"
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
meta: dict[str, Any],
|
|
24
|
+
subtitles: str,
|
|
25
|
+
appearances: dict[str, Any],
|
|
26
|
+
content_files: dict[str, bytes] | None = None,
|
|
27
|
+
content_dir: str | None = None,
|
|
28
|
+
):
|
|
29
|
+
self._meta = meta
|
|
30
|
+
self._subtitles = subtitles
|
|
31
|
+
self._appearances = appearances
|
|
32
|
+
self._content_files = content_files or {}
|
|
33
|
+
self._content_dir = content_dir or self.DEFAULT_CONTENT_DIR
|
|
34
|
+
self._validate()
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def create(
|
|
38
|
+
cls,
|
|
39
|
+
meta: dict[str, Any],
|
|
40
|
+
subtitles: str,
|
|
41
|
+
appearances: dict[str, Any],
|
|
42
|
+
) -> "MIFPackage":
|
|
43
|
+
return cls(meta=meta, subtitles=subtitles, appearances=appearances, content_files={})
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def load(cls, path: str | Path) -> "MIFPackage":
|
|
47
|
+
path_obj = Path(path)
|
|
48
|
+
if not path_obj.exists():
|
|
49
|
+
raise FileNotFoundError(f"MIF file not found: {path}")
|
|
50
|
+
|
|
51
|
+
with zipfile.ZipFile(path_obj, "r") as archive:
|
|
52
|
+
namelist = archive.namelist()
|
|
53
|
+
if cls.MANIFEST_NAME not in namelist:
|
|
54
|
+
raise MIFValidationError("manifest.ini is required in a MIF archive")
|
|
55
|
+
|
|
56
|
+
manifest_text = archive.read(cls.MANIFEST_NAME).decode("utf-8")
|
|
57
|
+
manifest = parse_manifest(manifest_text)
|
|
58
|
+
files = manifest["files"]
|
|
59
|
+
content_dir = files["content_dir"]
|
|
60
|
+
|
|
61
|
+
def read_required(file_key: str, name: str) -> str:
|
|
62
|
+
if name not in namelist:
|
|
63
|
+
raise MIFValidationError(f"{file_key} declared in manifest.ini is missing from the archive: {name}")
|
|
64
|
+
return archive.read(name).decode("utf-8")
|
|
65
|
+
|
|
66
|
+
meta_text = read_required("meta", files["meta"])
|
|
67
|
+
subtitles_text = read_required("subtitles", files["subtitles"])
|
|
68
|
+
appearances_text = read_required("appearances", files["appearances"])
|
|
69
|
+
|
|
70
|
+
metadata = parse_meta(meta_text)
|
|
71
|
+
appearances = parse_appearances(appearances_text, content_files=[name for name in namelist])
|
|
72
|
+
|
|
73
|
+
content_files: dict[str, bytes] = {}
|
|
74
|
+
for member in namelist:
|
|
75
|
+
if not member.endswith("/") and member.startswith(content_dir):
|
|
76
|
+
relative = member[len(content_dir) :]
|
|
77
|
+
if relative:
|
|
78
|
+
content_files[relative] = archive.read(member)
|
|
79
|
+
|
|
80
|
+
for actor_id, actor in appearances["actors"].items():
|
|
81
|
+
image_path = actor.get("image")
|
|
82
|
+
if image_path:
|
|
83
|
+
normalized = image_path.replace("\\", "/").lstrip("/")
|
|
84
|
+
if normalized not in namelist:
|
|
85
|
+
raise MIFValidationError(
|
|
86
|
+
f"actor '{actor_id}' references missing content file '{image_path}'"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return cls(
|
|
90
|
+
meta=metadata,
|
|
91
|
+
subtitles=subtitles_text,
|
|
92
|
+
appearances=appearances,
|
|
93
|
+
content_files=content_files,
|
|
94
|
+
content_dir=content_dir,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def save(self, path: str | Path) -> None:
|
|
98
|
+
self._validate_image_references()
|
|
99
|
+
path_obj = Path(path)
|
|
100
|
+
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
with zipfile.ZipFile(path_obj, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
103
|
+
archive.writestr(self.MANIFEST_NAME, build_manifest_text(self._content_dir))
|
|
104
|
+
archive.writestr(self.META_NAME, build_meta_text(self._meta))
|
|
105
|
+
archive.writestr(self.SUBTITLES_NAME, self._subtitles)
|
|
106
|
+
archive.writestr(self.APPEARANCES_NAME, build_appearances_text(self._appearances))
|
|
107
|
+
|
|
108
|
+
for relative_path, content in self._content_files.items():
|
|
109
|
+
normalized = relative_path.replace("\\", "/").lstrip("/")
|
|
110
|
+
if ".." in normalized.split("/"):
|
|
111
|
+
raise ValueError("Content file paths must not traverse parent directories")
|
|
112
|
+
archive_path = f"{self._content_dir}{normalized}"
|
|
113
|
+
archive.writestr(archive_path, content)
|
|
114
|
+
|
|
115
|
+
def _validate_image_references(self) -> None:
|
|
116
|
+
content_paths = {f"{self._content_dir}{name}" for name in self._content_files}
|
|
117
|
+
for actor_id, actor in self._appearances.get("actors", {}).items():
|
|
118
|
+
image_path = actor.get("image")
|
|
119
|
+
if image_path:
|
|
120
|
+
normalized = image_path.replace("\\", "/").lstrip("/")
|
|
121
|
+
if normalized not in content_paths:
|
|
122
|
+
raise MIFValidationError(
|
|
123
|
+
f"actor '{actor_id}' references missing content file '{image_path}'"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def get_metadata(self) -> dict[str, Any]:
|
|
127
|
+
return dict(self._meta)
|
|
128
|
+
|
|
129
|
+
def get_subtitles(self) -> str:
|
|
130
|
+
return self._subtitles
|
|
131
|
+
|
|
132
|
+
def get_appearances(self) -> dict[str, Any]:
|
|
133
|
+
return {
|
|
134
|
+
"actors": dict(self._appearances["actors"]),
|
|
135
|
+
"appearances": [dict(item) for item in self._appearances["appearances"]],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def list_content_files(self) -> list[str]:
|
|
139
|
+
return sorted(self._content_files.keys())
|
|
140
|
+
|
|
141
|
+
def add_content_file(self, path: str, data: bytes) -> None:
|
|
142
|
+
if not isinstance(path, str) or not path.strip():
|
|
143
|
+
raise ValueError("Content file path must be a non-empty string")
|
|
144
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
145
|
+
raise ValueError("Content file data must be bytes")
|
|
146
|
+
|
|
147
|
+
normalized = path.replace("\\", "/").lstrip("/")
|
|
148
|
+
if ".." in normalized.split("/"):
|
|
149
|
+
raise ValueError("Content file paths must not traverse parent directories")
|
|
150
|
+
self._content_files[normalized] = bytes(data)
|
|
151
|
+
|
|
152
|
+
def _validate(self) -> None:
|
|
153
|
+
if not isinstance(self._meta, dict):
|
|
154
|
+
raise MIFValidationError("meta must be a dictionary")
|
|
155
|
+
if not isinstance(self._subtitles, str):
|
|
156
|
+
raise MIFValidationError("subtitles must be a string")
|
|
157
|
+
if not isinstance(self._appearances, dict):
|
|
158
|
+
raise MIFValidationError("appearances must be a dictionary")
|
|
159
|
+
|
|
160
|
+
_ = parse_meta(build_meta_text(self._meta))
|
|
161
|
+
_ = parse_appearances(build_appearances_text(self._appearances), content_files=None)
|
|
162
|
+
|
mifpy/exceptions.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class MIFValidationError(Exception):
|
|
2
|
+
"""Raised when a MIF package contains invalid metadata or structure."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str):
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.message = message
|
|
7
|
+
|
|
8
|
+
def __str__(self) -> str:
|
|
9
|
+
return f"MIFValidationError: {self.message}"
|
mifpy/parser.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .exceptions import MIFValidationError
|
|
8
|
+
from .utils import timestamp_to_ms
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _normalize_path(path: str) -> str:
|
|
12
|
+
return path.replace("\\", "/").lstrip("/")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_manifest(text: str) -> dict[str, Any]:
|
|
16
|
+
parser = configparser.ConfigParser()
|
|
17
|
+
parser.read_string(text)
|
|
18
|
+
|
|
19
|
+
if "mif" not in parser:
|
|
20
|
+
raise MIFValidationError("manifest.ini is missing the [mif] section")
|
|
21
|
+
if "files" not in parser:
|
|
22
|
+
raise MIFValidationError("manifest.ini is missing the [files] section")
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
version = parser.getint("mif", "version")
|
|
26
|
+
except (configparser.NoOptionError, ValueError):
|
|
27
|
+
raise MIFValidationError("manifest.ini must declare an integer version")
|
|
28
|
+
|
|
29
|
+
files = {}
|
|
30
|
+
for key in ["meta", "subtitles", "appearances"]:
|
|
31
|
+
if not parser.has_option("files", key):
|
|
32
|
+
raise MIFValidationError(f"manifest.ini is missing the required file entry '{key}'")
|
|
33
|
+
files[key] = _normalize_path(parser.get("files", key))
|
|
34
|
+
|
|
35
|
+
content_dir = parser.get("files", "content_dir", fallback="content/")
|
|
36
|
+
content_dir = _normalize_path(content_dir)
|
|
37
|
+
if content_dir and not content_dir.endswith("/"):
|
|
38
|
+
content_dir += "/"
|
|
39
|
+
|
|
40
|
+
return {"version": version, "files": {**files, "content_dir": content_dir}}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_meta(text: str) -> dict[str, Any]:
|
|
44
|
+
parser = configparser.ConfigParser()
|
|
45
|
+
parser.read_string(text)
|
|
46
|
+
|
|
47
|
+
if "movie" not in parser:
|
|
48
|
+
raise MIFValidationError("meta.ini is missing the [movie] section")
|
|
49
|
+
|
|
50
|
+
movie = parser["movie"]
|
|
51
|
+
title = movie.get("title", fallback="").strip()
|
|
52
|
+
year = movie.get("year", fallback="").strip()
|
|
53
|
+
runtime_ms = movie.get("runtime_ms", fallback="").strip()
|
|
54
|
+
|
|
55
|
+
if not title:
|
|
56
|
+
raise MIFValidationError("meta.ini must include a non-empty title")
|
|
57
|
+
if not year.isdigit():
|
|
58
|
+
raise MIFValidationError("meta.ini year must be numeric")
|
|
59
|
+
if not runtime_ms.isdigit():
|
|
60
|
+
raise MIFValidationError("meta.ini runtime_ms must be numeric")
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"title": title,
|
|
64
|
+
"year": int(year),
|
|
65
|
+
"runtime_ms": int(runtime_ms),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_appearances(text: str, content_files: list[str] | None = None) -> dict[str, Any]:
|
|
70
|
+
try:
|
|
71
|
+
data = json.loads(text)
|
|
72
|
+
except json.JSONDecodeError as exc:
|
|
73
|
+
raise MIFValidationError(f"appearances.json is not valid JSON: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
raise MIFValidationError("appearances.json must contain a JSON object")
|
|
77
|
+
if "actors" not in data or "appearances" not in data:
|
|
78
|
+
raise MIFValidationError("appearances.json must include 'actors' and 'appearances' keys")
|
|
79
|
+
|
|
80
|
+
actors = data["actors"]
|
|
81
|
+
appearances = data["appearances"]
|
|
82
|
+
|
|
83
|
+
if not isinstance(actors, dict):
|
|
84
|
+
raise MIFValidationError("appearances.json actors section must be an object")
|
|
85
|
+
if not isinstance(appearances, list):
|
|
86
|
+
raise MIFValidationError("appearances.json appearances section must be an array")
|
|
87
|
+
|
|
88
|
+
for actor_id, actor in actors.items():
|
|
89
|
+
if not isinstance(actor_id, str) or not actor_id.strip():
|
|
90
|
+
raise MIFValidationError("actor IDs must be non-empty strings")
|
|
91
|
+
if not isinstance(actor, dict):
|
|
92
|
+
raise MIFValidationError(f"actor '{actor_id}' must be an object")
|
|
93
|
+
if "name" not in actor or not isinstance(actor["name"], str) or not actor["name"].strip():
|
|
94
|
+
raise MIFValidationError(f"actor '{actor_id}' must include a non-empty name")
|
|
95
|
+
if "image" in actor:
|
|
96
|
+
image = actor["image"]
|
|
97
|
+
if not isinstance(image, str) or not image.strip():
|
|
98
|
+
raise MIFValidationError(f"actor '{actor_id}' image path must be a non-empty string")
|
|
99
|
+
if content_files is not None:
|
|
100
|
+
normalized = _normalize_path(image)
|
|
101
|
+
if normalized not in content_files:
|
|
102
|
+
raise MIFValidationError(f"actor '{actor_id}' references missing content file '{image}'")
|
|
103
|
+
|
|
104
|
+
for index, appearance in enumerate(appearances):
|
|
105
|
+
if not isinstance(appearance, dict):
|
|
106
|
+
raise MIFValidationError("each appearance entry must be an object")
|
|
107
|
+
actor = appearance.get("actor")
|
|
108
|
+
if not isinstance(actor, str) or actor not in actors:
|
|
109
|
+
raise MIFValidationError(f"appearance entry at index {index} references unknown actor '{actor}'")
|
|
110
|
+
|
|
111
|
+
for field in ["start_ms", "end_ms"]:
|
|
112
|
+
value = appearance.get(field)
|
|
113
|
+
if not isinstance(value, int) or value < 0:
|
|
114
|
+
raise MIFValidationError(f"appearance entry at index {index} must include a non-negative integer '{field}'")
|
|
115
|
+
|
|
116
|
+
if appearance["end_ms"] < appearance["start_ms"]:
|
|
117
|
+
raise MIFValidationError(f"appearance entry at index {index} has end_ms before start_ms")
|
|
118
|
+
|
|
119
|
+
return {"actors": actors, "appearances": appearances}
|
mifpy/srt.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from .utils import timestamp_to_ms
|
|
6
|
+
from .exceptions import MIFValidationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_srt(text: str) -> List[dict]:
|
|
10
|
+
if not isinstance(text, str):
|
|
11
|
+
raise ValueError("SRT input must be a string")
|
|
12
|
+
|
|
13
|
+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
14
|
+
entries: List[dict] = []
|
|
15
|
+
current: dict | None = None
|
|
16
|
+
state = "index"
|
|
17
|
+
|
|
18
|
+
for raw_line in lines + [""]:
|
|
19
|
+
line = raw_line.strip()
|
|
20
|
+
|
|
21
|
+
if state == "index":
|
|
22
|
+
if not line:
|
|
23
|
+
continue
|
|
24
|
+
current = {"index": line, "start": "", "end": "", "text": []}
|
|
25
|
+
state = "timestamp"
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
if state == "timestamp" and current is not None:
|
|
29
|
+
if "-->" not in line:
|
|
30
|
+
raise MIFValidationError(f"Malformed SRT timestamp line: {line!r}")
|
|
31
|
+
start, end = [part.strip() for part in line.split("-->", 1)]
|
|
32
|
+
current["start"] = start
|
|
33
|
+
current["end"] = end
|
|
34
|
+
state = "text"
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
if state == "text" and current is not None:
|
|
38
|
+
if not line:
|
|
39
|
+
if current["text"]:
|
|
40
|
+
current["text"] = "\n".join(current["text"])
|
|
41
|
+
entries.append(current)
|
|
42
|
+
current = None
|
|
43
|
+
state = "index"
|
|
44
|
+
continue
|
|
45
|
+
current["text"].append(raw_line)
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
if current is not None and current["text"]:
|
|
49
|
+
current["text"] = "\n".join(current["text"])
|
|
50
|
+
entries.append(current)
|
|
51
|
+
|
|
52
|
+
return entries
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_srt(entries: List[dict]) -> str:
|
|
56
|
+
output_lines: List[str] = []
|
|
57
|
+
|
|
58
|
+
for entry in entries:
|
|
59
|
+
index = entry.get("index", "")
|
|
60
|
+
start = entry.get("start", "")
|
|
61
|
+
end = entry.get("end", "")
|
|
62
|
+
text = entry.get("text", "")
|
|
63
|
+
|
|
64
|
+
if not index or not start or not end or text is None:
|
|
65
|
+
raise ValueError("Each subtitle entry must include index, start, end, and text")
|
|
66
|
+
|
|
67
|
+
output_lines.append(str(index))
|
|
68
|
+
output_lines.append(f"{start} --> {end}")
|
|
69
|
+
if isinstance(text, str):
|
|
70
|
+
output_lines.extend(text.split("\n"))
|
|
71
|
+
else:
|
|
72
|
+
output_lines.extend(map(str, text))
|
|
73
|
+
output_lines.append("")
|
|
74
|
+
|
|
75
|
+
return "\n".join(output_lines).strip() + "\n"
|
mifpy/utils.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
SRT_TIMESTAMP_PATTERN = re.compile(r"^(\d{2}):(\d{2}):(\d{2}),(\d{3})$")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def ms_to_timestamp(ms: int) -> str:
|
|
7
|
+
if not isinstance(ms, int) or ms < 0:
|
|
8
|
+
raise ValueError("Milliseconds must be a non-negative integer")
|
|
9
|
+
|
|
10
|
+
hours, rest = divmod(ms, 3_600_000)
|
|
11
|
+
minutes, rest = divmod(rest, 60_000)
|
|
12
|
+
seconds, milliseconds = divmod(rest, 1_000)
|
|
13
|
+
return f"{hours:02}:{minutes:02}:{seconds:02},{milliseconds:03}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def timestamp_to_ms(timestamp: str) -> int:
|
|
17
|
+
if not isinstance(timestamp, str):
|
|
18
|
+
raise ValueError("Timestamp must be a string")
|
|
19
|
+
|
|
20
|
+
match = SRT_TIMESTAMP_PATTERN.match(timestamp.strip())
|
|
21
|
+
if not match:
|
|
22
|
+
raise ValueError(f"Invalid SRT timestamp format: {timestamp!r}")
|
|
23
|
+
|
|
24
|
+
hours, minutes, seconds, milliseconds = map(int, match.groups())
|
|
25
|
+
return ((hours * 60 + minutes) * 60 + seconds) * 1_000 + milliseconds
|
mifpy/writer.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_manifest_text(content_dir: str) -> str:
|
|
9
|
+
if content_dir and not content_dir.endswith("/"):
|
|
10
|
+
content_dir = f"{content_dir}/"
|
|
11
|
+
|
|
12
|
+
parser = configparser.ConfigParser()
|
|
13
|
+
parser["mif"] = {"version": "1"}
|
|
14
|
+
parser["files"] = {
|
|
15
|
+
"meta": "meta.ini",
|
|
16
|
+
"subtitles": "subtitles.srt",
|
|
17
|
+
"appearances": "appearances.json",
|
|
18
|
+
"content_dir": content_dir or "content/",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
from io import StringIO
|
|
22
|
+
|
|
23
|
+
buffer = StringIO()
|
|
24
|
+
parser.write(buffer)
|
|
25
|
+
return buffer.getvalue()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_meta_text(meta: dict[str, Any]) -> str:
|
|
29
|
+
parser = configparser.ConfigParser()
|
|
30
|
+
parser["movie"] = {
|
|
31
|
+
"title": str(meta.get("title", "")),
|
|
32
|
+
"year": str(int(meta.get("year", 0))),
|
|
33
|
+
"runtime_ms": str(int(meta.get("runtime_ms", 0))),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
from io import StringIO
|
|
37
|
+
|
|
38
|
+
buffer = StringIO()
|
|
39
|
+
parser.write(buffer)
|
|
40
|
+
return buffer.getvalue()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_appearances_text(appearances: dict[str, Any]) -> str:
|
|
44
|
+
return json.dumps(appearances, indent=2, ensure_ascii=False) + "\n"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mifpy
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A Python library for reading and writing Movie Information Format (.mif) containers.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: OmgRod
|
|
7
|
+
Author-email: rod@omgrod.me
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# MIFpy
|
|
19
|
+
|
|
20
|
+
`mifpy` is a lightweight Python library for the Movie Information Format (`.mif`), a ZIP-based container format for movie metadata, subtitles, actor appearance timelines, and associated assets.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- Load and save `.mif` archives using Python `zipfile`
|
|
25
|
+
- Validate required package files and formats
|
|
26
|
+
- Parse `manifest.ini`, `meta.ini`, `appearances.json`, and `subtitles.srt`
|
|
27
|
+
- Add content assets under `content/`
|
|
28
|
+
- CLI support: `inspect`, `extract`, and `create`
|
|
29
|
+
|
|
30
|
+
## Package structure
|
|
31
|
+
|
|
32
|
+
A valid `.mif` archive contains:
|
|
33
|
+
|
|
34
|
+
- `manifest.ini`
|
|
35
|
+
- `meta.ini`
|
|
36
|
+
- `subtitles.srt`
|
|
37
|
+
- `appearances.json`
|
|
38
|
+
- `content/` (optional)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Install with Poetry:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
poetry install
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from mifpy import MIFPackage
|
|
52
|
+
|
|
53
|
+
pkg = MIFPackage.create(
|
|
54
|
+
meta={"title": "The Matrix", "year": 1999, "runtime_ms": 8160000},
|
|
55
|
+
subtitles="1\n00:00:00,000 --> 00:00:02,000\nWake up, Neo.\n",
|
|
56
|
+
appearances={
|
|
57
|
+
"actors": {
|
|
58
|
+
"keanu_reeves": {
|
|
59
|
+
"name": "Keanu Reeves",
|
|
60
|
+
"image": "content/keanu.png",
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"appearances": [
|
|
64
|
+
{"actor": "keanu_reeves", "start_ms": 1000, "end_ms": 10000}
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
pkg.add_content_file("keanu.png", b"<raw-image-bytes>")
|
|
70
|
+
pkg.save("movie.mif")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## CLI
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
mifpy inspect movie.mif
|
|
77
|
+
mifpy extract movie.mif ./out
|
|
78
|
+
mifpy create sample.mif
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Testing
|
|
82
|
+
|
|
83
|
+
Run tests with:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
poetry run pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mifpy/__init__.py,sha256=AcWg1EKYe73gpnZWpae1_S8fvd6ealUmMW557-vBPMA,316
|
|
2
|
+
mifpy/cli.py,sha256=7-qQ0r47_FMxiG7WvfPhtYWtgcTdc_4EdY3SsGoRkwU,2869
|
|
3
|
+
mifpy/core.py,sha256=44gHr2E_ScoKYm7a2JKed-IPvVUMxJBLV0-Ha2vJru8,6869
|
|
4
|
+
mifpy/exceptions.py,sha256=JzZuJ9a8ma66TdUq-g0PS7P2hrPlQvmjK8yMoGKnrFM,310
|
|
5
|
+
mifpy/parser.py,sha256=dMplVRM0No7J4RDDZOV2EzHkbmgaudAVbh74_wG_x24,5007
|
|
6
|
+
mifpy/srt.py,sha256=k03Aw0hmWtYAt2qySsttfYad9XNtBQGexKvMTHMJzA8,2410
|
|
7
|
+
mifpy/utils.py,sha256=Q3Ygpg7DNMNQXfvQ2jG3Gf9_kp7gmK2GHLFYlJUWO5U,896
|
|
8
|
+
mifpy/writer.py,sha256=Uog9m2L1nx_vQQ-J4pHihY_mzye9Qyr1NZiEPXZ9d-U,1177
|
|
9
|
+
mifpy-1.0.0.dist-info/entry_points.txt,sha256=c4sCrX46OMrppJreNbgq3wvKCYicpBd1MIWm22qiZvQ,40
|
|
10
|
+
mifpy-1.0.0.dist-info/METADATA,sha256=dHNUA0soon3sEMo9848yTISq8OqRUXIm-HeVJYQwHeU,2071
|
|
11
|
+
mifpy-1.0.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
12
|
+
mifpy-1.0.0.dist-info/RECORD,,
|