releascenify 0.1.0__tar.gz
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.
- releascenify-0.1.0/LICENSE +21 -0
- releascenify-0.1.0/PKG-INFO +116 -0
- releascenify-0.1.0/README.md +80 -0
- releascenify-0.1.0/pyproject.toml +25 -0
- releascenify-0.1.0/releascenify/__init__.py +4 -0
- releascenify-0.1.0/releascenify/comparator.py +56 -0
- releascenify-0.1.0/releascenify/parser.py +135 -0
- releascenify-0.1.0/releascenify.egg-info/PKG-INFO +116 -0
- releascenify-0.1.0/releascenify.egg-info/SOURCES.txt +12 -0
- releascenify-0.1.0/releascenify.egg-info/dependency_links.txt +1 -0
- releascenify-0.1.0/releascenify.egg-info/requires.txt +3 -0
- releascenify-0.1.0/releascenify.egg-info/top_level.txt +1 -0
- releascenify-0.1.0/setup.cfg +4 -0
- releascenify-0.1.0/tests/test_parser.py +37 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Denis Machard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: releascenify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A regex-based Python library to parse scene release names and extract technical metadata
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Denis Machard
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Classifier: Programming Language :: Python :: 3
|
|
28
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
29
|
+
Classifier: Operating System :: OS Independent
|
|
30
|
+
Requires-Python: >=3.7
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# Releascenify
|
|
38
|
+
|
|
39
|
+
A robust, regex-based Python library to parse scene release names and extract technical metadata (title, year, resolution, quality, codec, audio, languages, etc.).
|
|
40
|
+
|
|
41
|
+
**Live Demo & Web Interface:** [https://dmachard.github.io/releascenify/docs/](https://dmachard.github.io/releascenify/docs/)
|
|
42
|
+
|
|
43
|
+
## Why?
|
|
44
|
+
Release names follow a strict grammar inherited from the Scene and P2P groups. Once decoded, you can predict the quality, source, language, and release group just from the filename. This library is a modernized alternative to PTN (Parse Torrent Name).
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
*(To be published on PyPI)*
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from releascenify import parse_filename
|
|
53
|
+
from releascenify.comparator import get_quality_score, is_better_release
|
|
54
|
+
|
|
55
|
+
filename = "Gladiator.II.2024.MULTi.2160p.WEB-DL.DV.HDR.H265-GROUP"
|
|
56
|
+
parsed = parse_filename(filename)
|
|
57
|
+
|
|
58
|
+
print(parsed)
|
|
59
|
+
# {
|
|
60
|
+
# "title": "Gladiator II",
|
|
61
|
+
# "year": 2024,
|
|
62
|
+
# "resolution": "2160P",
|
|
63
|
+
# "quality": "WEB-DL",
|
|
64
|
+
# "v_quality": "HDR DV",
|
|
65
|
+
# "codec": "H265",
|
|
66
|
+
# "languages": ["MULTI"]
|
|
67
|
+
# ...
|
|
68
|
+
# }
|
|
69
|
+
|
|
70
|
+
# Calculate quality score
|
|
71
|
+
score = get_quality_score(parsed)
|
|
72
|
+
print(f"Quality Score: {score}")
|
|
73
|
+
|
|
74
|
+
# Compare two releases
|
|
75
|
+
rel_a = parse_filename("Gladiator.II.2024.1080p.BluRay.x264-GROUP")
|
|
76
|
+
rel_b = parse_filename("Gladiator.II.2024.2160p.WEB-DL.x265-GROUP")
|
|
77
|
+
|
|
78
|
+
if is_better_release(rel_b, rel_a):
|
|
79
|
+
print("Release B is better than Release A")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Development & Tests
|
|
83
|
+
|
|
84
|
+
### Setup Local Virtual Environment
|
|
85
|
+
|
|
86
|
+
To set up the development environment, create a virtual environment and install the package in editable mode with development dependencies:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Create the virtual environment
|
|
90
|
+
virtualenv .venv
|
|
91
|
+
|
|
92
|
+
# Activate the virtual environment
|
|
93
|
+
source .venv/bin/activate
|
|
94
|
+
|
|
95
|
+
# Install the package in editable/development mode
|
|
96
|
+
pip install -e .[dev]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Run Tests
|
|
100
|
+
|
|
101
|
+
Run the test suite using `pytest`:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pytest tests/ -v
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Run Website Locally
|
|
108
|
+
|
|
109
|
+
Due to browser CORS security policies, opening the HTML files directly as a local file (via `file://`) will block loading the relative Python files. You need to run a local web server from the repository root:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Start a local HTTP server
|
|
113
|
+
python3 -m http.server 8000
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Then navigate to: [http://localhost:8000/](http://localhost:8000/) (which redirects to `/docs/`) or directly to [http://localhost:8000/docs/](http://localhost:8000/docs/).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Releascenify
|
|
2
|
+
|
|
3
|
+
A robust, regex-based Python library to parse scene release names and extract technical metadata (title, year, resolution, quality, codec, audio, languages, etc.).
|
|
4
|
+
|
|
5
|
+
**Live Demo & Web Interface:** [https://dmachard.github.io/releascenify/docs/](https://dmachard.github.io/releascenify/docs/)
|
|
6
|
+
|
|
7
|
+
## Why?
|
|
8
|
+
Release names follow a strict grammar inherited from the Scene and P2P groups. Once decoded, you can predict the quality, source, language, and release group just from the filename. This library is a modernized alternative to PTN (Parse Torrent Name).
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
*(To be published on PyPI)*
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from releascenify import parse_filename
|
|
17
|
+
from releascenify.comparator import get_quality_score, is_better_release
|
|
18
|
+
|
|
19
|
+
filename = "Gladiator.II.2024.MULTi.2160p.WEB-DL.DV.HDR.H265-GROUP"
|
|
20
|
+
parsed = parse_filename(filename)
|
|
21
|
+
|
|
22
|
+
print(parsed)
|
|
23
|
+
# {
|
|
24
|
+
# "title": "Gladiator II",
|
|
25
|
+
# "year": 2024,
|
|
26
|
+
# "resolution": "2160P",
|
|
27
|
+
# "quality": "WEB-DL",
|
|
28
|
+
# "v_quality": "HDR DV",
|
|
29
|
+
# "codec": "H265",
|
|
30
|
+
# "languages": ["MULTI"]
|
|
31
|
+
# ...
|
|
32
|
+
# }
|
|
33
|
+
|
|
34
|
+
# Calculate quality score
|
|
35
|
+
score = get_quality_score(parsed)
|
|
36
|
+
print(f"Quality Score: {score}")
|
|
37
|
+
|
|
38
|
+
# Compare two releases
|
|
39
|
+
rel_a = parse_filename("Gladiator.II.2024.1080p.BluRay.x264-GROUP")
|
|
40
|
+
rel_b = parse_filename("Gladiator.II.2024.2160p.WEB-DL.x265-GROUP")
|
|
41
|
+
|
|
42
|
+
if is_better_release(rel_b, rel_a):
|
|
43
|
+
print("Release B is better than Release A")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Development & Tests
|
|
47
|
+
|
|
48
|
+
### Setup Local Virtual Environment
|
|
49
|
+
|
|
50
|
+
To set up the development environment, create a virtual environment and install the package in editable mode with development dependencies:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Create the virtual environment
|
|
54
|
+
virtualenv .venv
|
|
55
|
+
|
|
56
|
+
# Activate the virtual environment
|
|
57
|
+
source .venv/bin/activate
|
|
58
|
+
|
|
59
|
+
# Install the package in editable/development mode
|
|
60
|
+
pip install -e .[dev]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Run Tests
|
|
64
|
+
|
|
65
|
+
Run the test suite using `pytest`:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pytest tests/ -v
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Run Website Locally
|
|
72
|
+
|
|
73
|
+
Due to browser CORS security policies, opening the HTML files directly as a local file (via `file://`) will block loading the relative Python files. You need to run a local web server from the repository root:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Start a local HTTP server
|
|
77
|
+
python3 -m http.server 8000
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then navigate to: [http://localhost:8000/](http://localhost:8000/) (which redirects to `/docs/`) or directly to [http://localhost:8000/docs/](http://localhost:8000/docs/).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "releascenify"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A regex-based Python library to parse scene release names and extract technical metadata"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
]
|
|
17
|
+
dependencies = []
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=7.0.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
packages = ["releascenify"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
|
|
3
|
+
def get_quality_score(parsed_release: Dict[str, Any]) -> int:
|
|
4
|
+
"""
|
|
5
|
+
Returns a numeric score for quality comparison.
|
|
6
|
+
Higher score means better quality.
|
|
7
|
+
Scoring priority: Resolution > Language > Video Quality > Audio > Source > Codec
|
|
8
|
+
"""
|
|
9
|
+
score = 0
|
|
10
|
+
|
|
11
|
+
# 1. Resolution
|
|
12
|
+
res = (parsed_release.get("resolution") or "").lower()
|
|
13
|
+
# Assuming 4K is best, then 4KLIGHT, 1080p, 720p, 480p
|
|
14
|
+
# Note: DDLtower logic penalizes 4KLight vs True 4K.
|
|
15
|
+
# True 4K: 450, 4KLight: 400 (or vice versa depending on preference, here we stick to generic)
|
|
16
|
+
if "4klight" in res: score += 400
|
|
17
|
+
elif "2160" in res or "4k" in res: score += 450
|
|
18
|
+
elif "1080" in res: score += 300
|
|
19
|
+
elif "720" in res: score += 200
|
|
20
|
+
elif "480" in res: score += 100
|
|
21
|
+
|
|
22
|
+
# 2. Language (Multi > VF > VOSTFR)
|
|
23
|
+
langs = [l.lower() for l in parsed_release.get("languages", [])]
|
|
24
|
+
if "multi" in langs: score += 50
|
|
25
|
+
elif "french" in langs or "vf" in langs: score += 30
|
|
26
|
+
elif "vostfr" in langs: score += 10
|
|
27
|
+
|
|
28
|
+
# 3. Video Quality (DV / HDR / 10bit)
|
|
29
|
+
vq = (parsed_release.get("v_quality") or "").lower()
|
|
30
|
+
if "dv" in vq or "dovi" in vq: score += 25
|
|
31
|
+
if "hdr" in vq: score += 20
|
|
32
|
+
if "10bit" in vq: score += 10
|
|
33
|
+
|
|
34
|
+
# 4. Audio Quality (Atmos / TrueHD > DTS > AC3)
|
|
35
|
+
aud = (parsed_release.get("audio") or "").lower()
|
|
36
|
+
if "atmos" in aud or "truehd" in aud: score += 15
|
|
37
|
+
elif "dts" in aud: score += 10
|
|
38
|
+
elif "ac3" in aud or "ddp" in aud: score += 5
|
|
39
|
+
|
|
40
|
+
# 5. Source Quality (BluRay > WEB-DL > HDTV)
|
|
41
|
+
q = (parsed_release.get("quality") or "").lower()
|
|
42
|
+
if "bluray" in q or "bdrip" in q: score += 10
|
|
43
|
+
elif "web" in q: score += 7
|
|
44
|
+
elif "hdtv" in q: score += 3
|
|
45
|
+
|
|
46
|
+
# 6. Codec (HEVC / x265 > x264)
|
|
47
|
+
c = (parsed_release.get("codec") or "").lower()
|
|
48
|
+
if "265" in c or "hevc" in c: score += 5
|
|
49
|
+
|
|
50
|
+
return score
|
|
51
|
+
|
|
52
|
+
def is_better_release(release_a: Dict[str, Any], release_b: Dict[str, Any]) -> bool:
|
|
53
|
+
"""
|
|
54
|
+
Returns True if release_a is considered better than release_b.
|
|
55
|
+
"""
|
|
56
|
+
return get_quality_score(release_a) > get_quality_score(release_b)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import html
|
|
3
|
+
import unicodedata
|
|
4
|
+
from typing import Dict, Any, Optional, List
|
|
5
|
+
|
|
6
|
+
class ReleaseParser:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
# Base patterns to extract common release info
|
|
9
|
+
self.patterns = {
|
|
10
|
+
'season': r'(?i)\b(?:s|saison)[\.\-\s]*(\d{1,2})\b',
|
|
11
|
+
'episode': r'(?i)\b(?:e|ep|episode)[\.\-\s]*(\d{1,3})\b',
|
|
12
|
+
'year': r'\b(19\d{2}|20[0-2]\d)\b',
|
|
13
|
+
'resolution': r'(?i)(4KLIGHT|4K|2160[pP]|1080[pP]|720[pP]|UHD)',
|
|
14
|
+
'quality': r'(?i)(WEB-DL|WEBRIP|WEBLIGHT|WEB|BLURAY|BDRIP|BRRIP|DVDRIP|HDTV)',
|
|
15
|
+
'codec': r'(?i)(x265|x264|h265|h264|HEVC)',
|
|
16
|
+
'audio': r'(?i)(AAC|AC3|E-AC3|DTS-HD|DTS|ATMOS|TRUEHD|DDP\d\.\d)',
|
|
17
|
+
'channels': r'(7\.1|5\.1|2\.0)\b',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def clean_network_name(self, name: str) -> str:
|
|
21
|
+
"""Normalizes network names for better UI display."""
|
|
22
|
+
if not name: return name
|
|
23
|
+
mapping = {
|
|
24
|
+
"Disney Plus": "Disney+", "Amazon Studios": "Amazon", "Amazon Prime": "Amazon",
|
|
25
|
+
"HBO Max": "HBO", "Apple TV Plus": "Apple TV+", "Paramount Plus": "Paramount+"
|
|
26
|
+
}
|
|
27
|
+
return mapping.get(name, name)
|
|
28
|
+
|
|
29
|
+
def extract_v_quality(self, filename: str) -> Optional[str]:
|
|
30
|
+
"""Detects HDR, DV, etc. from filename."""
|
|
31
|
+
if not filename: return None
|
|
32
|
+
fn = filename.upper()
|
|
33
|
+
tags = []
|
|
34
|
+
if any(x in fn for x in ["DV", "DOVI"]) or re.search(r'DOLBY[\.\-\s]VISION', fn): tags.append("DV")
|
|
35
|
+
if any(x in fn for x in ["HDR", "HDR10", "HDR10PLUS", "HDR10+"]): tags.append("HDR")
|
|
36
|
+
if "HLG" in fn: tags.append("HLG")
|
|
37
|
+
return " ".join(sorted(list(set(tags)), reverse=True)) if tags else None
|
|
38
|
+
|
|
39
|
+
def _extract_langs(self, fn_up: str) -> List[str]:
|
|
40
|
+
langs = []
|
|
41
|
+
if "TRUEFRENCH" in fn_up or "VFF" in fn_up or "FRENCH" in fn_up: langs.append("FRENCH")
|
|
42
|
+
if "MULTI" in fn_up: langs.append("MULTI")
|
|
43
|
+
if "VOSTFR" in fn_up or "VOST" in fn_up: langs.append("VOSTFR")
|
|
44
|
+
if "VFI" in fn_up or "VFQ" in fn_up or "VF2" in fn_up or re.search(r'\bVF\b', fn_up.replace('.', ' ').replace('-', ' ').replace('_', ' ')): langs.append("VF")
|
|
45
|
+
return list(dict.fromkeys(langs))
|
|
46
|
+
|
|
47
|
+
def parse(self, filename: str) -> Dict[str, Any]:
|
|
48
|
+
if not filename: return {}
|
|
49
|
+
|
|
50
|
+
result = {
|
|
51
|
+
"title": "", "category": "movie", "year": None, "season": None, "episode": None,
|
|
52
|
+
"resolution": None, "quality": None, "codec": None, "audio": None,
|
|
53
|
+
"channels": None, "network": "", "v_quality": "", "languages": [], "group": None
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Extract group
|
|
57
|
+
group_match = re.search(r'-([A-Za-z0-9_@]+)(?:\s*\(.*?\))?(?:\.[a-z0-9]{3,4})?$', filename.strip())
|
|
58
|
+
if group_match:
|
|
59
|
+
grp = group_match.group(1)
|
|
60
|
+
if grp.upper() not in ['DL', 'HDMA', 'FR', 'EN', 'HD']:
|
|
61
|
+
result['group'] = grp
|
|
62
|
+
|
|
63
|
+
# Check for joint SxxExx
|
|
64
|
+
se_match = re.search(r'(?i)\bs(\d{1,2})[\.\-\s]?[ex](\d{1,3})\b', filename)
|
|
65
|
+
if se_match:
|
|
66
|
+
result['season'] = str(int(se_match.group(1)))
|
|
67
|
+
result['episode'] = str(int(se_match.group(2)))
|
|
68
|
+
else:
|
|
69
|
+
# Check individual fallbacks
|
|
70
|
+
s_match = re.search(self.patterns['season'], filename)
|
|
71
|
+
if s_match: result['season'] = str(int(s_match.group(1)))
|
|
72
|
+
e_match = re.search(self.patterns['episode'], filename)
|
|
73
|
+
if e_match: result['episode'] = str(int(e_match.group(1)))
|
|
74
|
+
|
|
75
|
+
# Extract basic info using regex (except audio and year, handled below)
|
|
76
|
+
for key, pattern in self.patterns.items():
|
|
77
|
+
if key in ['season', 'episode', 'audio', 'year']: continue # Already handled
|
|
78
|
+
match = re.search(pattern, filename)
|
|
79
|
+
if match:
|
|
80
|
+
result[key] = match.group(1).upper()
|
|
81
|
+
|
|
82
|
+
# Extract year (prefer the last matching year if multiple are present, e.g., 1917 (2019))
|
|
83
|
+
years = re.findall(self.patterns['year'], filename)
|
|
84
|
+
if years:
|
|
85
|
+
result['year'] = int(years[-1])
|
|
86
|
+
|
|
87
|
+
# Extract audio with priority (Atmos/TrueHD > DTS > AC3/DDP/AAC)
|
|
88
|
+
for pat in [r'(?i)(ATMOS|TRUEHD)', r'(?i)(DTS-HD|DTS)', r'(?i)(E-AC3|AC3|AAC|DDP\d\.\d|DDP)']:
|
|
89
|
+
match = re.search(pat, filename)
|
|
90
|
+
if match:
|
|
91
|
+
result['audio'] = match.group(1).upper()
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
if result['season'] or result['episode']:
|
|
95
|
+
result['category'] = 'series'
|
|
96
|
+
|
|
97
|
+
# V_Quality
|
|
98
|
+
result['v_quality'] = self.extract_v_quality(filename) or ""
|
|
99
|
+
|
|
100
|
+
# Clean title
|
|
101
|
+
fn_clean = html.unescape(filename)
|
|
102
|
+
fn_clean = unicodedata.normalize('NFKD', fn_clean).encode('ASCII', 'ignore').decode('utf-8')
|
|
103
|
+
|
|
104
|
+
# Remove Volume/Part markers
|
|
105
|
+
fn_clean = re.sub(r'\b(Vol|Pt|Part|Partie)[\.\s]?\d+\b', ' ', fn_clean, flags=re.I)
|
|
106
|
+
fn_clean = re.sub(r'\b\d+(?:e|ème|re|nd|rd|th)?\s+partie\b', ' ', fn_clean, flags=re.I)
|
|
107
|
+
|
|
108
|
+
# Split point for title
|
|
109
|
+
tags_to_split = [
|
|
110
|
+
r'S\d+', r'E\d+', r'S\d+E\d+', r'SAISON[\.\-\s]?\d+', r'EPISODE[\.\-\s]?\d+', 'MULTI', 'FRENCH', 'TRUEFRENCH', 'VOSTFR', 'SUBFRENCH', 'VFF', 'VFI', 'VFQ', 'VOST', 'STFI',
|
|
111
|
+
'1080P', '720P', '2160P', '4K', '4KLIGHT', 'UHD', 'BLURAY', 'BDRIP', 'DVDRIP', 'WEBRIP', 'WEB-DL', 'WEBLIGHT', 'WEB',
|
|
112
|
+
'HDR', 'DV', 'HEVC', 'X264', 'X265', 'H264', 'H265', 'REPACK', 'PROPER', 'FINAL', 'INTERNAL', 'CUSTOM', 'AC3', 'DDP', 'DTS', 'ATMOS',
|
|
113
|
+
r'19\d{2}', r'20[0-2]\d'
|
|
114
|
+
]
|
|
115
|
+
pattern = r'[\.\[\(\s\-\_](?:' + '|'.join(tags_to_split) + r')\b'
|
|
116
|
+
title = re.split(pattern, fn_clean, flags=re.I)[0]
|
|
117
|
+
|
|
118
|
+
title = title.replace('.', ' ').replace('_', ' ').strip()
|
|
119
|
+
title = re.sub(r'\s+', ' ', title).strip()
|
|
120
|
+
|
|
121
|
+
result['title'] = title
|
|
122
|
+
|
|
123
|
+
# Languages
|
|
124
|
+
fn_up = filename.upper().replace('[', '.').replace(']', '.').replace('_', '.')
|
|
125
|
+
result['languages'] = self._extract_langs(fn_up)
|
|
126
|
+
|
|
127
|
+
# Fix resolution for 4KLIGHT
|
|
128
|
+
if "4KLIGHT" in fn_up:
|
|
129
|
+
result['resolution'] = "4KLIGHT"
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def parse_filename(filename: str) -> Dict[str, Any]:
|
|
134
|
+
parser = ReleaseParser()
|
|
135
|
+
return parser.parse(filename)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: releascenify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A regex-based Python library to parse scene release names and extract technical metadata
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Denis Machard
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Classifier: Programming Language :: Python :: 3
|
|
28
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
29
|
+
Classifier: Operating System :: OS Independent
|
|
30
|
+
Requires-Python: >=3.7
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# Releascenify
|
|
38
|
+
|
|
39
|
+
A robust, regex-based Python library to parse scene release names and extract technical metadata (title, year, resolution, quality, codec, audio, languages, etc.).
|
|
40
|
+
|
|
41
|
+
**Live Demo & Web Interface:** [https://dmachard.github.io/releascenify/docs/](https://dmachard.github.io/releascenify/docs/)
|
|
42
|
+
|
|
43
|
+
## Why?
|
|
44
|
+
Release names follow a strict grammar inherited from the Scene and P2P groups. Once decoded, you can predict the quality, source, language, and release group just from the filename. This library is a modernized alternative to PTN (Parse Torrent Name).
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
*(To be published on PyPI)*
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from releascenify import parse_filename
|
|
53
|
+
from releascenify.comparator import get_quality_score, is_better_release
|
|
54
|
+
|
|
55
|
+
filename = "Gladiator.II.2024.MULTi.2160p.WEB-DL.DV.HDR.H265-GROUP"
|
|
56
|
+
parsed = parse_filename(filename)
|
|
57
|
+
|
|
58
|
+
print(parsed)
|
|
59
|
+
# {
|
|
60
|
+
# "title": "Gladiator II",
|
|
61
|
+
# "year": 2024,
|
|
62
|
+
# "resolution": "2160P",
|
|
63
|
+
# "quality": "WEB-DL",
|
|
64
|
+
# "v_quality": "HDR DV",
|
|
65
|
+
# "codec": "H265",
|
|
66
|
+
# "languages": ["MULTI"]
|
|
67
|
+
# ...
|
|
68
|
+
# }
|
|
69
|
+
|
|
70
|
+
# Calculate quality score
|
|
71
|
+
score = get_quality_score(parsed)
|
|
72
|
+
print(f"Quality Score: {score}")
|
|
73
|
+
|
|
74
|
+
# Compare two releases
|
|
75
|
+
rel_a = parse_filename("Gladiator.II.2024.1080p.BluRay.x264-GROUP")
|
|
76
|
+
rel_b = parse_filename("Gladiator.II.2024.2160p.WEB-DL.x265-GROUP")
|
|
77
|
+
|
|
78
|
+
if is_better_release(rel_b, rel_a):
|
|
79
|
+
print("Release B is better than Release A")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Development & Tests
|
|
83
|
+
|
|
84
|
+
### Setup Local Virtual Environment
|
|
85
|
+
|
|
86
|
+
To set up the development environment, create a virtual environment and install the package in editable mode with development dependencies:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Create the virtual environment
|
|
90
|
+
virtualenv .venv
|
|
91
|
+
|
|
92
|
+
# Activate the virtual environment
|
|
93
|
+
source .venv/bin/activate
|
|
94
|
+
|
|
95
|
+
# Install the package in editable/development mode
|
|
96
|
+
pip install -e .[dev]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Run Tests
|
|
100
|
+
|
|
101
|
+
Run the test suite using `pytest`:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pytest tests/ -v
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Run Website Locally
|
|
108
|
+
|
|
109
|
+
Due to browser CORS security policies, opening the HTML files directly as a local file (via `file://`) will block loading the relative Python files. You need to run a local web server from the repository root:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Start a local HTTP server
|
|
113
|
+
python3 -m http.server 8000
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Then navigate to: [http://localhost:8000/](http://localhost:8000/) (which redirects to `/docs/`) or directly to [http://localhost:8000/docs/](http://localhost:8000/docs/).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
releascenify/__init__.py
|
|
5
|
+
releascenify/comparator.py
|
|
6
|
+
releascenify/parser.py
|
|
7
|
+
releascenify.egg-info/PKG-INFO
|
|
8
|
+
releascenify.egg-info/SOURCES.txt
|
|
9
|
+
releascenify.egg-info/dependency_links.txt
|
|
10
|
+
releascenify.egg-info/requires.txt
|
|
11
|
+
releascenify.egg-info/top_level.txt
|
|
12
|
+
tests/test_parser.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
releascenify
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import pytest
|
|
4
|
+
from releascenify.parser import parse_filename
|
|
5
|
+
|
|
6
|
+
def load_test_cases(category):
|
|
7
|
+
filename = f'test_cases_{category}.json'
|
|
8
|
+
cases_file = os.path.join(os.path.dirname(__file__), filename)
|
|
9
|
+
with open(cases_file, 'r', encoding='utf-8') as f:
|
|
10
|
+
data = json.load(f)
|
|
11
|
+
|
|
12
|
+
cases = []
|
|
13
|
+
for case in data:
|
|
14
|
+
# Use filename as ID for nice pytest output
|
|
15
|
+
cases.append(pytest.param(case['filename'], case['expected'], id=case['filename']))
|
|
16
|
+
return cases
|
|
17
|
+
|
|
18
|
+
def run_parse_test(filename, expected):
|
|
19
|
+
result = parse_filename(filename)
|
|
20
|
+
for key, expected_val in expected.items():
|
|
21
|
+
actual_val = result.get(key)
|
|
22
|
+
if isinstance(expected_val, list):
|
|
23
|
+
# Sort lists for comparison (like languages)
|
|
24
|
+
assert sorted(actual_val) == sorted(expected_val), f"Failed on '{filename}': expected {key}={expected_val}, got {actual_val}"
|
|
25
|
+
else:
|
|
26
|
+
assert actual_val == expected_val, f"Failed on '{filename}': expected {key}={expected_val}, got {actual_val}"
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize("filename,expected", load_test_cases('movies'))
|
|
29
|
+
def test_movie_parser(filename, expected):
|
|
30
|
+
run_parse_test(filename, expected)
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize("filename,expected", load_test_cases('series'))
|
|
33
|
+
def test_series_parser(filename, expected):
|
|
34
|
+
run_parse_test(filename, expected)
|
|
35
|
+
|
|
36
|
+
if __name__ == '__main__':
|
|
37
|
+
pytest.main([__file__, '-v'])
|