From 1c7ab40d01c267f047b9182ea4de6081e75035d5 Mon Sep 17 00:00:00 2001 From: MeGaNeKo Date: Sun, 22 Feb 2026 14:55:25 +0700 Subject: [PATCH] =?UTF-8?q?feat!:=20Aniparse=20v2=20=E2=80=94=20probabilit?= =?UTF-8?q?y-based=20scoring=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of the parser with a new architecture: - Six-stage pipeline: Tokenize → Identify → Expand → Score → Resolve → Compose - Probability-based scoring replaces hardcoded heuristics - Structured output with SeriesInfo, sequences, ranges, and alternatives - Alternative title support via pipe separator - Folder/path context parsing - Extensible keyword system with WordListManager - Modernized packaging (pyproject.toml + setuptools-scm) - Updated CI workflows (Python 3.7–3.13) Closes #1, Closes #3 BREAKING CHANGE: Output format has changed. See README for the new schema. --- .github/workflows/python-publish.yml | 34 +- .github/workflows/python-test.yml | 13 +- README.md | 305 ++- aniparse/__init__.py | 46 +- aniparse/abstraction/__init__.py | 0 aniparse/abstraction/keyword_base.py | 77 + aniparse/abstraction/parser_base.py | 52 + aniparse/abstraction/score_base.py | 106 + aniparse/aniparse.py | 188 -- aniparse/api.py | 238 ++ aniparse/config.py | 36 + aniparse/core/__init__.py | 0 aniparse/core/compose.py | 1186 +++++++++ aniparse/core/constant.py | 83 + aniparse/core/defaults.py | 52 + aniparse/core/parser.py | 204 ++ aniparse/core/resolve.py | 83 + aniparse/core/rhythm.py | 125 + aniparse/core/token.py | 272 ++ aniparse/core/token_tags.py | 129 + aniparse/core/tokenizer.py | 36 + aniparse/element.py | 63 - aniparse/keyword.py | 253 -- aniparse/keyword/AUDIO_TERM.py | 68 + aniparse/keyword/BRACKET.py | 13 + aniparse/keyword/CONTENT_TYPE.py | 17 + aniparse/keyword/CONTEXT_DELIMITER.py | 10 + aniparse/keyword/CONTEXT_DEPENDENT.py | 14 + aniparse/keyword/DELIMITER.py | 10 + aniparse/keyword/DEVICE_COMPATIBILITY.py | 19 + aniparse/keyword/EPISODE_PREFIX.py | 31 + aniparse/keyword/FILE_INDEX.py | 5 + aniparse/keyword/LANG.py | 99 + aniparse/keyword/RELEASE_INFORMATION.py | 21 + aniparse/keyword/RELEASE_VERSION.py | 15 + aniparse/keyword/SEASON_PREFIX.py | 11 + aniparse/keyword/SERIES_TYPE.py | 17 + aniparse/keyword/SOURCE.py | 52 + aniparse/keyword/SUBS_TERM.py | 50 + aniparse/keyword/VIDEO_RESOLUTION.py | 17 + aniparse/keyword/VIDEO_TERM.py | 74 + aniparse/keyword/VOLUME_PREFIX.py | 11 + aniparse/keyword/__init__.py | 55 + aniparse/output_schemas.py | 217 ++ aniparse/parser.py | 812 ------ aniparse/parser_helper.py | 90 - aniparse/parser_number.py | 702 ------ aniparse/rule/__init__.py | 0 aniparse/rule/possibilities/__init__.py | 0 .../possibilities/bracket_content_rule.py | 66 + .../checksum_placeholder_rule.py | 15 + .../possibilities/context_delimiter_rule.py | 32 + .../context_dependent_expansion_rule.py | 74 + aniparse/rule/possibilities/number_rule.py | 146 ++ .../rule/possibilities/release_group_rule.py | 320 +++ aniparse/rule/possibilities/title_rule.py | 1204 +++++++++ aniparse/rule/possibilities/year_rule.py | 48 + .../possibilities/zone_assignment_rule.py | 176 ++ aniparse/rule/scoring/__init__.py | 0 .../rule/scoring/audio_term_score_rule.py | 7 + .../rule/scoring/contextual_score_rule.py | 126 + .../rule/scoring/file_checksum_score_rule.py | 44 + .../rule/scoring/file_index_score_rule.py | 109 + .../rule/scoring/positional_score_rule.py | 40 + .../scoring/sequence_number_score_rule.py | 125 + .../rule/scoring/series_year_score_rule.py | 66 + .../scoring/video_resolution_score_rule.py | 158 ++ .../rule/scoring/video_term_score_rule.py | 7 + aniparse/token.py | 309 --- aniparse/tokenizer.py | 249 -- aniparse/wordlist.py | 105 + pyproject.toml | 38 +- requirements_dev.txt | 3 - setup.py | 29 - tests/conftest.py | 115 + tests/fixtures/basic_episodes.py | 2239 +++++++++++++++++ tests/fixtures/complex.py | 703 ++++++ tests/fixtures/content_types.py | 252 ++ tests/fixtures/episode_titles.py | 461 ++++ tests/fixtures/folder_context.py | 140 ++ tests/fixtures/ranges.py | 220 ++ tests/fixtures/release_info.py | 140 ++ tests/fixtures/seasons.py | 540 ++++ tests/fixtures/series_types.py | 241 ++ tests/fixtures/table.py | 2179 +--------------- tests/fixtures/video_audio.py | 22 + tests/fixtures/years.py | 172 ++ tests/test_aniparse.py | 50 - tests/test_fixtures.py | 69 + tests/test_fixtures_db.py | 9 + tox.ini | 22 - 91 files changed, 11930 insertions(+), 5151 deletions(-) create mode 100644 aniparse/abstraction/__init__.py create mode 100644 aniparse/abstraction/keyword_base.py create mode 100644 aniparse/abstraction/parser_base.py create mode 100644 aniparse/abstraction/score_base.py delete mode 100644 aniparse/aniparse.py create mode 100644 aniparse/api.py create mode 100644 aniparse/config.py create mode 100644 aniparse/core/__init__.py create mode 100644 aniparse/core/compose.py create mode 100644 aniparse/core/constant.py create mode 100644 aniparse/core/defaults.py create mode 100644 aniparse/core/parser.py create mode 100644 aniparse/core/resolve.py create mode 100644 aniparse/core/rhythm.py create mode 100644 aniparse/core/token.py create mode 100644 aniparse/core/token_tags.py create mode 100644 aniparse/core/tokenizer.py delete mode 100644 aniparse/element.py delete mode 100644 aniparse/keyword.py create mode 100644 aniparse/keyword/AUDIO_TERM.py create mode 100644 aniparse/keyword/BRACKET.py create mode 100644 aniparse/keyword/CONTENT_TYPE.py create mode 100644 aniparse/keyword/CONTEXT_DELIMITER.py create mode 100644 aniparse/keyword/CONTEXT_DEPENDENT.py create mode 100644 aniparse/keyword/DELIMITER.py create mode 100644 aniparse/keyword/DEVICE_COMPATIBILITY.py create mode 100644 aniparse/keyword/EPISODE_PREFIX.py create mode 100644 aniparse/keyword/FILE_INDEX.py create mode 100644 aniparse/keyword/LANG.py create mode 100644 aniparse/keyword/RELEASE_INFORMATION.py create mode 100644 aniparse/keyword/RELEASE_VERSION.py create mode 100644 aniparse/keyword/SEASON_PREFIX.py create mode 100644 aniparse/keyword/SERIES_TYPE.py create mode 100644 aniparse/keyword/SOURCE.py create mode 100644 aniparse/keyword/SUBS_TERM.py create mode 100644 aniparse/keyword/VIDEO_RESOLUTION.py create mode 100644 aniparse/keyword/VIDEO_TERM.py create mode 100644 aniparse/keyword/VOLUME_PREFIX.py create mode 100644 aniparse/keyword/__init__.py create mode 100644 aniparse/output_schemas.py delete mode 100644 aniparse/parser.py delete mode 100644 aniparse/parser_helper.py delete mode 100644 aniparse/parser_number.py create mode 100644 aniparse/rule/__init__.py create mode 100644 aniparse/rule/possibilities/__init__.py create mode 100644 aniparse/rule/possibilities/bracket_content_rule.py create mode 100644 aniparse/rule/possibilities/checksum_placeholder_rule.py create mode 100644 aniparse/rule/possibilities/context_delimiter_rule.py create mode 100644 aniparse/rule/possibilities/context_dependent_expansion_rule.py create mode 100644 aniparse/rule/possibilities/number_rule.py create mode 100644 aniparse/rule/possibilities/release_group_rule.py create mode 100644 aniparse/rule/possibilities/title_rule.py create mode 100644 aniparse/rule/possibilities/year_rule.py create mode 100644 aniparse/rule/possibilities/zone_assignment_rule.py create mode 100644 aniparse/rule/scoring/__init__.py create mode 100644 aniparse/rule/scoring/audio_term_score_rule.py create mode 100644 aniparse/rule/scoring/contextual_score_rule.py create mode 100644 aniparse/rule/scoring/file_checksum_score_rule.py create mode 100644 aniparse/rule/scoring/file_index_score_rule.py create mode 100644 aniparse/rule/scoring/positional_score_rule.py create mode 100644 aniparse/rule/scoring/sequence_number_score_rule.py create mode 100644 aniparse/rule/scoring/series_year_score_rule.py create mode 100644 aniparse/rule/scoring/video_resolution_score_rule.py create mode 100644 aniparse/rule/scoring/video_term_score_rule.py delete mode 100644 aniparse/token.py delete mode 100644 aniparse/tokenizer.py create mode 100644 aniparse/wordlist.py delete mode 100644 requirements_dev.txt delete mode 100644 setup.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/basic_episodes.py create mode 100644 tests/fixtures/complex.py create mode 100644 tests/fixtures/content_types.py create mode 100644 tests/fixtures/episode_titles.py create mode 100644 tests/fixtures/folder_context.py create mode 100644 tests/fixtures/ranges.py create mode 100644 tests/fixtures/release_info.py create mode 100644 tests/fixtures/seasons.py create mode 100644 tests/fixtures/series_types.py create mode 100644 tests/fixtures/video_audio.py create mode 100644 tests/fixtures/years.py delete mode 100644 tests/test_aniparse.py create mode 100644 tests/test_fixtures.py create mode 100644 tests/test_fixtures_db.py delete mode 100644 tox.ini diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index d06feb3..df925d3 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,14 +1,6 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries +name: Publish to PyPI -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI - -on: +on: release: types: [published] @@ -17,29 +9,29 @@ permissions: jobs: deploy: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install build + pip install build setuptools-scm - name: Build package run: python -m build - - name: Publish distribution 📦 to Test PyPI - uses: pypa/gh-action-pypi-publish@master + - name: Publish to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} - repository_url: https://test.pypi.org/legacy/ - skip_existing: true - - name: Publish distribution 📦 to PyPI - if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b9350b8..b9ecc72 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -10,17 +10,18 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install tox tox-gh-actions - - name: Test with tox - run: tox + pip install pytest + pip install -e . + - name: Test with pytest + run: pytest diff --git a/README.md b/README.md index dbb2d0a..8daae82 100644 --- a/README.md +++ b/README.md @@ -1,224 +1,215 @@ # Aniparse -Aniparse is a Python library for parsing anime video filenames. It's simple to use, and it's based on the C++ -library [Anitomy](https://github.com/erengy/anitomy) with a lot of improvement. +A probability-based anime filename parser for Python. -## Update +Aniparse parses anime video filenames into structured metadata. Unlike regex-based approaches, it uses a **scoring engine** where confidence accumulates from multiple signals — position, context, keywords, and patterns — so it handles the wild variety of fansub naming conventions gracefully. -This library has already achieved its goal in a somewhat hacky way, as discussed in [issue #9](https://github.com/MeGaNeKoS/Aniparse/issues/9). -I am aware that the last commit isn't the clean code, but I don't have much time to work on this project anymore. -It's a sacrifice I have to make. I don't expect any improvements here for another year or so unless something breaks. -If you have an interest in this project, I would suggest you take a look at the [v2-idea](https://github.com/MeGaNeKoS/Aniparse/tree/v2-idea) branch instead. -I've documented the library's goals, how I plan to achieve them, and other details more comprehensively in that branch. +Based on the C++ library [Anitomy](https://github.com/erengy/anitomy), redesigned from the ground up in v2. -## Example - -The following filename +## Installation ``` -[TaigaSubs]_Toradora!_(2008)_-_01v2_-_Tiger_and_Dragon_[1280x720_H.264_FLAC][1234ABCD].mkv -Toradora! S01E03-Your Song.mkv +pip install aniparse ``` -can be parsed using the following code: +## Usage ```python import aniparse aniparse.parse('[TaigaSubs]_Toradora!_(2008)_-_01v2_-_Tiger_and_Dragon_[1280x720_H.264_FLAC][1234ABCD].mkv') -{ - 'anime_title': 'Toradora!', - 'anime_year': 2008, - 'audio_term': 'FLAC', - 'episode_number': 1, - 'episode_title': 'Tiger and Dragon', - 'file_checksum': '1234ABCD', - 'file_extension': 'mkv', - 'file_name': '[TaigaSubs]_Toradora!_(2008)_-_01v2_-_Tiger_and_Dragon_[1280x720_H.264_FLAC][1234ABCD].mkv', - 'release_group': 'TaigaSubs', - 'release_version': 2, - 'video_resolution': '1280x720', - 'video_term': 'H.264' -} +``` -aniparse.parse("Toradora! S01E03-Your Song.mkv") +```python { - 'anime_season': 1, - 'anime_season_prefix': 'S', - 'anime_title': 'Toradora!', - 'episode_number': 3, - 'episode_prefix': 'E', - 'episode_title': 'Your Song', + 'file_name': '[TaigaSubs]_Toradora!_(2008)_-_01v2_-_Tiger_and_Dragon_[1280x720_H.264_FLAC][1234ABCD].mkv', + 'audio_term': ['FLAC'], 'file_extension': 'mkv', - 'file_name': 'Toradora! S01E03-Your Song.mkv' + 'file_checksum': '1234ABCD', + 'video_resolution': [{'video_height': 720, 'video_width': 1280}], + 'release_version': ['2'], + 'release_group': ['TaigaSubs'], + 'series': [{ + 'title': 'Toradora!', + 'year': [{'number': 2008}], + 'episode': [{'number': 1, 'release_version': '2', 'title': 'Tiger and Dragon'}], + }], + 'video_term': ['H.264'], } ``` -The `parse` function receives a string and returns a dictionary containing all found elements. -It can also receive parsing `options` and `keyword_manager`, this will be explained below. +The `parse` function returns a dict with all identified metadata, or `None` if the input is empty. -# How does it work? +### Alternative titles -Suppose that we're working on the following filename: +Pipe `|` is a first-class separator. Each segment after the first becomes an alternative series entry: -```text -"Aim_For_The_Top!_Gunbuster-ep1.BD(H264.FLAC.10bit)[KAA][69ECCDCF].mkv" +```python +aniparse.parse('[TROLLORANGE] Hell Girl Season 4 (CR WEB-DL 1080p x264 AAC) | Hell Girl: Fourth Twilight') ``` -The filename is first stripped off of its extension and split into groups. Groups are determined by the position of -brackets: - -```text -"Aim_For_The_Top!_Gunbuster-ep1.BD", "H264.FLAC.10bit", "KAA", "69ECCDCF" +```python +{ + 'file_name': '[TROLLORANGE] Hell Girl Season 4 (CR WEB-DL 1080p x264 AAC) | Hell Girl: Fourth Twilight', + 'audio_term': ['AAC'], + 'video_resolution': [{'video_height': 1080, 'scan_method': 'p'}], + 'release_group': ['TROLLORANGE'], + 'release_information': ['CR'], + 'series': [ + {'title': 'Hell Girl', 'season': [{'number': 4}]}, + {'title': 'Hell Girl: Fourth Twilight'}, + ], + 'source': ['WEB-DL'], + 'video_term': ['x264'], +} ``` -Each group is then split into tokens. In our current example, the delimiter for the enclosed group is `.`, while the -words in other groups are separated by `_`: +### Path and folder context -```text -"Aim", "For", "The", "Top!", "Gunbuster-ep1", "BD", "H264", "FLAC", "10bit", "KAA", "69ECCDCF" -``` +```python +# Parse from a full path +aniparse.parse('', path='/anime/Toradora/[Group] Toradora! - 01.mkv') -Note: the brackets and delimiter are stored as token with category `Delimiter` and `Bracket`. And each token remembers -if it enclosed or not. +# Or pass folder separately +aniparse.parse('[Group] Toradora! - 01.mkv', folder='/anime/Toradora') +``` -Once the tokenizer is done, the parser comes into effect. -First, all tokens are compared against a set of known keywords. In this case, -the tokens `BD`, `H264`, `FLAC`, `10bit`, and `69ECCDCF` are recognized as keywords, -and are assigned the category `Source`, `VideoTerm`, `AudioTerm`, `VideoResolution`, and `FileChecksum` respectively. +```python +# aniparse.parse('[Group] Toradora! - 01.mkv', folder='/anime/Toradora') +{ + 'file_name': '[Group] Toradora! - 01.mkv', + 'file_extension': 'mkv', + 'release_group': ['Group'], + 'series': [ + {'title': 'Toradora!', 'episode': [{'number': 1}]}, + ], + 'folder_name': '/anime/Toradora', +} -```text -"Aim", "For", "The", "Top!", "Gunbuster-ep1", "KAA" +# aniparse.parse('01 - surge.mkv', path='series s1+s2+s3/season1/01 - surge.mkv') +{ + 'file_name': '01 - surge.mkv', + 'file_extension': 'mkv', + 'series': [{ + 'episode': [{'number': 1, 'title': 'surge'}], + 'title': 'series', + 'season': [{'number': 1}], + }], + 'folder_name': 'series s1+s2+s3/season1', +} ``` -The next step is to look for the episode number. Each token that contains a number is analyzed. Here. -`Gunbuster-ep1` contains number, but it doesn't match the episode number pattern. In this case, -the token checked againts buggy dash pattern. So, `Gunbuster-ep1` will be split into `Gunbuster` and `ep1`. -After that, it will check and `ep1` is recognized as an episode number. -The category `EpisodeNumber` is assigned to it and the changes is saved. +### Custom instance -```text -"Aim", "For", "The", "Top!", "Gunbuster", "KAA" -``` +For repeated parsing with custom settings: -The next step is to look for the anime title. The parser will try to find unknown token before the episode number and -not inside a bracket. -In this case, `Aim`, `For`, `The`, `Top!`, and `Gunbuster` are unknown tokens, they are not inside a bracket, so it -assigned to the `AnimeTitle` category. +```python +from aniparse import Aniparse, ParserConfig -```text -"KAA" +parser = Aniparse(config=ParserConfig(fuzzy=True)) +result = parser.parse('[Group] Title - 01 [1080p].mkv') ``` -the next step is to look for the release group. The parser will try to find unknown token after the episode number and -inside a bracket. -In this case, `KAA` is unknown token, and it inside a bracket, so it assigned to the `ReleaseGroup` category. +### Custom keywords -```text +Provide your own `WordListManager` to extend or replace the built-in keyword lists: +```python +from aniparse import Aniparse, WordListManager + +parser = Aniparse(wordlist_provider=my_wordlist_manager) +result = parser.parse(filename) ``` -the next step is to look for the episode title. The parser will try to find unknown token after the episode number and -not inside a bracket. -In this case, no more unknown token left, so it leave it empty +### Debug mode -```text +Pass `debug=True` to include the token scoring breakdown in the output: +```python +aniparse.parse(filename, debug=True) ``` -lastly, the parser will try to find any unknown token and assign it to each category or to `Others` if it is not -recognized. +## Output structure -# Why should I use it? +The output is a flat dict with these top-level keys: -Anime video files are commonly named in a format where the anime title is followed by the episode number, -and all the technical details are enclosed within brackets. -However, fansub groups tend to use their own naming conventions, -and the problem is more complicated than it first appears: +| Key | Type | Description | +|---|---|---| +| `file_name` | `str` | Original input filename | +| `file_extension` | `str` | File extension | +| `file_checksum` | `str` | CRC32 checksum (e.g. `1234ABCD`) | +| `file_index` | `int` | File index number | +| `series` | `list[SeriesInfo]` | Series metadata (title, episodes, seasons, etc.) | +| `audio_term` | `list[str]` | Audio codec terms (FLAC, AAC, etc.) | +| `video_term` | `list[str]` | Video codec terms (H.264, x265, etc.) | +| `video_resolution` | `list[VideoResolution]` | Resolution info (height, width, scan method) | +| `source` | `list[str]` | Source terms (Blu-ray, WEB-DL, etc.) | +| `release_group` | `list[str]` | Release group names | +| `release_information` | `list[str]` | Release info (BATCH, REMASTER, etc.) | +| `release_version` | `list[str]` | Version strings | +| `language` | `list[str]` | Language tags | +| `subs_term` | `list[str]` | Subtitle terms (Subbed, Hardsub, etc.) | +| `device_compatibility` | `list[str]` | Device compatibility tags | -Element order is not always the same. -Technical information is not guaranteed to be enclosed. -Brackets and parentheses may be grouping symbols or a part of the anime/episode title. -Space and underscore are not the only delimiters in use. -A single filename may contain multiple delimiters. -There are so many cases to cover that it's simply not possible to parse all filenames solely with -regular expressions. Aniparse tries a different approach, and it succeeds: -It's able to parse tens of thousands of filenames, with great accuracy. +Each `SeriesInfo` contains: -# Are there any exceptions? +| Key | Type | Description | +|---|---|---| +| `title` | `str` | Series title | +| `type` | `str` | Series type (OVA, Movie, TV, Special, etc.) | +| `year` | `list[Sequence]` | Year(s) | +| `season` | `list[Sequence]` | Season number(s) | +| `episode` | `list[Sequence]` | Episode number(s), with optional `title`, `release_version`, `part` | +| `volume` | `list[Sequence]` | Volume number(s) | +| `content_type` | `list[Sequence]` | Content type (NCOP, NCED, PV, etc.) with optional `identifier` | -Yes, unfortunately. Aniparse fails to identify the anime title and episode number on rare occasions, -mostly due to bad naming conventions. See the examples below. +Episode/season/volume entries support ranges (`start`/`end`), totals (`number`, `total` for "X of Y"), and alternatives. -Arigatou.Shuffle!.Ep08.[x264.AAC][D6E43829].mkv -Here, Aniparse would report that this file is the 8th episode of `Arigatou Shuffle!`, where `Arigatou` is actually the -name of the fansub group. +Only present keys are included — `None` values are omitted. -Spice and Wolf 2 -Is this the 2nd episode of `Spice and Wolf`, or a batch release of `Spice and Wolf 2`? with a text after number, there's -no way to know. It's up to you consider both cases. For current version, it treats as part of title if it's not leading zero, -and as episode number if it's leading zero. +## Configuration -## Suggestions to fansub groups +`ParserConfig` options: -Please consider abiding by these simple rules before deciding on your naming convention: +| Attribute | Type | Default | Description | +|---|---|---|---| +| `year_min` | `int` | `1900` | Minimum valid year | +| `year_max` | `int` | `2099` | Maximum valid year | +| `range_total` | `set[str]` | `{"of"}` | Connectors for "X of Y" patterns | +| `range_separator` | `set[str]` | `{"-", "~", "&", "+"}` | Range delimiters | +| `fuzzy` | `bool` | `False` | Enable fuzzy keyword matching | +| `fuzzy_threshold` | `float` | `0.8` | Fuzzy match threshold | -- Don't enclose anime title, episode number and episode title within brackets. Enclose everything else, including the - name of your group. -- Don't use parentheses to enclose release information; use square brackets instead. Parentheses should only be used if - they are a part of the anime/episode title. -- Don't use multiple delimiters in a single filename. If possible, stick with either space or underscore. -- Use a separator (e.g. a dash) between anime title and episode number. There are anime titles that end with a number, - which creates ambiguity. -- Indicate the episode interval in batch releases. +## How does it work? -## Installation +Aniparse processes filenames through a six-stage pipeline: -To install Aniparse, simply use pip: +1. **Tokenize** — Split input into tokens, detecting brackets, delimiters, and text boundaries +2. **Identify** — Match tokens against keyword lists, assigning initial possibilities with base scores +3. **Expand** — Pattern-based rules add new possibilities (checksums, numbers, years, titles, etc.) +4. **Score** — Context-aware rules adjust confidence based on position, neighbors, brackets, and structural zones +5. **Resolve** — Pick the winning possibility per token based on highest score +6. **Compose** — Assemble tokens into the final metadata dict -```commandline -pip install aniparse -``` -Or download the source code and inside the source code's folder run: +This approach avoids hardcoded rules like "first bracket = release group". Instead, each token accumulates evidence from multiple signals, and the highest-confidence interpretation wins. -```commandline -python setup.py install -``` -Options -------- +## Why use Aniparse? -The `parse` function can receive the `options` parameter. E.g.: +Anime filenames are notoriously inconsistent: -```python +- Element order varies between groups +- Brackets and parentheses may be metadata containers or part of the title +- Multiple delimiter styles coexist in a single filename +- Numbers are ambiguous (episode? season? year? resolution?) -import aniparse +Regex-based parsers can't cover the combinatorial explosion of conventions. Aniparse's scoring approach handles tens of thousands of filenames with high accuracy. -aniparse_options = {'allowed_delimiters': ' '} -aniparse.parse('DRAMAtical Murder Episode 1 - Data_01_Login', options=aniparse_options) -{ - 'anime_title': 'DRAMAtical Murder', - 'episode_prefix': 'Episode', - 'episode_number': '1', - 'episode_title': 'Data_01_Login', - 'file_name': 'DRAMAtical Murder Episode 1 - Data_01_Login' -} -``` -If the default options had been used, the parser would have considered `_` as a delimiter and replaced it with space in -the episode title. - -The options contain the following attributes: - - -| **Attribute name** | **Type** | **Description** | **Default value** | -| -------------------- | --------------- | --------------------------------------------------------------- | ----------------- | -| allowed_delimiters | string | The list of character to be considered as delimiters. | ' _.&+,|' | -| check_title_enclosed | boolean | Check the anime title in enclosed if no title found | True | -| eps_lower_than_alt | boolean | Set episode number to the lowest and the alt to be the highest | True | -| ignored_dash | boolean | If the dash in anime/episode title should be ignored or not. | True | -| ignored_strings | list of strings | A list of strings to be removed from the filename during parse. | [] | -| keep_delimiters | boolean | If the delimiters should be kept or not in anime/episode title. | False | -| max_extension_length | integer | Maximum extension length. | 4 | -| title_before_episode | boolean | If the anime title should be before the episode number or not. | True | +## Known limitations + +- Single-letter "E" episode prefix can be too aggressive in brackets +- Number-dash-number in titles (e.g., `009-1`) may be parsed as episode ranges +- CJK language descriptors may be included in the title +- Parenthesized alternative series after metadata may not be detected ## License diff --git a/aniparse/__init__.py b/aniparse/__init__.py index c4f7515..f0fb01d 100644 --- a/aniparse/__init__.py +++ b/aniparse/__init__.py @@ -1,41 +1,7 @@ -import logging +"""Aniparse — Anime filename parser.""" +from aniparse.api import Aniparse, parse +from aniparse.config import ParserConfig +from aniparse.output_schemas import Metadata +from aniparse.wordlist import WordListManager -from aniparse.element import ElementCategory -from aniparse.keyword import KeywordManager -from aniparse.aniparse import Aniparse - -logger = logging.getLogger(__name__) - -__all__ = ['parse'] - -default_options = { - 'allowed_delimiters': ' _.&+,|', - 'check_title_enclosed': True, - 'eps_lower_than_alt': True, - 'ignored_dash': True, - 'ignored_strings': [], - 'keep_delimiters': False, - 'max_extension_length': 4, - 'title_before_episode': True, - 'season_part_as_unique': True, -} - - -def parse(filename, options=None, ignore_errors=True, keyword_manager: KeywordManager = KeywordManager()): - if options is None: - options = {} - - # Add missing options - for key, value in default_options.items(): - options.setdefault(key, value) - try: - parser = Aniparse(filename, options, keyword_manager) - parser.parse() - return parser.populate() - except Exception as e: - logger.error('Error parsing {}: {}'.format(filename, e)) - if ignore_errors: - return { - ElementCategory.FILE_NAME.value: filename - } - raise +__all__ = ["parse", "Aniparse", "ParserConfig", "Metadata", "WordListManager"] diff --git a/aniparse/abstraction/__init__.py b/aniparse/abstraction/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aniparse/abstraction/keyword_base.py b/aniparse/abstraction/keyword_base.py new file mode 100644 index 0000000..b97a961 --- /dev/null +++ b/aniparse/abstraction/keyword_base.py @@ -0,0 +1,77 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Dict, Set, Iterator + +import unicodedata + +from aniparse.core.token_tags import Tag + + +@dataclass +class ElementEntry: + """Represents a single entry in a fuzzy keyword database for filename parsing.""" + + word: str + """The original, non-normalized form of the word. """ + + categories: Set[Tag] + """The set of categories this word can belong to.""" + + canonical: str = "" + """Normalized output form. E.g. 'MOVIES' → canonical='Movie'. If empty, uses word as-is.""" + + regex_dict: Dict[str, Dict[int, Set[Tag]]] = field(default_factory=dict) + """A dictionary of precompiled regex patterns and their associated categories. + + If a word matches a pattern, the corresponding categories are applied. + """ + + def merge(self, other: 'ElementEntry') -> 'bool': + """Merges two ElementEntry objects if their words are exactly the same. + + :parameter + other: The other ElementEntry to merge with. + + :return + True if the entries are merged, and False otherwise. + """ + if self.word == other.word: + self.categories.update(other.categories) + self.regex_dict.update(other.regex_dict) + return True + return False + + +class WordListProvider(ABC): + """Abstract interface for a provider of word lists for filename parsing.""" + + @abstractmethod + def find(self, normalized_word: str, fuzzy: bool = False, threshold: float = 0.8) -> Iterator[ElementEntry]: + """Checks if a word exists in the word list, ignoring case. + + Args: + normalized_word: The normalized form of the word to check (e.g., lowercase, accents removed). + + Returns: + Return an interator for each element in the word list. + """ + pass + + @abstractmethod + def add_word(self, entry: ElementEntry) -> None: + """Adds a new word entry to the word list. + + Args: + entry: The ElementEntry object representing the new word. + """ + pass + + @staticmethod + def normalize(string: str) -> str: + """Normalizes a string for consistent comparison. + + This method should be implemented to match the normalization used for storing words. + """ + nfkd = unicodedata.normalize('NFKD', string) + without_accents = ''.join([c for c in nfkd if not unicodedata.combining(c)]) + return without_accents.upper() diff --git a/aniparse/abstraction/parser_base.py b/aniparse/abstraction/parser_base.py new file mode 100644 index 0000000..c70e9ad --- /dev/null +++ b/aniparse/abstraction/parser_base.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Type + +from aniparse.config import ParserConfig +from aniparse.wordlist import WordListManager +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.core.token import Tokens, Token + +if TYPE_CHECKING: + from aniparse.core.rhythm import DelimiterProfile + +logger = logging.getLogger(__name__) + + +class AbstractParser(ABC): + tokens: Tokens + _filename: str + word_list_manager: WordListManager + delimiter_profile: DelimiterProfile + config: ParserConfig + + @property + def filename(self): + return self._filename + + @abstractmethod + def process_entry(self, word: str, tokens: list[Token], entry: ElementEntry): + pass + + +class PossibilityRule(ABC): + @classmethod + @abstractmethod + def apply(cls, parser: AbstractParser): + pass + + +class Possibilities: + """Ordered collection of possibility rules.""" + + def __init__(self): + self._rules: list[Type[PossibilityRule]] = [] + + def add_rule(self, rule: Type[PossibilityRule]): + self._rules.append(rule) + + def apply_all(self, parser: AbstractParser): + for rule in self._rules: + rule.apply(parser) diff --git a/aniparse/abstraction/score_base.py b/aniparse/abstraction/score_base.py new file mode 100644 index 0000000..39e3149 --- /dev/null +++ b/aniparse/abstraction/score_base.py @@ -0,0 +1,106 @@ +from abc import ABC, abstractmethod +from typing import Type + +from aniparse.core.token import Token, Tokens +from aniparse.core.token_tags import Tag + + +class ScoreRule(ABC): + descriptorType: Tag = Tag.UNKNOWN + categoryType: Tag + + @classmethod + @abstractmethod + def apply(cls, token: Token, tokens: Tokens): + pass + + def __init_subclass__(cls): + if cls.descriptorType == Tag.UNKNOWN: + return + cls.categoryType = cls.descriptorType.group + + +class Score: + def __init__(self): + self.descriptor: dict[Tag, list[Type[ScoreRule]]] = {} + + def add_rule(self, descriptor: Tag, rule: Type[ScoreRule]): + self.descriptor.setdefault(descriptor, []).append(rule) + + def calculate(self, possibility: Tag, token: Token, tokens: Tokens): + for rule in self.descriptor.get(possibility, []): + rule.apply(token, tokens) + + +class MetadataScoreRule(ScoreRule): + """Base for metadata score rules (AudioTerm, VideoTerm, FileChecksum). + + Provides shared bracket-proximity and neighbor-boosting logic. + Subclasses set descriptorType and skip_labels. + """ + skip_labels: set[Tag] = set() + + @classmethod + def apply(cls, token: Token, tokens: Tokens): + cls._apply_in_bracket_rules(token, tokens) + cls._check_neighbor_token(token, tokens) + + @classmethod + def _should_skip(cls, token: Token) -> bool: + for label in cls.skip_labels: + if label in token.possibilities: + return True + return False + + @classmethod + def _apply_in_bracket_rules(cls, start_token: Token, tokens: Tokens): + for prev_token in tokens.loop_backward(start_token): + if cls._should_skip(prev_token): + continue + if Tag.BRACKET in prev_token.possibilities: + for next_token in tokens.loop_forward(start_token): + if cls._should_skip(next_token): + continue + if Tag.BRACKET in next_token.possibilities: + start_token.add_score(cls.categoryType, 0.25) + break + if Tag.DELIMITER not in next_token.possibilities: + break + start_token.add_score(cls.categoryType, 0.25) + break + if Tag.DELIMITER not in prev_token.possibilities: + break + + @classmethod + def _check_neighbor_token(cls, start_token: Token, tokens: Tokens): + for prev_token in tokens.loop_backward(start_token): + if cls._should_skip(prev_token): + continue + flag = False + for possibility in prev_token.possibilities: + if possibility in Tag.get_optional_info(): + if possibility == cls.categoryType: + continue + prev_token.add_score(possibility, 0.25) + flag = True + if flag: + start_token.add_score(cls.categoryType, 0.25) + break + if Tag.DELIMITER not in prev_token.possibilities: + break + + for next_token in tokens.loop_forward(start_token): + if cls._should_skip(next_token): + continue + flag = False + for possibility in next_token.possibilities: + if possibility in Tag.get_optional_info(): + if possibility == cls.categoryType: + continue + next_token.add_score(possibility, 0.25) + flag = True + if flag: + start_token.add_score(cls.categoryType, 0.25) + break + if Tag.DELIMITER not in next_token.possibilities: + break diff --git a/aniparse/aniparse.py b/aniparse/aniparse.py deleted file mode 100644 index 6df7167..0000000 --- a/aniparse/aniparse.py +++ /dev/null @@ -1,188 +0,0 @@ -import re - -from aniparse import parser_helper -from aniparse.element import ElementCategory -from aniparse.parser import Parser -from aniparse.token import TokenCategory - - -class Aniparse(Parser): - def __init__(self, filename, options, keywords_manager): - self.anime: dict = { - ElementCategory.FILE_NAME.value: filename - } - - super().__init__(filename, options, keywords_manager) - - def parse(self) -> None: - """ - Parse the filename, getting the elements and tokens - :return: - """ - self.remove_extension_from_filename() - - self.remove_ignored_strings_from_filename() - - if not self.filename: - self.anime = None - return # the anime is unknown - super().parse() - - def populate(self) -> dict: - """ - Populate the anime dictionary with the elements and tokens - :return: - """ - enclosed = False - others = [] - for token in self.tokens: - element = token.e_category - if token.t_category == TokenCategory.INVALID: - continue - - if token.t_category == TokenCategory.BRACKET: - enclosed = not enclosed - for t in others: - if t.e_category != ElementCategory.OTHER: - continue - this_element = t.e_category.value - if this_element in self.anime: - if not isinstance(self.anime[this_element], list): - self.anime[this_element] = [self.anime[this_element]] - self.anime[this_element].append(t.content) - else: - self.anime[this_element] = t.content - others = [] - continue - if token.t_category == TokenCategory.DELIMITER and token.e_category == ElementCategory.DELIMITER: - if others: - if token.content == "+": - content = "".join([t.content for t in others]) - others[0].content = content - token = others[0] - element = token.e_category.value - if element in self.anime: - if not isinstance(self.anime[element], list): - self.anime[element] = [self.anime[element]] - self.anime[element].append(token.content) - else: - self.anime[element] = token.content - others = [] - else: - others.append(token) - continue - - if token.t_category == TokenCategory.UNKNOWN or element == ElementCategory.UNKNOWN: - if self.options["ignored_dash"]: - if parser_helper.is_dash_character(token.content): - continue - token.e_category = ElementCategory.OTHER - others.append(token) - continue - - if others: - for t in others: - if t.e_category != ElementCategory.OTHER: - continue - if self.options["ignored_dash"]: - if parser_helper.is_dash_character(t.content): - continue - this_element = t.e_category.value - if this_element in self.anime: - if not isinstance(self.anime[this_element], list): - self.anime[this_element] = [self.anime[this_element]] - self.anime[this_element].append(t.content) - else: - self.anime[this_element] = t.content - others = [] - - if self.options["ignored_dash"]: - if parser_helper.is_dash_character(token.content): - continue - - if element in [ - ElementCategory.ANIME_SEASON, - ElementCategory.ANIME_YEAR, - ElementCategory.EPISODE_NUMBER, - ElementCategory.EPISODE_NUMBER_ALT, - ElementCategory.EPISODE_TOTAL, - ElementCategory.RELEASE_VERSION, - ElementCategory.VOLUME_NUMBER, - ElementCategory.FILE_INDEX - ]: - # set the content to be a number, or float if it has a decimal - try: - token.content = int(token.content) - except ValueError: - token.content = float(token.content) - - # if the element already in the dictionary, transform it into a list and append the new value - element = element.value - if element in self.anime: - if not isinstance(self.anime[element], list): - self.anime[element] = [self.anime[element]] - self.anime[element].append(token.content) - else: - self.anime[element] = token.content - if others: - for t in others: - if t.e_category != ElementCategory.OTHER: - continue - if self.options["ignored_dash"]: - if parser_helper.is_dash_character(t.content): - continue - this_element = t.e_category.value - if this_element in self.anime: - if not isinstance(self.anime[this_element], list): - self.anime[this_element] = [self.anime[this_element]] - self.anime[this_element].append(t.content) - else: - self.anime[this_element] = t.content - for token in self.batch: - self.anime.setdefault(ElementCategory.BATCH.value, []).append(token.content) - # clean up the anime dictionary - for element in [ElementCategory.ANIME_TITLE, - ElementCategory.ANIME_TITLE_ALT, - ElementCategory.EPISODE_TITLE]: - value = element.value - if isinstance(self.anime.get(value, None), list): - string = "".join(self.anime[value]).strip( - " " + parser_helper.DASHES if self.options["ignored_dash"] else "") # any similar dash - elif isinstance(self.anime.get(value, None), str): - string = self.anime.get(value, "") - else: - continue - # remove double spaces - string = re.sub(r"\s+", " ", string) - if string.isspace(): - self.anime.pop(value) - else: - self.anime[value] = string - - return self.anime - - def remove_extension_from_filename(self) -> None: - split_filename = self.filename.rsplit('.', 1) - - if len(split_filename) < 2: - return - - new_filename, extension = split_filename - - if len(extension) > self.options['max_extension_length']: - return - - # normalize the extension and check if it is a valid extension - keyword = self.keyword_manager.normalize(extension) - # only return if the extension is a valid video extension - if not self.keyword_manager.find(keyword, ElementCategory.FILE_EXTENSION): - return - - if extension: - self.anime[ElementCategory.FILE_EXTENSION.value] = extension - self.filename = new_filename - - def remove_ignored_strings_from_filename(self) -> None: - for string in self.options["ignored_strings"]: - self.filename = self.filename.replace(string, '') - diff --git a/aniparse/api.py b/aniparse/api.py new file mode 100644 index 0000000..3295cc1 --- /dev/null +++ b/aniparse/api.py @@ -0,0 +1,238 @@ +"""Public API: Aniparse class (configurable) and parse() convenience wrapper.""" +from __future__ import annotations + +import os +import re + +from aniparse import keyword +from aniparse.config import ParserConfig +from aniparse.core import constant +from aniparse.core.constant import DEFAULT_PROVIDER_NAME +from aniparse.core.defaults import build_default_score, build_default_possibilities +from aniparse.core.parser import Parser +from aniparse.wordlist import InMemoryWordListProvider, WordListManager + + +def _split_on_pipe(filename: str) -> list[str]: + """Split filename on | but not when inside brackets/parens.""" + segments = [] + current = [] + depth = 0 + for ch in filename: + if ch in constant.OPEN_BRACKETS: + depth += 1 + elif ch in constant.CLOSE_BRACKETS: + depth = max(0, depth - 1) + if ch == constant.PIPE and depth == 0: + segments.append(''.join(current).strip()) + current = [] + else: + current.append(ch) + segments.append(''.join(current).strip()) + return segments + + +def _build_default_wordlist() -> WordListManager: + provider = InMemoryWordListProvider() + manager = WordListManager() + manager.add_provider(DEFAULT_PROVIDER_NAME, provider) + for word in keyword.DEFAULT_KEYWORD: + provider.add_word(word) + return manager + + +def _parse_folder_segment(segment: str, wlm, config) -> dict | None: + """Parse a single folder segment and return its result dict.""" + segment = segment.strip() + if not segment: + return None + parser = Parser( + filename=segment, + word_list_manager=wlm, + possibilities=build_default_possibilities(), + score=build_default_score(), + config=config, + ) + return parser.parse(debug=False) + + +def _reinterpret_folder_result(result: dict) -> dict: + """Reinterpret folder parse results: bare episodes become seasons.""" + if not result or not result.get("series"): + return result + series = result["series"][0] + # A folder with only episode(s) and no title/season/type → treat as season + has_title = "title" in series + has_season = "season" in series + has_type = "type" in series + has_episode = "episode" in series + if has_episode and not has_title and not has_season and not has_type: + series["season"] = series.pop("episode") + return result + + +def _merge_folder_context(result: dict, folder: str, wlm, config): + """Parse folder segments and gap-fill into the filename result.""" + segments = re.split(r'[/\\]', folder) + segments = [s for s in segments if s.strip()] + if not segments: + return + + # Parse each segment outermost-first, layer into combined context + # Inner segments override outer for same series-level fields + combined_series = {} + _SERIES_FIELDS = ("title", "season", "year", "type") + + for seg in segments: + seg_result = _parse_folder_segment(seg, wlm, config) + if not seg_result: + continue + seg_result = _reinterpret_folder_result(seg_result) + if seg_result.get("series"): + seg_series = seg_result["series"][0] + for field in _SERIES_FIELDS: + if field in seg_series: + combined_series[field] = seg_series[field] + + if not combined_series: + return + + # Gap-fill into result's series[0] + result.setdefault("series", [{}]) + file_series = result["series"][0] + for field in _SERIES_FIELDS: + if field in combined_series and field not in file_series: + file_series[field] = combined_series[field] + + result["folder_name"] = folder + + +class Aniparse: + """Configurable anime filename parser. + + Example:: + + # Custom instance + parser = Aniparse(wordlist_provider=my_wlm) + result = parser.parse("[Group] Title - 01 [1080p].mkv") + + # Or just use the module-level convenience function + import aniparse + result = aniparse.parse("[Group] Title - 01 [1080p].mkv") + """ + + def __init__(self, wordlist_provider: WordListManager = None, + config: ParserConfig = None): + self._wlm = wordlist_provider or _build_default_wordlist() + self._config = config or ParserConfig() + + def parse(self, filename: str, *, folder: str = None, path: str = None, + debug: bool = False) -> dict | None: + """Parse an anime filename and return structured metadata. + + Args: + filename: The filename to parse. + folder: Optional folder name for context. + path: If provided, splits into folder + filename automatically. + debug: If True, include token scoring breakdown in output. + + Returns: + Metadata dict matching the output.ts schema, or None if empty. + """ + if path and not folder: + folder = os.path.dirname(path) + filename = os.path.basename(path) if not filename else filename + + score = build_default_score() + possibilities = build_default_possibilities() + + # Split on | for alternative titles + segments = [s.strip() for s in filename.split(constant.PIPE)] + primary = segments[0] + alternatives = segments[1:] if len(segments) > 1 else [] + + # Strip trailing unclosed parenthesis from primary when it resulted from + # splitting on | inside parens (e.g., "(TRIGUN STARGAZE | Season 2 | S2)") + _open_paren = constant.OPEN_PARENS[0] # ASCII '(' + _close_paren = constant.BRACKET_PAIRS[_open_paren] + if alternatives and primary.count(_open_paren) > primary.count(_close_paren): + last_open = primary.rfind(_open_paren) + trailing = primary[last_open + 1:].strip() + # Only strip if the trailing content looks like a title (no common metadata keywords) + trailing_words = set(trailing.lower().split()) + if trailing and not (trailing_words & constant.BASE_METADATA_INDICATORS): + primary = primary[:last_open].strip() + alternatives.insert(0, trailing) + # Clean alternatives: strip unmatched closing brackets + _close_chars = ''.join(constant.CLOSE_BRACKETS) + alternatives = [a.rstrip(_close_chars).strip() for a in alternatives] + + parser = Parser( + filename=primary, + word_list_manager=self._wlm, + possibilities=possibilities, + score=score, + config=self._config, + ) + + result = parser.parse(debug=debug) + if result is None: + return None + + result["file_name"] = filename + + if alternatives: + for alt in alternatives: + alt = alt.strip() + if not alt: + continue + alt_parser = Parser( + filename=alt, + word_list_manager=self._wlm, + possibilities=build_default_possibilities(), + score=build_default_score(), + config=self._config, + ) + alt_result = alt_parser.parse(debug=False) + if alt_result and alt_result.get("series"): + alt_series = alt_result["series"][0] + alt_entry = {} + if "title" in alt_series: + alt_entry["title"] = alt_series["title"] + if alt_entry: + result.setdefault("series", []).append(alt_entry) + + if folder: + _merge_folder_context(result, folder, self._wlm, self._config) + + return result + + +# Lazy default instance +_default: Aniparse | None = None + + +def parse(filename: str, *, folder: str = None, path: str = None, + wordlist_provider: WordListManager = None, + debug: bool = False) -> dict | None: + """Convenience wrapper using a default Aniparse instance. + + Args: + filename: The filename to parse. + folder: Optional folder name for context. + path: If provided, splits into folder + filename automatically. + wordlist_provider: Custom WordListManager. Uses default if None. + debug: If True, include token scoring breakdown in output. + + Returns: + Metadata dict matching the output.ts schema, or None if empty. + """ + if wordlist_provider: + return Aniparse(wordlist_provider=wordlist_provider).parse( + filename, folder=folder, path=path, debug=debug + ) + + global _default + if _default is None: + _default = Aniparse() + return _default.parse(filename, folder=folder, path=path, debug=debug) diff --git a/aniparse/config.py b/aniparse/config.py new file mode 100644 index 0000000..a23247e --- /dev/null +++ b/aniparse/config.py @@ -0,0 +1,36 @@ +"""User-configurable parser settings. + +These defaults are language-dependent and can be overridden per Aniparse instance. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class ParserConfig: + """Configuration for locale-dependent parsing behaviour.""" + + year_min: int = 1900 + year_max: int = 2099 + + range_total: set[str] = field(default_factory=lambda: {"of"}) + range_separator: set[str] = field(default_factory=lambda: {"-", "~", "&", "+"}) + range_connectors: set[str] = field(default_factory=lambda: {"&", "+"}) + + fuzzy: bool = False + fuzzy_threshold: float = 0.8 + + ordinal_suffixes: tuple[str, ...] = ('st', 'nd', 'rd', 'th') + + ordinals: dict[str, str] = field(default_factory=lambda: { + 'first': '1', + 'second': '2', + 'third': '3', + 'fourth': '4', + 'fifth': '5', + 'sixth': '6', + 'seventh': '7', + 'eighth': '8', + 'ninth': '9', + }) diff --git a/aniparse/core/__init__.py b/aniparse/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aniparse/core/compose.py b/aniparse/core/compose.py new file mode 100644 index 0000000..a4c0391 --- /dev/null +++ b/aniparse/core/compose.py @@ -0,0 +1,1186 @@ +"""Assemble resolved tokens into a Metadata output object.""" +from __future__ import annotations + +import os +import re +from typing import Optional + +from aniparse.config import ParserConfig +from aniparse.core import constant +from aniparse.rule.possibilities.number_rule import get_number_from_ordinal +from aniparse.core.token import Tokens, Token +from aniparse.core.token_tags import Tag +from aniparse.core.rhythm import DelimiterProfile +from aniparse.output_schemas import ( + Metadata, SeriesInfo, VideoResolution, SequenceNumber, + SequenceIdentifierNumber, SequenceRange, +) + + +def compose(tokens: Tokens, filename: str, delimiter_profile: DelimiterProfile = None, config: ParserConfig = None) -> Metadata: + """Assemble resolved tokens into a Metadata object.""" + # --- Collect raw values into plain lists/vars --- + title_tokens: list[Token] = [] + checksum_tokens: list[Token] = [] + season_numbers: list[dict] = [] + season_tokens: list[Token] = [] + episode_numbers: list[dict] = [] + episode_tokens: list[Token] = [] + volume_numbers: list[dict] = [] + volume_tokens: list[Token] = [] + content_type_entries: list[dict] = [] + series_type: Optional[str] = None + episode_title_tokens: list[Token] = [] + unknown_tokens: list[str] = [] + release_information: list[str] = [] + release_version: list[str] = [] + subs_term: list[str] = [] + language: list[str] = [] + device_compatibility: list[str] = [] + other: list[str] = [] + sequence_prefix_context: Optional[Tag] = None + last_content_type_identifier: Optional[str] = None + last_number_target: Optional[Tag] = None + file_index: Optional[int] = None + alt_series_list: list[dict] = [] + + token_list = list(tokens) + in_bracket = False + skip_next_prefix = False + consumed_tokens: set[int] = set() + + for i, token in enumerate(token_list): + if id(token) in consumed_tokens: + continue + label = token.category + poss = token.possibilities.get(label) + descriptor = poss.descriptor if poss else None + + if label == Tag.BRACKET: + if token.content in constant.OPEN_BRACKETS: + in_bracket = True + elif token.content in constant.CLOSE_BRACKETS: + in_bracket = False + continue + + if label == Tag.TITLE: + if descriptor == Tag.SEQUENCE_TITLE: + episode_title_tokens.append(token) + else: + title_tokens.append(token) + + elif label == Tag.TYPE: + type_val = _normalize_type(token) + if in_bracket: + # Bracketed TYPE (e.g., "(TV)") → series type qualifier + if not series_type: + series_type = type_val + else: + # Freestanding TYPE (e.g., "OVA" after brackets) → could be content_type + if series_type: + # Already have a series type — this is content type + sequence_prefix_context = Tag.CONTENT_TYPE + last_content_type_identifier = type_val + else: + series_type = type_val + + elif label == Tag.SEQUENCE_PREFIX: + if skip_next_prefix: + skip_next_prefix = False + continue + if descriptor in (Tag.SEASON,): + sequence_prefix_context = Tag.SEASON + elif descriptor in (Tag.EPISODE,): + sequence_prefix_context = Tag.EPISODE + elif descriptor in (Tag.VOLUME,): + sequence_prefix_context = Tag.VOLUME + elif descriptor in (Tag.CONTENT_TYPE, Tag.CONTENT_IDENTIFIER): + if sequence_prefix_context == Tag.CONTENT_TYPE and last_content_type_identifier: + last_content_type_identifier = last_content_type_identifier.strip() + constant.SPACE + token.content + else: + sequence_prefix_context = Tag.CONTENT_TYPE + last_content_type_identifier = token.content + else: + sequence_prefix_context = Tag.EPISODE + + elif label == Tag.SEQUENCE_NUMBER: + num = _try_int(token.content) + # For ordinals (e.g. "2nd"), extract the number part via config + if num is None and config: + ordinal_val = get_number_from_ordinal(token.content, config) + if ordinal_val.isdigit(): + num = int(ordinal_val) + # Look ahead for SEQUENCE_PREFIX to set context + for j in range(i + 1, len(token_list)): + ft = token_list[j] + if ft.category == Tag.DELIMITER: + continue + if ft.category == Tag.SEQUENCE_PREFIX: + fp = ft.possibilities.get(ft.category) + fd = fp.descriptor if fp else None + if fd in (Tag.SEASON,): + sequence_prefix_context = Tag.SEASON + elif fd in (Tag.VOLUME,): + sequence_prefix_context = Tag.VOLUME + else: + sequence_prefix_context = Tag.EPISODE + skip_next_prefix = True + break + entry: dict[str, object] = {"number": num if num is not None else token.content} + + # Token's own descriptor overrides prefix context (e.g. SxEE marks first number as SEASON) + num_poss = token.possibilities.get(Tag.SEQUENCE_NUMBER) + num_descriptor = num_poss.descriptor if num_poss else None + if num_descriptor == Tag.SEASON or sequence_prefix_context == Tag.SEASON: + season_numbers.append(entry) + season_tokens.append(token) + last_number_target = Tag.SEASON + elif sequence_prefix_context == Tag.VOLUME: + volume_numbers.append(entry) + volume_tokens.append(token) + last_number_target = Tag.VOLUME + elif sequence_prefix_context == Tag.CONTENT_TYPE: + ct_entry: dict[str, object] = {"number": num if num is not None else token.content} + if last_content_type_identifier: + ct_entry["identifier"] = last_content_type_identifier + content_type_entries.append(ct_entry) + last_content_type_identifier = None + last_number_target = Tag.CONTENT_TYPE + else: + # Check if this is an alternative episode (bracketed number when episode already exists + # from OUTSIDE this bracket group) + if in_bracket and episode_numbers and _has_episode_outside_bracket(token_list, i, episode_tokens): + # Check if bracket also has unknown text (= alternative series title) + alt_title = _get_bracket_unknown_text(token_list, i) + if alt_title: + # This is an alternative series, not just an alternative episode + alt_series_list.append({"title": alt_title, "episode": [entry]}) + last_number_target = Tag.EPISODE + sequence_prefix_context = None + continue + if "alternative" not in episode_numbers[-1]: + episode_numbers[-1]["alternative"] = [] + episode_numbers[-1]["alternative"].append(entry) + last_number_target = Tag.EPISODE + sequence_prefix_context = None + continue + episode_numbers.append(entry) + episode_tokens.append(token) + last_number_target = Tag.EPISODE + # Keep context if next token is a connector (&, +) + next_meaningful = _next_non_delimiter(token_list, i) + if next_meaningful and (next_meaningful.content in config.range_connectors + or next_meaningful.category == Tag.SEQUENCE_RANGE): + pass # keep sequence_prefix_context for the next number + else: + sequence_prefix_context = None + + elif label == Tag.SEQUENCE_PART: + if not (len(token.content) > 1 and token.content.isalpha()): + # Direct part value (digit/letter from glued forms) — attach to most recent entry + if last_number_target == Tag.CONTENT_TYPE and content_type_entries: + content_type_entries[-1]["part"] = token.content + elif episode_numbers: + episode_numbers[-1]["part"] = token.content + elif in_bracket and episode_numbers: + # "Part" word followed by a number → attach to last episode/alternative + next_tok = _next_non_delimiter(token_list, i) + if next_tok and next_tok.content.isdigit(): + last_ep = episode_numbers[-1] + alts = last_ep.get("alternative", []) + if alts: + alts[-1]["part"] = next_tok.content + else: + last_ep["part"] = next_tok.content + consumed_tokens.add(id(next_tok)) + + elif label == Tag.RELEASE_VERSION: + # Named version marker (e.g., "yorihime ver") — use preceding unknown as version value + ver_value = token.content + if not token.content[0].isdigit(): + prev_unknown = None + for j in range(i - 1, -1, -1): + pt = token_list[j] + if pt.category in (Tag.DELIMITER, Tag.CONTEXT_DELIMITER): + continue + if pt.category == Tag.UNKNOWN and not pt.possibilities: + prev_unknown = pt + break + if prev_unknown: + ver_value = prev_unknown.content + # Remove from unknown_tokens since it's now a version name + if prev_unknown.content in unknown_tokens: + unknown_tokens.remove(prev_unknown.content) + release_version.append(ver_value) + if last_number_target == Tag.CONTENT_TYPE and content_type_entries: + content_type_entries[-1]["release_version"] = ver_value + elif sequence_prefix_context == Tag.CONTENT_TYPE and last_content_type_identifier: + # Pending content_type without number (e.g., "EDv2") + content_type_entries.append({ + "identifier": last_content_type_identifier, + "release_version": ver_value, + }) + last_content_type_identifier = None + sequence_prefix_context = None + elif last_number_target == Tag.VOLUME and volume_numbers: + volume_numbers[-1]["release_version"] = ver_value + elif episode_numbers: + episode_numbers[-1]["release_version"] = ver_value + + elif label == Tag.FILE_CHECKSUM: + checksum_tokens.append(token) + + elif label == Tag.FILE_INDEX: + idx = _try_int(token.content) + file_index = idx if idx is not None else token.content + + elif label == Tag.RELEASE_INFORMATION: + release_information.append(token.content) + + elif label == Tag.DEVICE_COMPATIBILITY: + device_compatibility.append(token.content) + + elif label == Tag.EXTRA_INFO: + other.append(token.content) + + elif label == Tag.UNKNOWN: + if token.content.strip(): + unknown_tokens.append(token.content) + + if last_content_type_identifier and sequence_prefix_context == Tag.CONTENT_TYPE: + content_type_entries.append({"identifier": last_content_type_identifier}) + + episode_numbers = _merge_decimals(episode_numbers, token_list) + episode_numbers = _detect_ranges(episode_numbers, token_list, episode_tokens, config) + episode_numbers = _lift_alternatives_to_range(episode_numbers, token_list) + episode_numbers = _dedup_episodes(episode_numbers) + + season_numbers = _dedup_episodes(season_numbers) # reuse for season dedup + season_numbers = _detect_ranges(season_numbers, token_list, season_tokens, config) + + volume_numbers = _detect_ranges(volume_numbers, token_list, volume_tokens, config) + + if release_version and episode_numbers: + last_ep = episode_numbers[-1] + if isinstance(last_ep, dict) and "start" not in last_ep: + last_ep["release_version"] = release_version[0] if len(release_version) == 1 else (constant.COMMA + constant.SPACE).join(release_version) + elif isinstance(last_ep, dict) and "end" in last_ep: + last_ep["end"]["release_version"] = release_version[0] if len(release_version) == 1 else (constant.COMMA + constant.SPACE).join(release_version) + + # --- Grouped label merging --- + audio_term: list[str] = [] + video_term: list[str] = [] + source: list[str] = [] + for label_key, target in [ + (Tag.AUDIO_TERM, audio_term), + (Tag.VIDEO_TERM, video_term), + (Tag.SOURCE, source), + (Tag.SUBS_TERM, subs_term), + (Tag.LANGUAGE, language), + ]: + groups = _group_adjacent_by_label(tokens, label_key) + if groups: + target.extend(_merge_adjacent_tokens(g, delimiter_profile) for g in groups) + + if audio_term: + audio_term = _normalize_audio_terms(audio_term) + + if subs_term: + subs_term = _normalize_subs_terms(subs_term) + + # Filter CJK-only language entries (redundant with English equivalents) + if language: + language = [l for l in language if re.search(r'[a-zA-Z]', l)] + # Deduplicate (case-insensitive, preserve first occurrence) + seen_lang = set() + deduped = [] + for l in language: + key = l.lower() + if key not in seen_lang: + seen_lang.add(key) + deduped.append(l) + language = deduped + + video_resolution_dicts = _build_video_resolutions(tokens) + + release_group: list[str] = [] + rg_groups = _group_adjacent_by_label(tokens, Tag.RELEASE_GROUP) + if rg_groups: + for g in rg_groups: + # Split token group on & separators + subgroups = _split_release_group_on_ampersand(g) + keep_us = len(subgroups) > 1 # keep underscores when & split + for sg in subgroups: + merged = _merge_release_group_tokens(sg, keep_underscores=keep_us) + if merged.strip(): + release_group.append(merged.strip()) + + file_checksum: Optional[str] = None + if checksum_tokens: + file_checksum = "".join(t.content for t in checksum_tokens) + + # --- Build SeriesInfo --- + series_info_dict: dict = {} + + if title_tokens: + merged_title = _merge_title_tokens(title_tokens, delimiter_profile=delimiter_profile).strip() + # Append trailing period if glued to last title token and followed by + # context delimiter (e.g., "yatte mita. - 01"), but not when period is + # the primary delimiter (dot-separated filenames like "dragon.ball.kai.-.01") + if not (delimiter_profile and delimiter_profile.primary == constant.DOT): + last_tt = title_tokens[-1] + trailing_idx = last_tt.index + len(last_tt.content) + for t in token_list: + if t.index == trailing_idx and t.content == constant.DOT and t.category == Tag.DELIMITER: + # Only if next non-space token is context delimiter or bracket + for t2 in token_list: + if t2.index <= t.index: + continue + if t2.category == Tag.DELIMITER and t2.content.strip() == "": + continue + if t2.category in (Tag.CONTEXT_DELIMITER, Tag.BRACKET): + merged_title += constant.DOT + break + break + series_info_dict["title"] = merged_title + + if episode_numbers and title_tokens: + episode_numbers, _ = _absorb_glued_episodes_into_title( + title_tokens, episode_numbers, token_list, series_info_dict, delimiter_profile + ) + + if series_type: + series_info_dict["type"] = series_type + + year_entries: list[dict] = [] + year_tokens: list[Token] = [] + for token in tokens: + if token.category == Tag.YEAR: + num = _try_int(token.content) + if num is not None: + year_entries.append({"number": num}) + year_tokens.append(token) + year_entries = _detect_ranges(year_entries, token_list, year_tokens, config) + if year_entries: + series_info_dict["year"] = year_entries + + if season_numbers: + series_info_dict["season"] = season_numbers + if episode_numbers: + series_info_dict["episode"] = episode_numbers + if volume_numbers: + series_info_dict["volume"] = volume_numbers + if content_type_entries: + series_info_dict["content_type"] = content_type_entries + + if episode_title_tokens: + # Check if these tokens are between season and episode (→ season title) + is_season_title = False + if season_tokens and episode_tokens: + last_season_idx = season_tokens[-1].index + first_ep_idx = episode_tokens[0].index + ep_title_indices = [t.index for t in episode_title_tokens + if Tag.DELIMITER not in t.possibilities + and Tag.CONTEXT_DELIMITER not in t.possibilities] + if ep_title_indices and last_season_idx < ep_title_indices[0] < first_ep_idx: + is_season_title = True + # Exclude the episode number from title tokens + episode_title_tokens = [t for t in episode_title_tokens + if Tag.SEQUENCE_NUMBER not in t.possibilities] + + ep_title = _merge_title_tokens(episode_title_tokens, replace_dashes=True, delimiter_profile=delimiter_profile).strip() + # Normalize doubled single-quotes to single quotes (e.g., ''word'' → 'word') + ep_title = ep_title.replace(constant.APOSTROPHE * 2, constant.APOSTROPHE) + # Append trailing period if glued to last episode title token + if episode_title_tokens: + last_ett = episode_title_tokens[-1] + ett_trailing_idx = last_ett.index + len(last_ett.content) + for t in token_list: + if t.index == ett_trailing_idx and t.content == constant.DOT and t.category == Tag.DELIMITER: + for t2 in token_list: + if t2.index <= t.index: + continue + if t2.category == Tag.DELIMITER and t2.content.strip() == "": + continue + if t2.category in (Tag.CONTEXT_DELIMITER, Tag.BRACKET): + ep_title += constant.DOT + break + break + if ep_title: + if is_season_title: + series_info_dict["season"][-1]["title"] = ep_title + elif "episode" in series_info_dict and series_info_dict["episode"]: + last_ep = series_info_dict["episode"][-1] + if isinstance(last_ep, dict): + last_ep["title"] = ep_title + elif "content_type" in series_info_dict and series_info_dict["content_type"]: + series_info_dict["content_type"][-1]["title"] = ep_title + elif "volume" in series_info_dict and series_info_dict["volume"]: + series_info_dict["volume"][-1]["title"] = ep_title + + # --- Expand +separated bracket components into multiple series --- + expanded = _expand_plus_separated_series( + series_info_dict, season_numbers, token_list, tokens, config) + if expanded: + series_info_dict = expanded[0] # base series (may be trimmed) + extra_series = expanded[1:] + else: + extra_series = [] + + # --- Convert intermediate dicts to dataclass instances --- + series_list: list[SeriesInfo] = [] + if series_info_dict: + series_list = [_dict_to_series_info(series_info_dict)] + for es in extra_series: + series_list.append(_dict_to_series_info(es)) + for alt in alt_series_list: + series_list.append(_dict_to_series_info(alt)) + + video_res_list: list[VideoResolution] = [] + if video_resolution_dicts: + video_res_list = [VideoResolution(**d) for d in video_resolution_dicts] + + from aniparse.core.constant import FILE_EXTENSIONS + name, ext = os.path.splitext(filename) + ext_val = ext.lstrip(constant.DOT).lower() if ext else "" + if ext_val not in FILE_EXTENSIONS: + ext_val = "" + + # Filter file extension from release_group + if ext_val and release_group: + release_group = [rg for rg in release_group if rg.lower() != ext_val] + if not release_group: + release_group = [] + + return Metadata( + file_name=filename, + file_extension=ext_val or None, + file_checksum=file_checksum, + file_index=file_index, + audio_term=audio_term or None, + device_compatibility=device_compatibility or None, + language=language or None, + video_resolution=video_res_list or None, + release_version=release_version or None, + release_group=release_group or None, + release_information=release_information or None, + series=series_list or None, + source=source or None, + subs_term=subs_term or None, + video_term=video_term or None, + other=other or None, + unknown=_group_unknown_tokens(tokens, unknown_tokens, delimiter_profile) or None, + ) + + +def _expand_plus_separated_series(series_info, season_numbers, token_list, tokens, config: ParserConfig = None): + """Expand bracket groups like (S01+S02+...+Movies+OVAs) into multiple series. + + Returns list of series dicts if expansion happened, else None. + """ + if not series_info or not series_info.get("title"): + return None + + # Find a bracket group containing + separated items mixing seasons and types + bracket_groups = _find_plus_bracket_groups(token_list, config) + if not bracket_groups: + return None + + for components in bracket_groups: + season_comps = [c for c in components if c["kind"] == Tag.SEASON] + type_or_text = [c for c in components if c["kind"] in (Tag.TYPE, Tag.TITLE)] + # Only expand when we have multiple individual seasons AND type/text entries + # (batch pattern like S01+S02+S03+S04+Movies+OVAs) + # Skip when there's a season range (Season 1-4 + OVA) — that's one series + if len(season_comps) < 2 or not type_or_text: + continue + + # Expand: each component becomes a separate series + title = series_info.get("title", "") + result = [] + for comp in components: + entry = {"title": title} + if comp["kind"] == Tag.SEASON: + entry["season"] = [{"number": comp["number"]}] + elif comp["kind"] == Tag.TYPE: + entry["type"] = comp["value"] + elif comp["kind"] == Tag.TITLE: + entry["title"] = title + constant.SPACE + comp["value"] + result.append(entry) + + return result + + return None + + +def _find_plus_bracket_groups(token_list, config: ParserConfig = None): + """Find bracket groups containing +-separated items.""" + results = [] + i = 0 + while i < len(token_list): + token = token_list[i] + if token.category == Tag.BRACKET and token.content in constant.OPEN_BRACKETS: + # Collect tokens inside bracket + inside = [] + j = i + 1 + while j < len(token_list): + t = token_list[j] + if t.category == Tag.BRACKET and t.content in constant.CLOSE_BRACKETS: + break + inside.append(t) + j += 1 + + # Check if this bracket has + separators + connectors = config.range_connectors if config else () + plus_count = sum(1 for t in inside if t.content in connectors) + if plus_count >= 2: + # Split on connectors into components + components = [] + current = [] + for t in inside: + if t.content in connectors: + comp = _classify_component(current) + if comp: + components.append(comp) + current = [] + else: + current.append(t) + comp = _classify_component(current) + if comp: + components.append(comp) + if len(components) >= 3: + results.append(components) + i = j + 1 + else: + i += 1 + return results + + +def _classify_component(tokens): + """Classify a +-separated component as season, type, or text.""" + meaningful = [t for t in tokens + if t.category not in (Tag.DELIMITER, Tag.CONTEXT_DELIMITER)] + if not meaningful: + return None + + # Season: SEQUENCE_PREFIX(season) + SEQUENCE_NUMBER + if (len(meaningful) >= 2 + and meaningful[0].category == Tag.SEQUENCE_PREFIX + and meaningful[1].category == Tag.SEQUENCE_NUMBER): + poss = meaningful[0].possibilities.get(Tag.SEQUENCE_PREFIX) + if poss and poss.descriptor == Tag.SEASON: + num = _try_int(meaningful[1].content) + return {"kind": Tag.SEASON, "number": num if num is not None else meaningful[1].content} + + # Type: TYPE token (possibly followed by plural 's') + if meaningful[0].category == Tag.TYPE: + # Reconstruct the full type name (e.g., "OVA" + "s" → "OVAs") + type_val = "".join(t.content for t in meaningful) + return {"kind": Tag.TYPE, "value": type_val} + + # Text: unknown tokens (like "Alternative") + text_parts = [t.content for t in meaningful if not t.possibilities or t.category == Tag.UNKNOWN] + if text_parts: + return {"kind": Tag.TITLE, "value": constant.SPACE.join(text_parts)} + + return None + + +def _dict_to_series_info(d: dict) -> SeriesInfo: + """Convert an intermediate series info dict to a SeriesInfo dataclass.""" + return SeriesInfo( + title=d.get("title"), + type=d.get("type"), + year=_convert_sequence_list(d.get("year")), + season=_convert_sequence_list(d.get("season")), + episode=_convert_sequence_list(d.get("episode")), + volume=_convert_sequence_list(d.get("volume")), + content_type=_convert_sequence_list(d.get("content_type"), identifier=True), + ) + + +def _convert_sequence_list(entries: list[dict] | None, identifier: bool = False): + """Convert a list of intermediate dicts to SequenceNumber/Range instances.""" + if not entries: + return None + result = [] + for entry in entries: + if "start" in entry and "end" in entry: + alt_dicts = entry.get("alternative", []) + alt_ranges = _convert_sequence_list(alt_dicts, identifier) if alt_dicts else [] + result.append(SequenceRange( + start=_dict_to_sequence(entry["start"], identifier), + end=_dict_to_sequence(entry["end"], identifier), + alternative=alt_ranges, + )) + else: + result.append(_dict_to_sequence(entry, identifier)) + return result + + +def _dict_to_sequence(d: dict, identifier: bool = False): + """Convert a single intermediate dict to a SequenceNumber or SequenceIdentifierNumber.""" + if identifier or "identifier" in d: + return SequenceIdentifierNumber( + number=d.get("number"), + identifier=d.get("identifier"), + total=d.get("total"), + release_version=d.get("release_version"), + title=d.get("title"), + part=d.get("part"), + ) + alt = d.get("alternative") + alt_list = [_dict_to_sequence(a, identifier) for a in alt] if alt else [] + return SequenceNumber( + number=d["number"], + total=d.get("total"), + release_version=d.get("release_version"), + title=d.get("title"), + part=d.get("part"), + alternative=alt_list, + ) + +def _absorb_glued_episodes_into_title(title_tokens, episode_numbers, token_list, series_info, + delimiter_profile=None): + last_title = title_tokens[-1] + last_title_end = last_title.index + len(last_title.content) + + seq_tokens = [t for t in token_list if t.category == Tag.SEQUENCE_NUMBER] + if not seq_tokens: + return episode_numbers, 0 + + first_ep_token = seq_tokens[0] + between_start = last_title_end + between_end = first_ep_token.index + + if between_end - between_start != 1: + return episode_numbers, 0 + + glue_token = None + for t in token_list: + if t.index == between_start and Tag.DELIMITER in t.possibilities: + glue_token = t + break + + if not glue_token: + return episode_numbers, 0 + + # Don't absorb when the glue delimiter is also the primary delimiter (structural separator) + if delimiter_profile and glue_token.content == delimiter_profile.primary: + return episode_numbers, 0 + + old_title = series_info.get("title", "") + series_info["title"] = f"{old_title}{glue_token.content}{first_ep_token.content}" + return episode_numbers[1:], 1 + + +def _detect_ranges(entries: list, token_list: list, entry_tokens: list[Token] | None = None, config: ParserConfig = None) -> list: + if len(entries) < 2: + return entries + + if entry_tokens is None: + # Fallback: assume seq_tokens are 1:1 with entries (legacy behavior) + entry_tokens = [t for t in token_list if t.category == Tag.SEQUENCE_NUMBER] + + result = [] + skip_next = False + for idx, entry in enumerate(entries): + if skip_next: + skip_next = False + continue + if idx + 1 < len(entries): + if idx < len(entry_tokens) and idx + 1 < len(entry_tokens): + t1 = entry_tokens[idx] + t2 = entry_tokens[idx + 1] + range_token = None + t1_pos = token_list.index(t1) + t2_pos = token_list.index(t2) + for between_idx in range(t1_pos + 1, t2_pos): + bt = token_list[between_idx] + if bt.category == Tag.SEQUENCE_RANGE: + range_token = bt + break + if range_token is not None: + start_num = entry.get("number") + end_num = entries[idx + 1].get("number") + if start_num is not None and end_num is not None and start_num <= end_num: + if range_token.content.lower() in config.range_total: + entry["total"] = end_num + result.append(entry) + elif range_token.content in config.range_connectors: + # + means separate entries, not a range + result.append(entry) + result.append(entries[idx + 1]) + else: + result.append({ + "start": entry, + "end": entries[idx + 1], + }) + skip_next = True + continue + result.append(entry) + return result + + +def _merge_title_tokens(title_tokens: list[Token], replace_dashes: bool = False, + delimiter_profile: DelimiterProfile | None = None) -> str: + if not title_tokens: + return "" + parts = [] + _glued_paren_depth = 0 + _stripped_open_bracket = None + for i, token in enumerate(title_tokens): + if i > 0: + gap = token.index - (title_tokens[i - 1].index + len(title_tokens[i - 1].content)) + if gap > 0: + parts.append(constant.SPACE) + elif gap == 0: + # Insert space between word and number when glued (e.g., "part1" → "part 1") + prev_tok = title_tokens[i - 1] + if (Tag.CONTENT_IDENTIFIER in prev_tok.possibilities and token.content[0:1].isdigit()): + parts.append(constant.SPACE) + content = token.content + resolved = token.category + is_primary = delimiter_profile and content == delimiter_profile.primary + + has_delim_possibility = Tag.DELIMITER in token.possibilities + # Brackets in title: square brackets kept as-is, parentheses removed + # only when glued to non-delimiter text (e.g., "mezzo(dsa)" → "mezzo dsa") + if Tag.BRACKET in token.possibilities and resolved == Tag.TITLE: + if content in constant.OPEN_BRACKETS: + # Check if glued to previous non-delimiter token → replace with space + glued = False + for k in range(i - 1, -1, -1): + pt = title_tokens[k] + if Tag.DELIMITER in pt.possibilities or Tag.CONTEXT_DELIMITER in pt.possibilities: + continue + if pt.index + len(pt.content) == token.index: + content = constant.SPACE + _glued_paren_depth += 1 + glued = True + break + if not glued and i == 0: + # Strip outer container bracket (first title token) + content = "" + _stripped_open_bracket = token.content + elif content in constant.CLOSE_BRACKETS: + if _glued_paren_depth > 0: + content = constant.SPACE + _glued_paren_depth -= 1 + elif (i == len(title_tokens) - 1 + and _stripped_open_bracket + and constant.BRACKET_PAIRS.get(_stripped_open_bracket) == content): + # Strip matching outer close bracket + content = "" + elif is_primary or content == constant.UNDERSCORE: + content = constant.SPACE + elif resolved == Tag.CONTEXT_DELIMITER and replace_dashes: + content = constant.SPACE + elif resolved == Tag.DELIMITER or (has_delim_possibility and resolved == Tag.TITLE): + # Glued delimiters (e.g. "3.0") are kept as-is + prev_glued = i > 0 and token.index == title_tokens[i - 1].index + len(title_tokens[i - 1].content) + next_glued = i + 1 < len(title_tokens) and title_tokens[i + 1].index == token.index + 1 + if prev_glued and next_glued: + pass + else: + content = constant.SPACE + parts.append(content) + result = constant.SPACE.join("".join(parts).split()) + return result + +def _group_unknown_tokens(tokens: Tokens, raw_unknowns: list[str], delimiter_profile: DelimiterProfile = None) -> list[str]: + """Group adjacent UNKNOWN tokens (across spaces/underscores only) into merged strings.""" + if not raw_unknowns: + return [] + groups: list[list[Token]] = [] + current: list[Token] = [] + pending_delims: list[Token] = [] + for token in tokens: + if token.category == Tag.UNKNOWN: + if current: + current.extend(pending_delims) + current.append(token) + pending_delims = [] + elif (token.category in (Tag.DELIMITER, Tag.CONTEXT_DELIMITER) + and current and delimiter_profile and token.content == delimiter_profile.primary): + pending_delims.append(token) + else: + pending_delims = [] + if current: + groups.append(current) + current = [] + if current: + groups.append(current) + result = [] + for g in groups: + merged = _merge_adjacent_tokens(g, delimiter_profile).strip() + if merged: + result.append(merged) + return result + + +def _group_adjacent_by_label(tokens: Tokens, label: Tag) -> list[list[Token]]: + groups = [] + current = [] + saw_plain_delimiter = False + for token in tokens: + if token.category == label: + if (saw_plain_delimiter or token.split_boundary) and current: + groups.append(current) + current = [] + current.append(token) + saw_plain_delimiter = False + elif token.category in (Tag.DELIMITER, Tag.CONTEXT_DELIMITER): + # A delimiter that also has the target label is part of the keyword match + if current and label not in token.possibilities: + saw_plain_delimiter = True + else: + saw_plain_delimiter = False + if current: + groups.append(current) + current = [] + if current: + groups.append(current) + return groups + + +def _merge_adjacent_tokens(tokens: list[Token], delimiter_profile: DelimiterProfile = None) -> str: + if not tokens: + return "" + parts = [] + for i, token in enumerate(tokens): + if i > 0: + gap = token.index - (tokens[i - 1].index + len(tokens[i - 1].content)) + if gap > 0: + parts.append(constant.SPACE) + content = token.content + if content == constant.UNDERSCORE: + content = constant.SPACE + parts.append(content) + return "".join(parts) + + +def _split_release_group_on_ampersand(tokens: list[Token]) -> list[list[Token]]: + """Split a release group token list on '&' separators (only when surrounded by delimiters).""" + # Only split on & that has delimiter tokens on both sides (e.g., "_&_") + split_indices = [] + for i, t in enumerate(tokens): + if Tag.CONTEXT_DEPENDENT in t.possibilities: + has_delim_before = i > 0 and Tag.DELIMITER in tokens[i - 1].possibilities + has_delim_after = i + 1 < len(tokens) and Tag.DELIMITER in tokens[i + 1].possibilities + if has_delim_before and has_delim_after: + split_indices.append(i) + + if not split_indices: + return [tokens] + + groups = [] + start = 0 + for si in split_indices: + groups.append(tokens[start:si]) + start = si + 1 + groups.append(tokens[start:]) + + # Trim leading/trailing delimiter tokens from each subgroup + trimmed = [] + for g in groups: + while g and Tag.DELIMITER in g[0].possibilities: + g = g[1:] + while g and Tag.DELIMITER in g[-1].possibilities: + g = g[:-1] + if g: + trimmed.append(g) + return trimmed if trimmed else [tokens] + + +def _merge_release_group_tokens(tokens: list[Token], keep_underscores: bool = False) -> str: + if not tokens: + return "" + parts = [] + for i, token in enumerate(tokens): + if i > 0: + gap = token.index - (tokens[i - 1].index + len(tokens[i - 1].content)) + if gap > 0: + parts.append(constant.SPACE) + content = token.content + if not keep_underscores: + content = content.replace(constant.UNDERSCORE, constant.SPACE) + parts.append(content) + result = constant.SPACE.join("".join(parts).split()) + return result + + +def _merge_decimals(entries: list, token_list: list) -> list: + if len(entries) < 2: + return entries + + seq_tokens = [t for t in token_list if t.category == Tag.SEQUENCE_NUMBER] + result = [] + skip_next = False + for idx, entry in enumerate(entries): + if skip_next: + skip_next = False + continue + if idx + 1 < len(entries) and idx < len(seq_tokens) - 1: + t1 = seq_tokens[idx] + t2 = seq_tokens[idx + 1] + t1_pos = token_list.index(t1) + t2_pos = token_list.index(t2) + if t2_pos - t1_pos == 2: + between = token_list[t1_pos + 1] + if between.content == constant.DOT and between.category == Tag.DELIMITER: + decimal_val = float(f"{t1.content}.{t2.content}") + result.append({"number": decimal_val}) + skip_next = True + continue + result.append(entry) + return result + + +def _lift_alternatives_to_range(entries: list[dict], token_list: list) -> list[dict]: + """Move alternatives from start/end sub-entries up to the range level, + and detect ranges within alternatives.""" + for entry in entries: + if "start" not in entry: + continue + # Collect alternatives from start and end + alts = [] + for sub_key in ("start", "end"): + sub = entry.get(sub_key) + if sub and "alternative" in sub: + alts.extend(sub.pop("alternative")) + if alts: + # Detect ranges within alternatives (pairs connected by range tokens) + if len(alts) == 2: + a, b = alts + a_num = a.get("number") + b_num = b.get("number") + if (isinstance(a_num, int) and isinstance(b_num, int) + and a_num < b_num): + # Check if there's a range token between them in the original tokens + entry["alternative"] = [{"start": a, "end": b}] + else: + entry["alternative"] = alts + else: + entry["alternative"] = alts + return entries + + +def _dedup_episodes(entries: list[dict]) -> list[dict]: + """Merge duplicate episode numbers, keeping the entry with most info.""" + if len(entries) <= 1: + return entries + seen: dict[object, int] = {} # number -> index in result + result = [] + for entry in entries: + if "start" in entry: # range — always keep + result.append(entry) + continue + num = entry.get("number") + if num in seen: + # Merge: keep the one with more fields + existing = result[seen[num]] + for k, v in entry.items(): + if k not in existing or existing[k] is None: + existing[k] = v + else: + seen[num] = len(result) + result.append(entry) + return result + + +def _has_episode_outside_bracket(token_list: list, current_idx: int, episode_tokens: list) -> bool: + """Check if any existing episode token is outside the current bracket group.""" + # Find the opening bracket of the current group + bracket_start_idx = -1 + for j in range(current_idx - 1, -1, -1): + t = token_list[j] + if t.category == Tag.BRACKET and t.content in constant.OPEN_BRACKETS: + bracket_start_idx = t.index + break + if bracket_start_idx < 0: + return False + # Check if any episode token has index before this bracket + return any(et.index < bracket_start_idx for et in episode_tokens) + + +def _has_range_before_in_bracket(token_list: list, current_idx: int) -> bool: + """Check if there's a range token (dash/SEQUENCE_RANGE) before this number in the same bracket group.""" + for j in range(current_idx - 1, -1, -1): + t = token_list[j] + cat = t.category + if cat == Tag.BRACKET: + return False # hit the bracket opening without finding a range + if cat == Tag.SEQUENCE_RANGE or cat == Tag.CONTEXT_DELIMITER: + return True + return False + + +def _is_alternative_episode(token_list: list, current_idx: int) -> bool: + """Check if this bracketed SEQUENCE_NUMBER is an alternative episode. + + True when the bracket group containing this number is immediately preceded + by another SEQUENCE_NUMBER (with only delimiters/brackets between). + """ + # Walk backward from current position to find what precedes the bracket group + for j in range(current_idx - 1, -1, -1): + t = token_list[j] + cat = t.category + if cat == Tag.BRACKET: + continue # skip the opening bracket + if cat == Tag.DELIMITER or t.content.strip() == '': + continue + if cat == Tag.SEQUENCE_PREFIX: + continue # skip "episode" prefix inside the bracket + # The first meaningful token before the bracket group + return cat == Tag.SEQUENCE_NUMBER + return False + + +def _get_bracket_unknown_text(token_list, seq_num_idx): + """Get unknown text from the same bracket group as a SEQUENCE_NUMBER. + + Walks backward from seq_num_idx to find the bracket open, collecting + UNKNOWN tokens. Returns merged text if found, else None. + """ + unknown_parts = [] + for j in range(seq_num_idx - 1, -1, -1): + t = token_list[j] + if t.category == Tag.BRACKET: + break # reached the opening bracket + if t.category == Tag.CONTEXT_DELIMITER: + continue # skip the dash separator + if t.category == Tag.DELIMITER: + if unknown_parts: + unknown_parts.append(t.content) + continue + if t.category == Tag.UNKNOWN: + unknown_parts.append(t.content) + else: + break + if not unknown_parts: + return None + # Count actual words (not delimiters/punctuation) + word_count = sum(1 for p in unknown_parts if p.strip() and p.strip().isalpha()) + if word_count < 2: + return None # single word isn't a title + # Reverse since we walked backward + unknown_parts.reverse() + text = ''.join(unknown_parts).strip() + return text if text else None + + +def _next_non_delimiter(token_list, current_idx): + """Find next non-delimiter token after current index.""" + for j in range(current_idx + 1, len(token_list)): + t = token_list[j] + if t.category in (Tag.DELIMITER, Tag.CONTEXT_DELIMITER): + continue + return t + return None + + +def _normalize_type(token: Token) -> str: + """Normalize type using canonical form from keyword entry, or content as-is.""" + poss = token.possibilities.get(Tag.TYPE) or token.possibilities.get(Tag.SERIES_TYPE) + if poss and poss.element and poss.element.canonical: + return poss.element.canonical.lower() + return token.content.lower() + + +def _try_int(s: str): + try: + return int(s) + except (ValueError, TypeError): + return None + + +def _normalize_audio_terms(terms: list[str]) -> list[str]: + result = [] + seen = set() + for term in terms: + for part in _split_audio_term(term): + key = part.lower() + if key not in seen: + seen.add(key) + result.append(part) + return result + + +def _split_audio_term(term: str) -> list[str]: + m = re.match(r'^([a-z][a-z\-]+?)x\d{1,2}$', term, re.IGNORECASE) + if m: + return [m.group(1)] + m = re.match(r'^(truehd|dd|flac|aac|dts)(\d{1,2}(?:\.\d{1,2}){0,2}(?:ch)?)$', term, re.IGNORECASE) + if m: + return [m.group(1), m.group(2)] + return [term] + + +def _normalize_subs_terms(terms: list[str]) -> list[str]: + result = [] + seen = set() + for term in terms: + m = re.match(r'^([a-z]+?)x\d{1,2}$', term, re.IGNORECASE) + normalized = m.group(1) if m else term + key = normalized.lower() + if key not in seen: + seen.add(key) + result.append(normalized) + return result + + +def _build_video_resolutions(tokens: Tokens) -> list[dict]: + groups = [] + current_group = [] + + for token in tokens: + if token.category == Tag.VIDEO_RESOLUTION: + current_group.append(token) + else: + if current_group: + groups.append(current_group) + current_group = [] + if current_group: + groups.append(current_group) + + if not groups: + return [] + + resolutions = [] + for group in groups: + combined = "".join(t.content for t in group).lower() + + match = re.match(r'^(\d+)([pi])$', combined) + if match: + resolutions.append({ + "video_height": int(match.group(1)), + "scan_method": match.group(2), + }) + continue + + match = re.match(r'^(\d+)k$', combined) + if match: + resolutions.append({ + "video_width": int(match.group(1)) * 1000, + }) + continue + + match = re.match(r'^(\d+)[x×](\d+)$', combined) + if match: + resolutions.append({ + "video_width": int(match.group(1)), + "video_height": int(match.group(2)), + }) + continue + + if combined.isdigit(): + num = int(combined) + if num > 100: + entry: dict[str, int | str] = {"video_height": num} + # Default scan method to 'p' for common resolutions + if num in constant.COMMON_RESOLUTIONS: + entry["scan_method"] = "p" + resolutions.append(entry) + + return resolutions diff --git a/aniparse/core/constant.py b/aniparse/core/constant.py new file mode 100644 index 0000000..bb1db79 --- /dev/null +++ b/aniparse/core/constant.py @@ -0,0 +1,83 @@ +"""Structural constants for the aniparse pipeline (fixed, internal).""" +from aniparse.core.token_tags import Tag + +OPEN_BRACKETS = ( + "(", # U+0028 LEFT PARENTHESIS + "[", # U+005B LEFT SQUARE BRACKET + "{", # U+007B LEFT CURLY BRACKET + "\u300C", # Corner bracket + "\u300E", # White corner bracket + "\u3010", # Black lenticular bracket + "\uFF08", # Fullwidth parenthesis +) + +CLOSE_BRACKETS = ( + ")", # U+0029 Right parenthesis + "]", # U+005D Right square bracket + "}", # U+007D Right curly bracket + "\u300D", # Corner bracket + "\u300F", # White corner bracket + "\u3011", # Black lenticular bracket + "\uFF09", # Fullwidth right parenthesis +) + +BRACKETS = OPEN_BRACKETS + CLOSE_BRACKETS + +# Parenthesis-style brackets (used for titles/alternatives, not metadata) +OPEN_PARENS = ("(", "\uFF08") # ASCII + Fullwidth parenthesis + +# Map open bracket to its matching close bracket (same index) +BRACKET_PAIRS = dict(zip(OPEN_BRACKETS, CLOSE_BRACKETS)) + +DASHES = "-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D" +UNDERSCORE = "_" # Universal space substitute in filenames +DELIMITERS = f".{UNDERSCORE}+, {DASHES}" +CONTEXT_DELIMITER = DASHES + +SPACE = " " # Output space (replaces underscores/delimiters in titles) +APOSTROPHE = "'" # Possessive marker (e.g., "queen's") +DOT = "." # Decimal separator in titles (e.g., "3.33") +COMMA = "," # List separator + +# Video resolution suffixes (e.g., 1080p, 720i, 4k) +VIDEO_RESOLUTION_SUFFIXES = frozenset({"p", "i"}) +VIDEO_RESOLUTION_MULTIPLIER_SUFFIXES = frozenset({"k"}) + +PIPE = "|" # Series alternative separator + +CHECKSUM_PLACEHOLDER = "" +DEFAULT_PROVIDER_NAME = "InMemory" + +# Structural zone constants +ZONE_TITLE = "title" +ZONE_TRANSITION = "transition" +ZONE_METADATA = "metadata" + +# Label sets — shared base for rules that need to classify "metadata" tokens. +# Individual rules extend this with context-specific labels. +BASE_METADATA_LABELS = frozenset({ + Tag.VIDEO_RESOLUTION, Tag.SOURCE, + Tag.AUDIO_TERM, Tag.VIDEO_TERM, + Tag.FILE_CHECKSUM, Tag.SUBS_TERM, + Tag.LANGUAGE, Tag.DEVICE_COMPATIBILITY, + Tag.RELEASE_VERSION, Tag.RELEASE_INFORMATION, +}) + +# Common metadata indicator words (for heuristic checks, e.g., pipe-split cleanup) +BASE_METADATA_INDICATORS = frozenset({ + 'bd', 'dvd', 'remux', '1080p', '720p', 'x264', 'x265', + 'flac', 'aac', 'hevc', 'dual', +}) + +# Common video resolutions (bare numbers without 'p' suffix) +COMMON_RESOLUTIONS = frozenset({360, 480, 576, 720, 1080, 1440, 2160, 4320}) + +# Known media file extensions +FILE_EXTENSIONS = frozenset({ + "3gp", "avi", "divx", "flv", "m2ts", "m4v", "mkv", "mov", "mp4", "mpg", + "mpeg", "ogm", "ogv", "rmvb", "ts", "webm", "wmv", + "aac", "ac3", "dts", "flac", "mp3", "ogg", "wav", "wma", + "ass", "srt", "ssa", "sub", "sup", "idx", + "7z", "rar", "zip", "gz", +}) + diff --git a/aniparse/core/defaults.py b/aniparse/core/defaults.py new file mode 100644 index 0000000..07cc256 --- /dev/null +++ b/aniparse/core/defaults.py @@ -0,0 +1,52 @@ +"""Default configuration: score rules, possibility rules.""" +from aniparse.abstraction.parser_base import Possibilities +from aniparse.abstraction.score_base import Score +from aniparse.core.token_tags import Tag + + +def build_default_score() -> Score: + from aniparse.rule.scoring.file_checksum_score_rule import FileChecksumScoreRule + from aniparse.rule.scoring.video_term_score_rule import VideoTermScoreRule + from aniparse.rule.scoring.audio_term_score_rule import AudioTermScoreRule + from aniparse.rule.scoring.file_index_score_rule import FileIndexRule + from aniparse.rule.scoring.sequence_number_score_rule import EpisodeNumberScore + from aniparse.rule.scoring.video_resolution_score_rule import VideoResolutionScoreRule + from aniparse.rule.scoring.series_year_score_rule import SeriesYearScore + from aniparse.rule.scoring.positional_score_rule import PositionalScoreRule + from aniparse.rule.scoring.contextual_score_rule import ContextualScoreRule + + score = Score() + score.add_rule(Tag.FILE_CHECKSUM, FileChecksumScoreRule) + score.add_rule(Tag.VIDEO_TERM, VideoTermScoreRule) + score.add_rule(Tag.AUDIO_TERM, AudioTermScoreRule) + score.add_rule(Tag.FILE_INDEX, FileIndexRule) + score.add_rule(Tag.SEQUENCE_NUMBER, EpisodeNumberScore) + score.add_rule(Tag.VIDEO_RESOLUTION, VideoResolutionScoreRule) + score.add_rule(Tag.YEAR, SeriesYearScore) + score.add_rule(Tag.RELEASE_GROUP, PositionalScoreRule) + score.add_rule(Tag.LANGUAGE, ContextualScoreRule) + return score + + +def build_default_possibilities() -> Possibilities: + from aniparse.rule.possibilities.number_rule import NumberPossibilityRule + from aniparse.rule.possibilities.context_dependent_expansion_rule import ContextDependentExpansionPossibilityRule + from aniparse.rule.possibilities.context_delimiter_rule import ContextDelimiterPossibilityRule + from aniparse.rule.possibilities.checksum_placeholder_rule import ChecksumPlaceholderPossibilityRule + from aniparse.rule.possibilities.year_rule import YearPossibilityRule + from aniparse.rule.possibilities.bracket_content_rule import BracketContentPossibilityRule + from aniparse.rule.possibilities.release_group_rule import ReleaseGroupPossibilityRule + from aniparse.rule.possibilities.title_rule import TitlePossibilityRule + from aniparse.rule.possibilities.zone_assignment_rule import ZoneAssignmentPossibilityRule + + possibilities = Possibilities() + possibilities.add_rule(ChecksumPlaceholderPossibilityRule) + possibilities.add_rule(ContextDelimiterPossibilityRule) + possibilities.add_rule(NumberPossibilityRule) + possibilities.add_rule(YearPossibilityRule) + possibilities.add_rule(BracketContentPossibilityRule) + possibilities.add_rule(ZoneAssignmentPossibilityRule) + possibilities.add_rule(ContextDependentExpansionPossibilityRule) + possibilities.add_rule(ReleaseGroupPossibilityRule) + possibilities.add_rule(TitlePossibilityRule) + return possibilities diff --git a/aniparse/core/parser.py b/aniparse/core/parser.py new file mode 100644 index 0000000..edd3fd3 --- /dev/null +++ b/aniparse/core/parser.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import logging +import re +import warnings +from typing import List + +from aniparse.config import ParserConfig +from aniparse.wordlist import WordListManager +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.abstraction.parser_base import AbstractParser, Possibilities +from aniparse.abstraction.score_base import Score +from aniparse.core.compose import compose +from aniparse.core.resolve import resolve, calculate_confidence +from aniparse.core.rhythm import analyze_delimiters +from aniparse.core.token import Tokens, Token +from aniparse.core.tokenizer import Tokenizer + +logger = logging.getLogger(__name__) + + +def _both_have_regex(p_entries, s_entries): + """Return True if both sides have at least one entry with regex patterns.""" + return (any(e.regex_dict for e in p_entries) + and any(e.regex_dict for e in s_entries)) + + +class BaseParser(AbstractParser): + def __init__(self, + filename: str, + word_list_manager: WordListManager, + possibilities: Possibilities = None, + score: Score = None, + config: ParserConfig = None): + self.tokens: Tokens = Tokenizer(filename, word_list_manager).tokenize() + self._filename = filename + self.word_list_manager = word_list_manager + self.config = config or ParserConfig() + + self.possibilities = possibilities or Possibilities() + self.warn = [] + + if score is None: + self.score: Score = Score() + else: + self.score: Score = score + + self.delimiter_profile = analyze_delimiters(self.tokens) + self.processed_entries = set() + + @staticmethod + def get_entry_id(token: Token, entry: ElementEntry): + return f"{id(token)}_{id(entry)}" + + def process_entry(self, word: str, tokens: list[Token], entry: ElementEntry): + """Process a single entry for a word.""" + entry_id = self.get_entry_id(tokens[0], entry) + self.add_possibility(tokens, entry) + if entry_id in self.processed_entries: + return + self.processed_entries.add(entry_id) + self.process_regex_matches(tokens[-1], entry) + + @staticmethod + def add_possibility(tokens: list[Token], entry: ElementEntry): + """Process category sets of an entry.""" + for category_set in entry.categories: + for token in tokens: + token.add_possibility(category_set, element=entry) + + def process_regex_matches(self, token: Token, entry: ElementEntry): + """Process regex matches for a word.""" + for regex, group_possibilities in entry.regex_dict.items(): + with warnings.catch_warnings(record=True) as warn: + for match in re.finditer(regex, self.filename, re.IGNORECASE): + if match.start() <= token.index < match.end(): + for group_idx, possibilities in group_possibilities.items(): + matched_group_start, matched_group_end = match.span(group_idx) + matched_tokens = self.get_matched_tokens_range(matched_group_start, matched_group_end) + for target_token in matched_tokens: + target_token.add_possibility(possibilities, element=entry, base_score=1.5) + if warn and any(issubclass(item.category, FutureWarning) for item in warn): + logger.warning(f'Warning from "{entry.word}", regex: "{regex}"') + self.warn.append((f'Warning from "{entry.word}", regex: "{regex}"', warn)) + + def get_matched_tokens_range(self, start: int, end: int) -> List[Token]: + """Get tokens matched by the regex within a specific range.""" + matched_tokens = [] + for token in self.tokens: + if start <= token.index < end: + matched_tokens.append(token) + return matched_tokens + + +class ParserProcessor(BaseParser): + def assign_possibilities(self): + fuzzy = self.config.fuzzy + threshold = self.config.fuzzy_threshold + for token in self.tokens: + for entry in self.word_list_manager.find(token.content, fuzzy=fuzzy, threshold=threshold): + self.process_entry(token.content, [token], entry) + + self._resplit_compound_tokens() + self.possibilities.apply_all(self) + + def _resplit_compound_tokens(self): + """Split unmatched tokens into sub-tokens that match known keywords.""" + token_list = self.tokens.tokens + i = 0 + while i < len(token_list): + token = token_list[i] + if token.possibilities or len(token.content) < 2 or not token.bracket_group: + i += 1 + continue + split = self._find_keyword_split(token.content) + if split: + sp, prefix, suffix, p_entries, s_entries = split + t1 = Token(content=prefix, index=token.index) + t2 = Token(content=suffix, index=token.index + sp) + t1.bracket_group = token.bracket_group + t2.bracket_group = token.bracket_group + t1.zone = token.zone + t2.zone = token.zone + # Replace in token list and register with Tokens container + token.remove_observer(self.tokens) + token_list[i:i + 1] = [t1, t2] + t1.add_observer(self.tokens) + t2.add_observer(self.tokens) + self.tokens.lookup_category.setdefault(t1.category, []).append(t1) + self.tokens.lookup_category.setdefault(t2.category, []).append(t2) + # Remove old token from lookup + if token in self.tokens.lookup_category.get(token.category, []): + self.tokens.lookup_category[token.category].remove(token) + t2.split_boundary = True # mark for compose group-breaking + for entry in p_entries: + self.process_entry(prefix, [t1], entry) + for entry in s_entries: + self.process_entry(suffix, [t2], entry) + i += 2 + else: + i += 1 + + def _find_keyword_split(self, content): + """Find a split point where both sides match known keywords. + + Requires both halves to be at least 2 chars, unless both match keywords. + """ + # First pass: both sides >= 2 chars + for sp in range(2, len(content) - 1): + prefix, suffix = content[:sp], content[sp:] + p_entries = list(self.word_list_manager.find(prefix)) + s_entries = list(self.word_list_manager.find(suffix)) + if p_entries and s_entries and _both_have_regex(p_entries, s_entries): + return (sp, prefix, suffix, p_entries, s_entries) + # Second pass: allow 1-char halves only when both match and content >= 4 chars + if len(content) >= 4: + for sp in (1, len(content) - 1): + prefix, suffix = content[:sp], content[sp:] + p_entries = list(self.word_list_manager.find(prefix)) + s_entries = list(self.word_list_manager.find(suffix)) + if p_entries and s_entries and _both_have_regex(p_entries, s_entries): + return (sp, prefix, suffix, p_entries, s_entries) + return None + + def score_token_possibilities(self): + for token in self.tokens: + if not token.possibilities: + continue + for label in list(token.possibilities.keys()): + self.score.calculate(label, token, self.tokens) + + +class Parser(ParserProcessor): + def parse(self, debug: bool = False) -> dict | None: + if not self.tokens: + return None + + self.assign_possibilities() + self.score_token_possibilities() + + resolve(self.tokens) + confidence = calculate_confidence(self.tokens) + metadata = compose(self.tokens, self._filename, self.delimiter_profile, self.config) + + result = metadata.to_dict() + + result["_confidence"] = round(confidence, 4) + + if debug: + result["_debug"] = { + "tokens": [ + { + "content": t.content, + "category": t.category.value, + "possibilities": { + label.value: {"descriptor": p.descriptor.value, "score": p.score} + for label, p in t.possibilities.items() + }, + } + for t in self.tokens + ] + } + + return result diff --git a/aniparse/core/resolve.py b/aniparse/core/resolve.py new file mode 100644 index 0000000..2402265 --- /dev/null +++ b/aniparse/core/resolve.py @@ -0,0 +1,83 @@ +from aniparse.core.token import Tokens +from aniparse.core.token_tags import Tag +from aniparse.core.constant import BRACKETS + + +def resolve(tokens: Tokens) -> None: + """Pick winning possibility per token, set token._category to winner.""" + for token in tokens: + if not token.possibilities: + continue + best = token.get_best_possibility() + if best is not None: + label, possibility = best + + # Tiebreaker: regex-matched metadata beats SEQUENCE_NUMBER. + if label == Tag.SEQUENCE_NUMBER: + for alt_label in (Tag.YEAR, Tag.VIDEO_TERM, Tag.AUDIO_TERM, Tag.VIDEO_RESOLUTION): + alt = token.possibilities.get(alt_label) + if alt and (alt.element is not None or alt_label == Tag.AUDIO_TERM) and alt.score >= 1.5: + label, possibility = alt_label, alt + break + + # Tiebreaker: RELEASE_GROUP with element beats SEQUENCE_NUMBER + if label == Tag.SEQUENCE_NUMBER: + alt = token.possibilities.get(Tag.RELEASE_GROUP) + if alt and alt.element is not None: + label, possibility = Tag.RELEASE_GROUP, alt + + # Tiebreaker: keyword-matched SEQUENCE_PREFIX should beat + # SEQUENCE_NUMBER on non-digit tokens + if label == Tag.SEQUENCE_NUMBER and not token.content.isdigit(): + alt = token.possibilities.get(Tag.SEQUENCE_PREFIX) + if alt and alt.element is not None: + label, possibility = Tag.SEQUENCE_PREFIX, alt + + # Tiebreaker: TITLE should beat SEQUENCE_NUMBER only when TITLE + # has significantly higher score + if label == Tag.SEQUENCE_NUMBER: + alt = token.possibilities.get(Tag.TITLE) + if alt and alt.score > possibility.score + 0.5: + label, possibility = Tag.TITLE, alt + + # Tiebreaker: regex-matched VIDEO_TERM/AUDIO_TERM beats SEQUENCE_PREFIX + if label == Tag.SEQUENCE_PREFIX: + for alt_label in (Tag.VIDEO_TERM, Tag.AUDIO_TERM): + alt = token.possibilities.get(alt_label) + if alt and alt.element is not None and alt.score >= possibility.score: + label, possibility = alt_label, alt + break + + # Tiebreaker: TITLE beats SEQUENCE_RANGE when scores are equal + if label == Tag.SEQUENCE_RANGE: + alt = token.possibilities.get(Tag.TITLE) + if alt and alt.score >= possibility.score: + label, possibility = Tag.TITLE, alt + + # Tiebreaker: BRACKET always wins for actual bracket characters + # unless TITLE or RELEASE_GROUP has higher score + if label != Tag.BRACKET and Tag.BRACKET in token.possibilities: + if token.content in BRACKETS: + title_poss = token.possibilities.get(Tag.TITLE) + rg_poss = token.possibilities.get(Tag.RELEASE_GROUP) + has_override = ((title_poss and title_poss.score > 0) + or (rg_poss and rg_poss.score > 0)) + if not has_override: + label = Tag.BRACKET + + token._category = label + + +def calculate_confidence(tokens: Tokens) -> float: + """Average margin between winner and runner-up across resolved tokens.""" + margins = [] + for token in tokens: + if not token.possibilities or len(token.possibilities) < 1: + continue + scores = sorted((p.score for p in token.possibilities.values()), reverse=True) + winner = scores[0] + runner_up = scores[1] if len(scores) > 1 else 0.0 + margins.append(winner - runner_up) + if not margins: + return 0.0 + return sum(margins) / len(margins) diff --git a/aniparse/core/rhythm.py b/aniparse/core/rhythm.py new file mode 100644 index 0000000..32bc3fc --- /dev/null +++ b/aniparse/core/rhythm.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from aniparse.core import constant + +if TYPE_CHECKING: + from aniparse.core.token import Tokens + + +@dataclass +class BracketGroup: + """Represents a bracket group with its positional metadata.""" + group_index: int # 0-based index among all bracket groups + open_pos: int # token list index of the open bracket + close_pos: int # token list index of the close bracket + is_first: bool = False # is this the first bracket group? + is_last: bool = False # is this the last bracket group? + starts_filename: bool = False # does it start at position 0? + + +@dataclass +class DelimiterProfile: + primary: str | None = None + counts: dict[str, int] = field(default_factory=dict) + is_uniform: bool = False + file_index_end: int | None = None + bracket_groups: list[BracketGroup] = field(default_factory=list) + + +def analyze_delimiters(tokens: Tokens) -> DelimiterProfile: + """Analyze delimiter patterns across all tokens outside brackets.""" + profile = DelimiterProfile() + counts: dict[str, int] = {} + in_bracket = 0 + + for token in tokens: + if token.content in constant.OPEN_BRACKETS: + in_bracket += 1 + continue + if token.content in constant.CLOSE_BRACKETS: + in_bracket = max(0, in_bracket - 1) + continue + if in_bracket > 0: + continue + + if len(token.content) == 1 and token.content in constant.DELIMITERS: + c = token.content + counts[c] = counts.get(c, 0) + 1 + + profile.counts = counts + if not counts: + return profile + + profile.primary = max(counts, key=counts.get) + unique_types = set(counts.keys()) + profile.is_uniform = len(unique_types) == 1 + + _detect_file_index(tokens, profile) + _detect_bracket_groups(tokens, profile) + + return profile + + +def _detect_file_index(tokens: Tokens, profile: DelimiterProfile): + """Detect leading number + delimiter file index pattern like '01. title here'.""" + token_list = tokens.tokens + if len(token_list) < 3: + return + + first = token_list[0] + second = token_list[1] + + if not first.content.isdigit(): + return + if len(second.content) != 1 or second.content not in constant.DELIMITERS: + return + + sep = second.content + rest_counts = dict(profile.counts) + rest_counts[sep] = rest_counts.get(sep, 1) - 1 + if rest_counts[sep] <= 0: + del rest_counts[sep] + + if rest_counts: + rest_primary = max(rest_counts, key=rest_counts.get) + if sep != rest_primary: + profile.file_index_end = 2 + + +def _detect_bracket_groups(tokens: Tokens, profile: DelimiterProfile): + """Detect bracket groups and annotate tokens with their bracket group info.""" + token_list = tokens.tokens + groups = [] + i = 0 + while i < len(token_list): + t = token_list[i] + if t.content in constant.OPEN_BRACKETS: + # Find matching close bracket of same type + expected_close = constant.BRACKET_PAIRS.get(t.content) + for j in range(i + 1, len(token_list)): + if token_list[j].content == expected_close: + bg = BracketGroup( + group_index=len(groups), + open_pos=i, + close_pos=j, + ) + groups.append(bg) + # Annotate interior tokens + for k in range(i + 1, j): + token_list[k].bracket_group = bg + i = j + 1 + break + else: + i += 1 + else: + i += 1 + + if groups: + groups[0].is_first = True + groups[-1].is_last = True + groups[0].starts_filename = (groups[0].open_pos == 0) + + profile.bracket_groups = groups diff --git a/aniparse/core/token.py b/aniparse/core/token.py new file mode 100644 index 0000000..12bd6d9 --- /dev/null +++ b/aniparse/core/token.py @@ -0,0 +1,272 @@ +import weakref +from typing import Union, Iterator, Literal, List, Set, Dict, Optional + +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.core.token_tags import Tag + +PossibilityAction = Literal['add', 'remove'] + + +class Possibility: + __slots__ = ('descriptor', 'score', 'element') + + def __init__(self, descriptor: Tag, score: float = 0.0, element: Optional[ElementEntry] = None): + self.descriptor = descriptor + self.score = score + self.element = element + + def __repr__(self): + return f"Possibility({self.descriptor.value}, score={self.score})" + + +class Token: + """ + A class representing a token in a filename. + """ + + def __init__(self, content: str, index: int, category: Tag = Tag.UNKNOWN): + self.content = content + self.original = content + self.index = index + self._category = category + self._possibilities: Dict[Tag, Possibility] = {} + self._observers = weakref.WeakSet() + self.zone: Optional[str] = None + self.bracket_group = None # Set by rhythm analysis (BracketGroup or None) + self.split_boundary: bool = False # Set by resplit — marks group break for compose + + def __repr__(self) -> str: + return (f"Token(\n" + f"\tcontent=\"{self.content}\"\n" + f"\tcategory=\"{self.category}\"\n" + f"\tpossibilities={[pos for pos in self.possibilities]}\n)") + + @property + def possibilities(self) -> Dict[Tag, Possibility]: + return self._possibilities + + @possibilities.setter + def possibilities(self, new_possibilities): + raise AttributeError('Cannot set possibilities directly. Use add_possibility and remove_possibility instead.') + + @property + def category(self): + return self._category + + @category.setter + def category(self, new_category): + raise AttributeError('Cannot set category directly.') + + def add_possibility(self, possibilities: Union[Tag, List[Tag], Set[Tag]], + element: Optional[ElementEntry] = None, base_score: float = 0.0): + """Adds one or more possibilities to the token.""" + if not isinstance(possibilities, (list, set)): + possibilities = [possibilities] + + for possibility in possibilities: + label = possibility.group + if label not in self._possibilities: + self._possibilities[label] = Possibility( + descriptor=possibility, + score=base_score, + element=element, + ) + elif base_score > self._possibilities[label].score: + # Update score if higher (e.g., regex match overrides keyword match) + self._possibilities[label] = Possibility( + descriptor=possibility, + score=base_score, + element=element or self._possibilities[label].element, + ) + + self._notify_possibilities_observers('add', possibilities) + + def remove_possibility(self, possibilities: Union[Tag, List[Tag]] = None): + """Removes one or more possibilities from the token.""" + if not possibilities: + possibilities = list(self._possibilities.keys()) + elif not isinstance(possibilities, list): + possibilities = [possibilities] + + for possibility in possibilities: + self._possibilities.pop(possibility, None) + + self._notify_possibilities_observers('remove', possibilities) + + def add_score(self, label: Tag, delta: float): + """Add delta to score for a given label.""" + if label in self._possibilities: + self._possibilities[label].score += delta + + def get_score(self, label: Tag) -> float: + """Get the score for a given label, 0.0 if not present.""" + p = self._possibilities.get(label) + return p.score if p else 0.0 + + def get_best_possibility(self) -> Optional[tuple[Tag, Possibility]]: + """Return (Label, Possibility) with highest score, or None.""" + if not self._possibilities: + return None + return max(self._possibilities.items(), key=lambda x: x[1].score) + + def _notify_category_observers(self, old_category: Tag): + for observer in self._observers: + observer.token_category_changed(self, old_category) + + def _notify_possibilities_observers(self, action: PossibilityAction, possibility): + for observer in self._observers: + observer.token_possibilities_changed(self, action, possibility) + + def add_observer(self, observer: 'Tokens'): + self._observers.add(observer) + + def remove_observer(self, observer: 'Tokens'): + self._observers.discard(observer) + + +class Tokens: + """ + A container for a list of tokens, each associated with one or more categories. + """ + + def __init__(self): + self.tokens: List[Token] = [] + self.lookup_category: dict[Tag, list] = {} + self.lookup_possibilities: dict[Tag, list] = {} + + def __iter__(self) -> Iterator[Token]: + return iter(self.tokens) + + def add_token(self, token: Token): + self.tokens.append(token) + self.lookup_category.setdefault(token.category, []).append(token) + token.add_observer(self) + + @staticmethod + def find_in_tokens( + tokens: list[Token], + category_in: list[Tag] = None, + category_not_in: list[Tag] = None, + content_in: list[str] = None, + content_not_in: list[str] = None) -> Union['Token', None]: + for token in tokens: + if (category_in and token.category not in category_in + or category_not_in and token.category in category_not_in + or content_in and token.content not in content_in + or content_not_in and token.content in content_not_in): + continue + return token + return None + + def find_next(self, + token: Token = None, + category_in: list[Tag] = None, + category_not_in: list[Tag] = None, + content_in: list[str] = None, + content_not_in: list[str] = None) -> Union['Token', None]: + if token is not None: + tokens = self.tokens[self.get_index(token) + 1:] + else: + tokens = self.tokens + return self.find_in_tokens(tokens, category_in, category_not_in, content_in, content_not_in) + + def find_prev(self, + token: Token = None, + category_in: list[Tag] = None, + category_not_in: list[Tag] = None, + content_in: list[str] = None, + content_not_in: list[str] = None) -> Union['Token', None]: + if token is None: + tokens = self.tokens[::-1] + else: + tokens = self.tokens[:self.get_index(token)][::-1] + return self.find_in_tokens(tokens, category_in, category_not_in, content_in, content_not_in) + + def find_in_between(self, + start_token: Token = None, + end_token: Token = None, + category_in: list[Tag] = None, + category_not_in: list[Tag] = None, + content_in: list[str] = None, + content_not_in: list[str] = None) -> Union['Token', None]: + start_index = self.get_index(start_token) + 1 if start_token is not None else 0 + end_index = self.get_index(end_token) if end_token is not None else len(self.tokens) + if start_index >= end_index: + return None + tokens = self.tokens[start_index:end_index] + return self.find_in_tokens(tokens, category_in, category_not_in, content_in, content_not_in) + + def get_categories(self, category: Tag): + return self.lookup_category[category] + + def get_token(self, index: int) -> Union[Token, None]: + """Retrieve the token by a given character position in the filename.""" + for token in self.tokens: + if token.index == index: + return token + return None + + def get_index(self, token: Token) -> int: + return self.tokens.index(token) + + def insert(self, index: int, token: Token) -> None: + self.tokens.insert(index, token) + self.lookup_category[token.category].append(token) + token.add_observer(self) + + def insert_after(self, token: Token, new_token: Token) -> None: + self.insert(self.get_index(token) + 1, new_token) + + def insert_before(self, token: Token, new_token: Token) -> None: + self.insert(self.get_index(token), new_token) + + def loop_forward(self, + begin: Token = None, + end: Token = None, + category: Union[Tag, list[Tag]] = None) -> Iterator[Token]: + begin_index = self.get_index(begin) + 1 if begin else 0 + end_index = self.get_index(end) if end else len(self.tokens) + tokens = self.tokens[begin_index:end_index] + for token in tokens: + if category: + if token.category not in category: + continue + yield token + + def loop_backward(self, + end: Token = None, + begin: Token = None, + category: Union[Tag, list[Tag]] = None) -> Iterator[Token]: + end_index = self.get_index(end) if end else len(self.tokens) + begin_index = self.get_index(begin) + 1 if begin else 0 + tokens = self.tokens[begin_index:end_index][::-1] + for token in tokens: + if category: + if isinstance(category, list): + if token.category not in category: + continue + elif token.category != category: + continue + yield token + + def remove_token(self, token: Token): + self.tokens.remove(token) + self.lookup_category[token.category].remove(token) + token.remove_observer(self) + + def token_category_changed(self, token: Token, old_category: Tag): + self.lookup_category[old_category].remove(token) + self.lookup_category[token.category].append(token) + + def token_possibilities_changed(self, token: Token, + action: PossibilityAction, + possibilities: list[Tag]): + if action == 'add': + for possibility in possibilities: + self.lookup_possibilities.setdefault(possibility, []).append(token) + elif action == 'remove': + for possibility in possibilities: + try: + self.lookup_possibilities.get(possibility, []).remove(token) + except Exception: + pass diff --git a/aniparse/core/token_tags.py b/aniparse/core/token_tags.py new file mode 100644 index 0000000..9cb6aba --- /dev/null +++ b/aniparse/core/token_tags.py @@ -0,0 +1,129 @@ +import enum +from typing import Collection + + +class Tag(enum.Enum): + """Single enum for all token tagging — both scoring keys and fine-grained descriptors.""" + + # Series-related + TITLE = 'title' + TYPE = 'type' + YEAR = 'year' + SERIES_TITLE = 'series_title' + SERIES_TYPE = 'series_type' + SERIES_YEAR = 'series_year' + + # Sequence prefix (scoring group for SEASON/EPISODE/VOLUME/etc.) + SEQUENCE_PREFIX = 'sequence_prefix' + SERIES = 'series' + SEASON = 'season' + EPISODE = 'episode' + VOLUME = 'volume' + CONTENT_TYPE = 'content_type' + CONTENT_IDENTIFIER = 'content_identifier' + + # Sequence numbers and ranges + SEQUENCE_NUMBER = 'sequence_number' + SEQUENCE_ALTERNATIVE = 'sequence_alternative' + SEQUENCE_PART = 'sequence_part' + SEQUENCE_TITLE = 'sequence_title' + SEQUENCE_TOTAL = 'sequence_total' + SEQUENCE_RANGE = 'sequence_range' + SEQUENCE_START = 'sequence_start' + SEQUENCE_END = 'sequence_end' + + # File-related + FILE_NAME = 'file_name' + FILE_CHECKSUM = 'file_checksum' + FILE_EXTENSION = 'file_extension' + FILE_INDEX = 'file_index' + + # Audio-related + AUDIO_TERM = 'audio_term' + + # Release-related + RELEASE_GROUP = 'release_group' + RELEASE_INFORMATION = 'release_information' + RELEASE_VERSION = 'release_version' + RELEASE_VERSION_PREFIX = 'release_version_prefix' + + # Video-related + VIDEO_RESOLUTION = 'video_resolution' + VIDEO_WIDTH = 'width' + VIDEO_HEIGHT = 'height' + SCAN_METHOD = 'scan_method' + VIDEO_TERM = 'video_term' + SOURCE = 'source' + + # Subtitle-related + SUBS_TERM = 'subs_term' + + # Delimiter and formatting + CONTEXT_DELIMITER = 'context_delimiter' + BRACKET = 'bracket' + DELIMITER = 'delimiter' + + # Miscellaneous + CONTEXT_DEPENDENT = 'context_dependent' + + # Language and compatibility + LANGUAGE = 'language' + DEVICE_COMPATIBILITY = 'device_compatibility' + EXTRA_INFO = 'extra_info' + + # Undefined + OTHER = 'other' + UNKNOWN = 'unknown' + + def __contains__(self, item: 'Tag') -> bool: + return self.value == item.value + + @property + def group(self) -> 'Tag': + """Return the scoring-group head for this tag. + + Most tags map to themselves. Fine-grained tags (SEASON, EPISODE, etc.) + collapse into coarser group heads (SEQUENCE_PREFIX, TITLE, etc.). + """ + return _GROUP_OVERRIDES.get(self, self) + + @classmethod + def get_optional_info(cls) -> list['Tag']: + return [ + cls.FILE_CHECKSUM, + cls.AUDIO_TERM, + cls.RELEASE_GROUP, + cls.RELEASE_INFORMATION, + cls.RELEASE_VERSION, + cls.RELEASE_VERSION_PREFIX, + cls.VIDEO_RESOLUTION, + cls.VIDEO_TERM, + cls.SOURCE, + cls.SUBS_TERM, + cls.LANGUAGE, + cls.DEVICE_COMPATIBILITY, + cls.EXTRA_INFO, + ] + + @classmethod + def additional_video_information(cls, items: Collection['Tag']) -> bool: + for item in items: + if item in cls.get_optional_info(): + return True + return False + + +# Many-to-one overrides: fine-grained tag → scoring group head +_GROUP_OVERRIDES = { + Tag.SERIES_TITLE: Tag.TITLE, + Tag.SERIES_TYPE: Tag.TYPE, + Tag.SERIES_YEAR: Tag.YEAR, + Tag.SEASON: Tag.SEQUENCE_PREFIX, + Tag.EPISODE: Tag.SEQUENCE_PREFIX, + Tag.VOLUME: Tag.SEQUENCE_PREFIX, + Tag.CONTENT_TYPE: Tag.SEQUENCE_PREFIX, + Tag.SEQUENCE_TITLE: Tag.TITLE, + Tag.SEQUENCE_TOTAL: Tag.SEQUENCE_NUMBER, + Tag.VIDEO_WIDTH: Tag.VIDEO_RESOLUTION, + Tag.VIDEO_HEIGHT: Tag.VIDEO_RESOLUTION, +} diff --git a/aniparse/core/tokenizer.py b/aniparse/core/tokenizer.py new file mode 100644 index 0000000..2bd2c36 --- /dev/null +++ b/aniparse/core/tokenizer.py @@ -0,0 +1,36 @@ +import re +from typing import Iterator, Tuple + +from aniparse.wordlist import WordListManager +from aniparse.core.token import Tokens, Token + +_L = r"a-zA-Z\u00C0-\u024F\u1E00-\u1EFF" # ASCII + Latin Extended + Latin Additional +_CJK = r"\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF" # CJK + Hiragana + Katakana +MIXED_TOKEN_REGEX = re.compile( + r"\d+(?:st|nd|rd|th)" # ordinals: 1st, 2nd, 3rd, 4th + rf"|[{_CJK}]+" # CJK/Japanese character sequences + rf"|[a-z\u00E0-\u024F\u1E00-\u1EFF][A-Z\u00C0-\u024F\u1E00-\u1EFF]+" + rf"|[A-Z\u00C0-\u024F\u1E00-\u1EFF]?[a-z\u00E0-\u024F\u1E00-\u1EFF]+" + rf"|[A-Z\u00C0-\u024F\u1E00-\u1EFF]+" + r"|\d+" + rf"|[^{_L}{_CJK}\d]", +) + + +class Tokenizer: + + def __init__(self, filename: str, wordlist_manager: WordListManager): + self.filename = filename + self.wordlist_manager = wordlist_manager + self.tokens = Tokens() + + def tokenize(self) -> Tokens: + for content, start_pos in self.generate_tokens(): + self.tokens.add_token(Token(content=content, index=start_pos)) + return self.tokens + + def generate_tokens(self) -> Iterator[Tuple[str, int]]: + for match in MIXED_TOKEN_REGEX.finditer(self.filename): + token_string = match.group(0) + start_index = match.start() + yield token_string, start_index diff --git a/aniparse/element.py b/aniparse/element.py deleted file mode 100644 index be671e4..0000000 --- a/aniparse/element.py +++ /dev/null @@ -1,63 +0,0 @@ -import enum - - -class ElementCategory(enum.Enum): - ANIME_SEASON = 'anime_season' - ANIME_SEASON_PREFIX = 'anime_season_prefix' - ANIME_TITLE = 'anime_title' - ANIME_TITLE_ALT = 'anime_title_alt' - ANIME_TYPE = 'anime_type' - ANIME_YEAR = 'anime_year' - AUDIO_TERM = 'audio_term' - BATCH = 'batch' # Special token to mark a batch release. Use in validate_elements only - DEVICE_COMPATIBILITY = 'device_compatibility' - BRACKET = 'bracket' - DELIMITER = 'delimiter' - EPISODE_NUMBER = 'episode_number' - EPISODE_PART = 'episode_part' - EPISODE_NUMBER_ALT = 'episode_number_alt' - EPISODE_PREFIX = 'episode_prefix' - EPISODE_TITLE = 'episode_title' - EPISODE_TOTAL = 'episode_total' - FILE_CHECKSUM = 'file_checksum' - FILE_EXTENSION = 'file_extension' - FILE_INDEX = 'file_index' - FILE_NAME = 'file_name' - LANGUAGE = 'language' - OTHER = 'other' - RANGE_SEPARATOR = 'range_separator' - RELEASE_GROUP = 'release_group' - RELEASE_INFORMATION = 'release_information' - RELEASE_VERSION = 'release_version' - SOURCE = 'source' - SUBTITLES = 'subtitles' - VIDEO_RESOLUTION = 'video_resolution' - VIDEO_TERM = 'video_term' - VOLUME_NUMBER = 'volume_number' - VOLUME_PREFIX = 'volume_prefix' - UNKNOWN = 'unknown' - - @classmethod - def is_searchable(cls, category: 'ElementCategory') -> bool: - searchable_categories = [ - cls.ANIME_SEASON_PREFIX, - cls.ANIME_TYPE, - cls.AUDIO_TERM, - cls.DEVICE_COMPATIBILITY, - cls.EPISODE_PREFIX, - cls.FILE_CHECKSUM, - cls.LANGUAGE, - cls.OTHER, - cls.RELEASE_GROUP, - cls.RELEASE_INFORMATION, - cls.RELEASE_VERSION, - cls.SOURCE, - cls.SUBTITLES, - cls.VIDEO_RESOLUTION, - cls.VIDEO_TERM, - cls.VOLUME_PREFIX - ] - return category in searchable_categories - - def __contains__(self, item: 'ElementCategory') -> bool: - return self.value == item.value diff --git a/aniparse/keyword.py b/aniparse/keyword.py deleted file mode 100644 index f043ba8..0000000 --- a/aniparse/keyword.py +++ /dev/null @@ -1,253 +0,0 @@ -import logging -from typing import List, Union - -import unicodedata - -from aniparse.element import ElementCategory - -__all__ = ['option', 'KeywordManager', 'BaseKeywordManager'] - -logger = logging.getLogger(__name__) - - -class KeywordOption: - def __init__(self, identifiable=True, searchable=True, valid=True): - self.identifiable = identifiable - self.searchable = searchable - self.valid = valid - - -class Keyword: - def __init__(self, e_category: ElementCategory, options: KeywordOption): - self.e_category = e_category - self.options = options - - -option = { - "default": KeywordOption(), - "invalid": KeywordOption(valid=False), - "unidentifiable": KeywordOption(identifiable=False), - "unidentifiable_invalid": KeywordOption(identifiable=False, valid=False), - "unidentifiable_unsearchable": KeywordOption(identifiable=False, searchable=False) - -} - - -class BaseKeywordManager: - def __init__(self): - self._file_extensions = {} - self._keys = {} - - def _get_keyword_container(self, category) -> dict: - """ - File extensions are stored in a separate container - """ - return self._file_extensions if category == ElementCategory.FILE_EXTENSION else self._keys - - def add(self, category: ElementCategory, options: KeywordOption, keywords: List[str]) -> None: - keyword_container = self._get_keyword_container(category) - for keyword in keywords: - if not keyword: - continue - if keyword in keyword_container: - continue - - keyword_container[keyword] = Keyword(category, options) - logger.debug('Added keyword: {} with option {}'.format(keyword, options)) - - def find(self, string: str, e_category: ElementCategory = ElementCategory.UNKNOWN) -> Union[Keyword, None]: - """ - Find a keyword in the string based on the category - - :param string: - :param e_category: - :return: - """ - logger.debug('Searching for keyword: {}'.format(string)) - keyword_container = self._get_keyword_container(e_category) - if string not in keyword_container: - return None - keyword = keyword_container[string] - if (e_category != ElementCategory.UNKNOWN - and keyword.e_category != e_category): - return None - return keyword - - @staticmethod - def normalize(string: str) -> str: - """ - Remove accents and other special symbols - NFKD: Normalization Form Compatibility Decomposition, (Normalize form with change the length) - """ - nfkd = unicodedata.normalize('NFKD', string) - without_accents = ''.join([c for c in nfkd if not unicodedata.combining(c)]) - return without_accents.upper() - - @staticmethod - def peek(string: str) -> list: - """ - this to handle the case where the string are seperated by a delimiter (space) - e.g. "H264-Flac" -> ["H264", "Flac"] - this early peek have many things to improve, but for now it's good enough - """ - entries = [ - (ElementCategory.AUDIO_TERM, ['Dual Audio', 'Multi Audio']), - (ElementCategory.VIDEO_TERM, ['H264', 'H.264', "10 bit", "10 bits", "8 bit", "8 bits"]), - (ElementCategory.VIDEO_RESOLUTION, ['480p', '720p', '1080p', '2160p', '4K', "480i", "720i", "1080i"]), - (ElementCategory.SUBTITLES, ['Multi Subs', 'Multiple Subtitle', 'Multiple Subtitles']), - (ElementCategory.SOURCE, ['Blu-Ray']) - ] - - pre_identified_tokens = [] - - for category, keywords in entries: - for keyword in keywords: - keyword_begin_pos = string.lower().find(keyword.lower()) - if keyword_begin_pos != -1: # Found the keyword in the string - keyword_end_pos = keyword_begin_pos + len(keyword) - pre_identified_tokens.append( - (keyword_begin_pos, keyword_end_pos, category)) - pre_identified_tokens = sorted(pre_identified_tokens, - key=lambda x: (x[0], -x[1])) # lowest begin pos, the highest end pos - # check for overlapping tokens - # e.g. (0, 3, "Sub") and (0, 4, "Subs") - # if the first token is a substring of the second token, remove the first token - last_token = (-1, -1, None) - for token in pre_identified_tokens.copy(): - if token[0] == last_token[0]: - pre_identified_tokens.remove(token) - last_token = token - - return sorted(pre_identified_tokens) - - -class KeywordManager(BaseKeywordManager): - """ - Default Keyword Manager - """ - def __init__(self): - super().__init__() - - self.add(ElementCategory.ANIME_SEASON_PREFIX, option["default"], [ - 'SAISON', 'SEASON' - ]) - - self.add(ElementCategory.ANIME_SEASON_PREFIX, option["unidentifiable"], [ - 'S']) - - self.add(ElementCategory.ANIME_TYPE, option["unidentifiable_unsearchable"], [ - 'GEKIJOUBAN', 'MOVIE', - 'OAD', 'OAV', 'ONA', 'OVA', - 'SPECIAL', 'SPECIALS', - 'TV' - ]) - - self.add(ElementCategory.ANIME_TYPE, - option["unidentifiable_unsearchable"], - ['SP']) # e.g. "Yumeiro Patissiere SP Professional" - - self.add(ElementCategory.ANIME_TYPE, option["unidentifiable_invalid"], [ - 'ED', 'ENDING', 'NCED', "CLEAN ENDING", - 'NCOP', 'OP', 'OPENING', "CLEAN OPENING", - 'PREVIEW', 'PV' - ]) - - self.add(ElementCategory.AUDIO_TERM, option["default"], [ - # Audio channels - '2.0CH', '2CH', '5.1', '5.1CH', 'DTS', 'DTS-ES', 'DTS5.1', - 'TRUEHD5.1', - # Audio codec - 'AAC', 'AACX2', 'AACX3', 'AACX4', 'AC3', 'EAC3', 'E-AC-3', - 'FLAC', 'FLACX2', 'FLACX3', 'FLACX4', 'LOSSLESS', 'MP3', 'OGG', - 'VORBIS', - # Audio language - 'DUALAUDIO', 'DUAL AUDIO', 'DUAL-AUDIO', - 'MULTIAUDIO', 'MULTI AUDIO', 'MULTI-AUDIO' - ]) - - self.add(ElementCategory.DEVICE_COMPATIBILITY, option["default"], [ - 'IPAD3', 'IPHONE5', 'IPOD', 'PS3', 'XBOX', 'XBOX360' - ]) - - self.add(ElementCategory.DEVICE_COMPATIBILITY, option["unidentifiable"], [ - 'ANDROID' - ]) - - self.add(ElementCategory.EPISODE_PREFIX, option["default"], [ - 'EP', 'EP.', 'EPS', 'EPS.', 'EPISODE', 'EPISODE.', 'EPISODES', - 'CAPITULO', 'EPISODIO', 'FOLGE' - ]) - - self.add(ElementCategory.EPISODE_PREFIX, option["invalid"], [ - 'E' - ]) # single-letter episode keywords are not valid - - self.add(ElementCategory.FILE_EXTENSION, option["default"], [ - '3GP', 'AVI', 'DIVX', 'FLV', 'M2TS', 'MKV', 'MOV', 'MP4', 'MPG', - 'OGM', 'RM', 'RMVB', 'TS', 'WEBM', 'WMV' - ]) - - self.add(ElementCategory.FILE_EXTENSION, option["invalid"], [ - 'AAC', 'AIFF', 'FLAC', 'M4A', 'MP3', 'MKA', 'OGG', 'WAV', 'WMA', - '7Z', 'RAR', 'ZIP', - 'ASS', 'SRT' - ]) - - self.add(ElementCategory.LANGUAGE, option["default"], [ - 'ENG', 'ENGLISH', 'ESPANOL', 'JAP', 'PT-BR', 'SPANISH', 'VOSTFR' - ]) - - self.add(ElementCategory.LANGUAGE, option["unidentifiable"], [ - 'ESP', 'ITA' - ]) # e.g. "Tokyo ESP", "Bokura ga Ita" - - self.add(ElementCategory.OTHER, option["default"], [ - 'REMASTER', 'REMASTERED', 'UNCENSORED', 'UNCUT', - 'TS', 'VFR', 'WIDESCREEN', 'WS' - ]) - - self.add(ElementCategory.RELEASE_INFORMATION, option["default"], [ - 'BATCH', 'COMPLETE', 'PATCH', 'REMUX' - ]) - - self.add(ElementCategory.RELEASE_INFORMATION, option["unidentifiable"], [ - 'END', 'FINAL' - ]) # e.g. "The End of Evangelion", "Final Approach" - - self.add(ElementCategory.RELEASE_VERSION, option["default"], [ - 'V0', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'V7', 'V8', 'V9', 'V10' - ]) - - self.add(ElementCategory.SOURCE, option["default"], [ - 'BD', 'BDRIP', 'BLURAY', 'BLU-RAY', - 'DVD', 'DVD5', 'DVD9', 'DVD-R2J', 'DVDRIP', 'DVD-RIP', - 'R2DVD', 'R2J', 'R2JDVD', 'R2JDVDRIP', - 'HDTV', 'HDTVRIP', 'TVRIP', 'TV-RIP', - 'WEBCAST', 'WEBRIP' - ]) - - self.add(ElementCategory.SUBTITLES, option["default"], [ - 'ASS', 'BIG5', 'DUB', 'DUBBED', 'HARDSUB', 'HARDSUBS', 'RAW', - 'SOFTSUB', 'SOFTSUBS', 'SUB', 'SUBBED', 'SUBTITLED', - 'MULTI SUBS', 'MULTI-SUBS', 'MULTISUB', 'MULTISUBS' - ]) - - self.add(ElementCategory.VIDEO_TERM, option["default"], [ - # Frame rate - '23.976FPS', '24FPS', '29.97FPS', '30FPS', '60FPS', '120FPS', - # Video codec - '8BIT', '8-BIT', '10BIT', '10BITS', '10-BIT', '10-BITS', - 'HI10', 'HI10P', 'HI444', 'HI444P', 'HI444PP', - 'H264', 'H265', 'H.264', 'H.265', 'X264', 'X265', 'X.264', - 'AVC', 'HEVC', 'HEVC2', 'DIVX', 'DIVX5', 'DIVX6', 'XVID', - # Video format - 'AVI', 'RMVB', 'WMV', 'WMV3', 'WMV9', - # Video quality - 'HQ', 'LQ', - # Video resolution - 'HD', 'SD' - ]) - - self.add(ElementCategory.VOLUME_PREFIX, option["default"], [ - 'VOL', 'VOL.', 'VOLUME' - ]) diff --git a/aniparse/keyword/AUDIO_TERM.py b/aniparse/keyword/AUDIO_TERM.py new file mode 100644 index 0000000..ed68d7f --- /dev/null +++ b/aniparse/keyword/AUDIO_TERM.py @@ -0,0 +1,68 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +audio_term = [ + # Audio channels + ElementEntry(word='CH', categories={Tag.AUDIO_TERM}, regex_dict={ + r'[2-9](\.\d{1,2}){1,2}(CH)?': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='DTS', categories={Tag.AUDIO_TERM}, regex_dict={ + r'DTS(?:[\W_]?(?:ES|HD[\W_]?(?:MA|HR)?))?': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='TRUEHD', categories={Tag.AUDIO_TERM}, regex_dict={ + r'TRUE[\W_]?HD[\W_]?\d{1,2}(\.\d{1,2}){0,2}\W?(CH)?': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='TRUE', categories=set(), regex_dict={ + r'TRUE[\W_]?HD[\W_]?\d{1,2}(\.\d{1,2}){0,2}\W?(CH)?': {0: {Tag.AUDIO_TERM}} + }), + + # Audio codec + ElementEntry(word='AAC', categories={Tag.AUDIO_TERM}), + ElementEntry(word='AACX', categories=set(), regex_dict={ + r'AACX[\W_]?\d{1,2}': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='AC', categories=set(), regex_dict={ + r'AC[\W_]?3': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='AC3', categories={Tag.AUDIO_TERM}), + ElementEntry(word='DD', categories=set(), regex_dict={ + r'DD[\W_]?\d{1,2}(\.\d{1,2}){0,2}': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='EAC3', categories={Tag.AUDIO_TERM}), + ElementEntry(word='E', categories=set(), regex_dict={ + r'E[\W_]?AC\W?3': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='FLAC', categories={Tag.AUDIO_TERM}, regex_dict={ + r'FLAC\d\.\d': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='FLACX', categories={Tag.AUDIO_TERM}, regex_dict={ + r'FLACX[\W_]?\d{1,2}': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='LOSSLESS', categories={Tag.AUDIO_TERM}), + ElementEntry(word='MP', categories=set(), regex_dict={ + r'MP[\W_]?3': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='MP3', categories={Tag.AUDIO_TERM}), + ElementEntry(word='OGG', categories={Tag.AUDIO_TERM}), + ElementEntry(word='VORBIS', categories={Tag.AUDIO_TERM}), + ElementEntry(word='Opus', categories={Tag.AUDIO_TERM}), + + # MISC + ElementEntry(word='DUAL', categories=set(), regex_dict={ + r'DUAL[\W_]?AUDIO': {0: {Tag.AUDIO_TERM}} + }), + ElementEntry(word='DUALAUDIO', categories={Tag.AUDIO_TERM}), + ElementEntry(word='MULTI', categories=set(), regex_dict={ + r'MULTI[\W_]?AUDIO': {0: {Tag.AUDIO_TERM}}, + }), + ElementEntry(word='MULTIAUDIO', categories={Tag.AUDIO_TERM}), + + # Additional codecs + ElementEntry(word='PCM', categories={Tag.AUDIO_TERM}), + ElementEntry(word='ALAC', categories={Tag.AUDIO_TERM}), + ElementEntry(word='WMA', categories={Tag.AUDIO_TERM}), + ElementEntry(word='ATMOS', categories={Tag.AUDIO_TERM}), + ElementEntry(word='HE', categories=set(), regex_dict={ + r'HE[\W_]?AAC': {0: {Tag.AUDIO_TERM}} + }), +] \ No newline at end of file diff --git a/aniparse/keyword/BRACKET.py b/aniparse/keyword/BRACKET.py new file mode 100644 index 0000000..68d935e --- /dev/null +++ b/aniparse/keyword/BRACKET.py @@ -0,0 +1,13 @@ +import itertools + +from aniparse.core import constant +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +brackets = [] +for word in itertools.chain(constant.OPEN_BRACKETS, constant.CLOSE_BRACKETS): + brackets.append(ElementEntry( + word=word, + categories={Tag.BRACKET} + )) + diff --git a/aniparse/keyword/CONTENT_TYPE.py b/aniparse/keyword/CONTENT_TYPE.py new file mode 100644 index 0000000..894acd4 --- /dev/null +++ b/aniparse/keyword/CONTENT_TYPE.py @@ -0,0 +1,17 @@ +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.core.token_tags import Tag + +content_type = [ + ElementEntry(word='ED', categories={Tag.CONTENT_TYPE}), + ElementEntry(word='ENDING', categories={Tag.CONTENT_TYPE}), + ElementEntry(word='NCED', categories={Tag.CONTENT_TYPE}), + ElementEntry(word='CLEAN', categories=set(), regex_dict={ + r'CLEAN[-.\s]?(ENDING|OPENING)S?': {0: {Tag.CONTENT_TYPE}} + }), + ElementEntry(word='OP', categories={Tag.CONTENT_TYPE}), + ElementEntry(word='OPENING', categories={Tag.CONTENT_TYPE}), + ElementEntry(word='NCOP', categories={Tag.CONTENT_TYPE}), + ElementEntry(word='PREVIEW', categories={Tag.CONTENT_TYPE}), + ElementEntry(word='PV', categories={Tag.CONTENT_TYPE}), + ElementEntry(word='PILOT', categories={Tag.CONTENT_TYPE}) +] diff --git a/aniparse/keyword/CONTEXT_DELIMITER.py b/aniparse/keyword/CONTEXT_DELIMITER.py new file mode 100644 index 0000000..36bdbec --- /dev/null +++ b/aniparse/keyword/CONTEXT_DELIMITER.py @@ -0,0 +1,10 @@ +from aniparse.core import constant +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.core.token_tags import Tag + +context_delimiters = [] +for char in constant.CONTEXT_DELIMITER: + context_delimiters.append(ElementEntry( + word=char, + categories={Tag.CONTEXT_DELIMITER} + )) diff --git a/aniparse/keyword/CONTEXT_DEPENDENT.py b/aniparse/keyword/CONTEXT_DEPENDENT.py new file mode 100644 index 0000000..4081f4a --- /dev/null +++ b/aniparse/keyword/CONTEXT_DEPENDENT.py @@ -0,0 +1,14 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +context_dependent_prefix = [ + ElementEntry(word='THE', categories={Tag.CONTEXT_DEPENDENT}), + ElementEntry(word='PART', categories={Tag.CONTEXT_DEPENDENT}, regex_dict={ + r'PART(\d+)': { + 0: {Tag.CONTENT_IDENTIFIER}, + 1: {Tag.SEQUENCE_NUMBER}, + }, + }), + ElementEntry(word='+', categories={Tag.CONTEXT_DEPENDENT}), + ElementEntry(word='&', categories={Tag.CONTEXT_DEPENDENT}), +] \ No newline at end of file diff --git a/aniparse/keyword/DELIMITER.py b/aniparse/keyword/DELIMITER.py new file mode 100644 index 0000000..737ce71 --- /dev/null +++ b/aniparse/keyword/DELIMITER.py @@ -0,0 +1,10 @@ +from aniparse.core import constant +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.core.token_tags import Tag + +delimiters = [] +for char in constant.DELIMITERS: + delimiters.append(ElementEntry( + word=char, + categories={Tag.DELIMITER} + )) diff --git a/aniparse/keyword/DEVICE_COMPATIBILITY.py b/aniparse/keyword/DEVICE_COMPATIBILITY.py new file mode 100644 index 0000000..96a143c --- /dev/null +++ b/aniparse/keyword/DEVICE_COMPATIBILITY.py @@ -0,0 +1,19 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +device_compatibility_prefix = [ + ElementEntry(word='IPAD', categories={Tag.DEVICE_COMPATIBILITY}, regex_dict={ + r'IPAD\d+': {0: {Tag.DEVICE_COMPATIBILITY}} + }), + ElementEntry(word='IPHONE', categories={Tag.DEVICE_COMPATIBILITY}, regex_dict={ + r'IPHONE\d+': {0: {Tag.DEVICE_COMPATIBILITY}} + }), + ElementEntry(word='IPOD', categories={Tag.DEVICE_COMPATIBILITY}), + ElementEntry(word='PS', categories={Tag.DEVICE_COMPATIBILITY}, regex_dict={ + r'PS\d+': {0: {Tag.DEVICE_COMPATIBILITY}} + }), + ElementEntry(word='XBOX', categories={Tag.DEVICE_COMPATIBILITY}, regex_dict={ + r'XBOX\d+': {0: {Tag.DEVICE_COMPATIBILITY}} + }), + ElementEntry(word='ANDROID', categories={Tag.DEVICE_COMPATIBILITY}), +] diff --git a/aniparse/keyword/EPISODE_PREFIX.py b/aniparse/keyword/EPISODE_PREFIX.py new file mode 100644 index 0000000..45acfeb --- /dev/null +++ b/aniparse/keyword/EPISODE_PREFIX.py @@ -0,0 +1,31 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +episode_prefix = [ + ElementEntry(word='EP', categories={Tag.EPISODE}, regex_dict={ + r'EP\.?': {0: {Tag.EPISODE}} + }), + ElementEntry(word='EPS', categories={Tag.EPISODE}, regex_dict={ + r'EPS\.?': {0: {Tag.EPISODE}} + }), + ElementEntry(word='EPISODE', categories={Tag.EPISODE}, regex_dict={ + r'EPISODE\.?': {0: {Tag.EPISODE}} + }), + ElementEntry(word='EPISODES', categories={Tag.EPISODE}, regex_dict={ + r'EPISODES\.?': {0: {Tag.EPISODE}} + }), + ElementEntry(word='CAPITULO', categories={Tag.EPISODE}), + ElementEntry(word='EPISODIO', categories={Tag.EPISODE}), + ElementEntry(word='FOLGE', categories={Tag.EPISODE}), + ElementEntry(word='E', categories={Tag.EPISODE}), + ElementEntry(word='#', categories={Tag.EPISODE}), + ElementEntry(word='X', categories=set(), regex_dict={ + r"(\d+)([\W_])?(X)([\W_])?(\d+)": { + 1: {Tag.SEQUENCE_NUMBER}, + 2: {Tag.DELIMITER}, + 3: {Tag.EPISODE}, + 4: {Tag.DELIMITER}, + 5: {Tag.SEQUENCE_NUMBER} + } + }) +] diff --git a/aniparse/keyword/FILE_INDEX.py b/aniparse/keyword/FILE_INDEX.py new file mode 100644 index 0000000..8129b2c --- /dev/null +++ b/aniparse/keyword/FILE_INDEX.py @@ -0,0 +1,5 @@ +from aniparse.keyword import ElementEntry + +file_indexes = [ + # ElementEntry("", ) +] \ No newline at end of file diff --git a/aniparse/keyword/LANG.py b/aniparse/keyword/LANG.py new file mode 100644 index 0000000..35f984a --- /dev/null +++ b/aniparse/keyword/LANG.py @@ -0,0 +1,99 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +language = [ + # ISO 639-2 three-letter codes (the standard in media tagging) + # Users can extend via custom wordlist for less common languages + ElementEntry(word='ENG', categories={Tag.LANGUAGE}), + ElementEntry(word='JPN', categories={Tag.LANGUAGE}), + ElementEntry(word='JAP', categories={Tag.LANGUAGE}), + ElementEntry(word='CHI', categories={Tag.LANGUAGE}), + ElementEntry(word='KOR', categories={Tag.LANGUAGE}), + ElementEntry(word='SPA', categories={Tag.LANGUAGE}), + ElementEntry(word='FRE', categories={Tag.LANGUAGE}), + ElementEntry(word='FRA', categories={Tag.LANGUAGE}), + ElementEntry(word='GER', categories={Tag.LANGUAGE}), + ElementEntry(word='DEU', categories={Tag.LANGUAGE}), + ElementEntry(word='ITA', categories={Tag.LANGUAGE}), + ElementEntry(word='POR', categories={Tag.LANGUAGE}), + ElementEntry(word='RUS', categories={Tag.LANGUAGE}), + ElementEntry(word='ARA', categories={Tag.LANGUAGE}), + ElementEntry(word='THA', categories={Tag.LANGUAGE}), + ElementEntry(word='VIE', categories={Tag.LANGUAGE}), + ElementEntry(word='POL', categories={Tag.LANGUAGE}), + ElementEntry(word='DUT', categories={Tag.LANGUAGE}), + ElementEntry(word='HUN', categories={Tag.LANGUAGE}), + ElementEntry(word='TUR', categories={Tag.LANGUAGE}), + ElementEntry(word='GRE', categories={Tag.LANGUAGE}), + ElementEntry(word='HEB', categories={Tag.LANGUAGE}), + ElementEntry(word='HIN', categories={Tag.LANGUAGE}), + ElementEntry(word='SWE', categories={Tag.LANGUAGE}), + ElementEntry(word='NOR', categories={Tag.LANGUAGE}), + ElementEntry(word='UKR', categories={Tag.LANGUAGE}), + ElementEntry(word='CZE', categories={Tag.LANGUAGE}), + ElementEntry(word='RON', categories={Tag.LANGUAGE}), + ElementEntry(word='FIN', categories={Tag.LANGUAGE}), + ElementEntry(word='DAN', categories={Tag.LANGUAGE}), + ElementEntry(word='PER', categories={Tag.LANGUAGE}), + + # Full names that commonly appear in anime filenames + ElementEntry(word='ENGLISH', categories={Tag.LANGUAGE}), + ElementEntry(word='JAPANESE', categories={Tag.LANGUAGE}), + ElementEntry(word='CHINESE', categories={Tag.LANGUAGE}), + ElementEntry(word='KOREAN', categories={Tag.LANGUAGE}), + ElementEntry(word='SPANISH', categories={Tag.LANGUAGE}), + ElementEntry(word='FRENCH', categories={Tag.LANGUAGE}), + ElementEntry(word='GERMAN', categories={Tag.LANGUAGE}), + ElementEntry(word='ITALIAN', categories={Tag.LANGUAGE}), + ElementEntry(word='PORTUGUESE', categories={Tag.LANGUAGE}), + ElementEntry(word='RUSSIAN', categories={Tag.LANGUAGE}), + + # Two-letter codes only when unambiguous (no conflicts with other tokens) + ElementEntry(word='EN', categories={Tag.LANGUAGE}), + ElementEntry(word='JP', categories={Tag.LANGUAGE}), + ElementEntry(word='SP', categories={Tag.LANGUAGE}), + ElementEntry(word='CH', categories={Tag.LANGUAGE}), + ElementEntry(word='FR', categories={Tag.LANGUAGE}), + ElementEntry(word='DE', categories={Tag.LANGUAGE}), + ElementEntry(word='RU', categories={Tag.LANGUAGE}), + ElementEntry(word='PL', categories={Tag.LANGUAGE}), + ElementEntry(word='NL', categories={Tag.LANGUAGE}), + ElementEntry(word='PT', categories={Tag.LANGUAGE}, regex_dict={ + r'PT[\W_]?BR': {0: {Tag.LANGUAGE}} + }), + + # Common shorthands specific to anime releases + ElementEntry(word='VOSTFR', categories={Tag.LANGUAGE}), # French sub + ElementEntry(word='PTBR', categories={Tag.LANGUAGE}), # Brazilian Portuguese + ElementEntry(word='ESP', categories={Tag.LANGUAGE}), # Spanish alt + ElementEntry(word='DEUTSCH', categories={Tag.LANGUAGE}), # German alt + + # Chinese variants (important for anime — lots of sub groups) + ElementEntry(word='ZH', categories={Tag.LANGUAGE}), + ElementEntry(word='SC', categories={Tag.LANGUAGE}), # Simplified Chinese + ElementEntry(word='TC', categories={Tag.LANGUAGE}), # Traditional Chinese + ElementEntry(word='CHS', categories={Tag.LANGUAGE}), + ElementEntry(word='CHT', categories={Tag.LANGUAGE}), + ElementEntry(word='GB', categories={Tag.LANGUAGE}), + ElementEntry(word='MANDARIN', categories={Tag.LANGUAGE}), + ElementEntry(word='CANTONESE', categories={Tag.LANGUAGE}), + # Note: TAIWANESE intentionally excluded — it's a modifier (e.g., "Taiwanese Mandarin") + # not a standalone language identifier + + # Indonesian / Malay (common in SEA anime community) + ElementEntry(word='IND', categories={Tag.LANGUAGE}), + ElementEntry(word='MAL', categories={Tag.LANGUAGE}), + + # CJK language/dub descriptors + ElementEntry(word='中文配', categories={Tag.LANGUAGE}), # Chinese dub + ElementEntry(word='中文配音', categories={Tag.LANGUAGE}), # Chinese dub (full) + ElementEntry(word='台粤配', categories={Tag.LANGUAGE}), # Taiwanese+Cantonese dub + ElementEntry(word='国语', categories={Tag.LANGUAGE}), # Mandarin + ElementEntry(word='國語', categories={Tag.LANGUAGE}), # Mandarin (traditional) + ElementEntry(word='粤语', categories={Tag.LANGUAGE}), # Cantonese + ElementEntry(word='粵語', categories={Tag.LANGUAGE}), # Cantonese (traditional) + ElementEntry(word='中配版', categories={Tag.LANGUAGE}), # Chinese dub version + ElementEntry(word='中配', categories={Tag.LANGUAGE}), # Chinese dub (short) + ElementEntry(word='国语版', categories={Tag.LANGUAGE}), # Mandarin version + ElementEntry(word='國語版', categories={Tag.LANGUAGE}), # Mandarin version (traditional) +] diff --git a/aniparse/keyword/RELEASE_INFORMATION.py b/aniparse/keyword/RELEASE_INFORMATION.py new file mode 100644 index 0000000..daedd7c --- /dev/null +++ b/aniparse/keyword/RELEASE_INFORMATION.py @@ -0,0 +1,21 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +release_information = [ + ElementEntry(word='BATCH', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='COMPLETE', categories={Tag.RELEASE_INFORMATION}, regex_dict={ + r'COMPLETE[\W_]?VERSION': {0: {Tag.RELEASE_INFORMATION}} + }), + ElementEntry(word='PATCH', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='REMUX', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='END', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='FINAL', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='REMASTER', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='REMASTERED', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='UNCENSORED', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='UNCUT', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='TS', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='VFR', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='WIDESCREEN', categories={Tag.RELEASE_INFORMATION}), + ElementEntry(word='WS', categories={Tag.RELEASE_INFORMATION}), # Accommodate "WIDESCREEN" +] diff --git a/aniparse/keyword/RELEASE_VERSION.py b/aniparse/keyword/RELEASE_VERSION.py new file mode 100644 index 0000000..9615eba --- /dev/null +++ b/aniparse/keyword/RELEASE_VERSION.py @@ -0,0 +1,15 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +release_version = [ + ElementEntry( + word='V', + categories=set(), + regex_dict={r'(V)(\d{1,2})': { + 1: {Tag.CONTEXT_DELIMITER}, + 2: {Tag.RELEASE_VERSION} + }} + ), + ElementEntry(word='VER', categories={Tag.RELEASE_VERSION}), + ElementEntry(word='VERSION', categories={Tag.RELEASE_VERSION}), +] diff --git a/aniparse/keyword/SEASON_PREFIX.py b/aniparse/keyword/SEASON_PREFIX.py new file mode 100644 index 0000000..3d39429 --- /dev/null +++ b/aniparse/keyword/SEASON_PREFIX.py @@ -0,0 +1,11 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +season_prefix = [ + ElementEntry(word='SAISON', categories={Tag.SEASON}), + ElementEntry(word='SEASON', categories={Tag.SEASON}), + ElementEntry(word='S', categories={Tag.SEASON}), + ElementEntry(word='COUR', categories={Tag.SEASON}), + ElementEntry(word='TEMPORADA', categories={Tag.SEASON}), # Spanish + ElementEntry(word='STAFFEL', categories={Tag.SEASON}), # German +] diff --git a/aniparse/keyword/SERIES_TYPE.py b/aniparse/keyword/SERIES_TYPE.py new file mode 100644 index 0000000..c98fd37 --- /dev/null +++ b/aniparse/keyword/SERIES_TYPE.py @@ -0,0 +1,17 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +series_type = [ + ElementEntry(word='GEKIJOUBAN', categories={Tag.SERIES_TYPE}), + ElementEntry(word='MOVIE', categories={Tag.SERIES_TYPE}), + ElementEntry(word='MOVIES', categories={Tag.SERIES_TYPE}, canonical='Movie'), + ElementEntry(word='OAD', categories={Tag.SERIES_TYPE}), + ElementEntry(word='OAV', categories={Tag.SERIES_TYPE}), + ElementEntry(word='OVAS', categories={Tag.SERIES_TYPE}, canonical='OVA'), + ElementEntry(word='ONA', categories={Tag.SERIES_TYPE}), + ElementEntry(word='OVA', categories={Tag.SERIES_TYPE}), # Accommodate "OAV" + ElementEntry(word='SPECIAL', categories={Tag.SERIES_TYPE}), + ElementEntry(word='SPECIALS', categories={Tag.SERIES_TYPE}, canonical='Special'), + ElementEntry(word='TV', categories={Tag.SERIES_TYPE}), + ElementEntry(word='SP', categories={Tag.SERIES_TYPE}), # Accommodate "SPECIAL" +] diff --git a/aniparse/keyword/SOURCE.py b/aniparse/keyword/SOURCE.py new file mode 100644 index 0000000..b7e2267 --- /dev/null +++ b/aniparse/keyword/SOURCE.py @@ -0,0 +1,52 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +source_prefix = [ + ElementEntry(word='BD', categories={Tag.SOURCE}, regex_dict={ + r'BD[\W_]?RIP': {0: {Tag.SOURCE}} + }), + ElementEntry(word='BDRIP', categories={Tag.SOURCE}), + ElementEntry(word='BLURAY', categories={Tag.SOURCE}), + ElementEntry(word='BLU', categories=set(), regex_dict={ + r'BLU[\W_]?RAY': {0: {Tag.SOURCE}} + }), + ElementEntry(word='DVD', categories={Tag.SOURCE}, regex_dict={ + r'DVD[\W_]?([Rr]\d{1,2}|R(?:E?MUX|IP))': {0: {Tag.SOURCE}} + }), + ElementEntry(word='DVDRIP', categories={Tag.SOURCE}), + ElementEntry(word='DVDSCR', categories={Tag.SOURCE}), + ElementEntry(word='REMUX', categories={Tag.SOURCE}), + ElementEntry(word='RIP', categories={Tag.SOURCE}), + ElementEntry(word='HDTV', categories={Tag.SOURCE}), + ElementEntry(word='TV', categories={Tag.SOURCE}, regex_dict={ + r'TV[\W_]?RIP': {0: {Tag.SOURCE}} + }), + ElementEntry(word='TVRIP', categories={Tag.SOURCE}, regex_dict={ + r'TV[\W_]?RIP': {0: {Tag.SOURCE}} + }), + ElementEntry(word='WEB', categories={Tag.SOURCE}, regex_dict={ + r'WEB[\W_]?(RIP|CAST|DL)': {0: {Tag.SOURCE}} + }), + ElementEntry(word='WEBRIP', categories={Tag.SOURCE}, regex_dict={ + r'WEB[\W_]?(RIP|CAST|DL)': {0: {Tag.SOURCE}} + }), + ElementEntry(word='DL', categories=set(), regex_dict={ + r'WEB[\W_]?DL': {0: {Tag.SOURCE}} + }), + + # Streaming service tags (used as source identifiers) + ElementEntry(word='CR', categories={Tag.RELEASE_INFORMATION}), # Crunchyroll + ElementEntry(word='AMZN', categories={Tag.SOURCE}), # Amazon Prime + ElementEntry(word='NF', categories={Tag.SOURCE}), # Netflix + ElementEntry(word='DSNP', categories={Tag.SOURCE}), # Disney+ + ElementEntry(word='HIDI', categories={Tag.SOURCE}), # HiDive + ElementEntry(word='HIDIVE', categories={Tag.SOURCE}), + ElementEntry(word='FUNI', categories={Tag.SOURCE}), # Funimation + ElementEntry(word='VRV', categories={Tag.SOURCE}), + ElementEntry(word='HULU', categories={Tag.SOURCE}), + ElementEntry(word='MAX', categories={Tag.SOURCE}), # HBO Max + ElementEntry(word='ABEMA', categories={Tag.SOURCE}), + ElementEntry(word='ADN', categories={Tag.SOURCE}), # Anime Digital Network + ElementEntry(word='B-GLOBAL', categories={Tag.SOURCE}), # Bilibili Global + ElementEntry(word='BILI', categories={Tag.SOURCE}), # Bilibili +] \ No newline at end of file diff --git a/aniparse/keyword/SUBS_TERM.py b/aniparse/keyword/SUBS_TERM.py new file mode 100644 index 0000000..f5323dd --- /dev/null +++ b/aniparse/keyword/SUBS_TERM.py @@ -0,0 +1,50 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +subs_term_prefix = [ + ElementEntry(word='ASS', categories={Tag.SUBS_TERM}), + ElementEntry(word='BIG5', categories={Tag.SUBS_TERM}), + ElementEntry(word='DUB', categories={Tag.SUBS_TERM}), # Accommodate "DUBBED" + ElementEntry(word='DUBBED', categories={Tag.SUBS_TERM}), + ElementEntry(word='HARDSUB', categories={Tag.SUBS_TERM}), + ElementEntry(word='HARDSUBS', categories={Tag.SUBS_TERM}), + ElementEntry(word='RAW', categories={Tag.SUBS_TERM}), + ElementEntry(word='SOFTSUB', categories={Tag.SUBS_TERM}), + ElementEntry(word='SOFTSUBS', categories={Tag.SUBS_TERM}), + ElementEntry(word='SUB', categories={Tag.SUBS_TERM}), # Accommodate "SUBBED" + ElementEntry(word='SUBBED', categories={Tag.SUBS_TERM}), + ElementEntry(word='SUBTITLE', categories={Tag.SUBS_TERM}), + ElementEntry(word='SUBTITLES', categories={Tag.SUBS_TERM}), + ElementEntry(word='SUBTITLED', categories={Tag.SUBS_TERM}), + ElementEntry(word='MULTIPLE', categories=set(), regex_dict={ + r'MULTIPLE[\W_]?(SUBTITLES?|SUBS?)': {0: {Tag.SUBS_TERM}} + }), + + # MISC + ElementEntry(word='DUAL', categories=set(), regex_dict={ + r'DUAL[\W_]?(SUBTITLES?|SUBS?)': {0:{Tag.SUBS_TERM}} + }), + ElementEntry(word='DUALSUB', categories={Tag.SUBS_TERM}), + ElementEntry(word='DUALSUBS', categories={Tag.SUBS_TERM}), + ElementEntry(word='MULTI', categories=set(), regex_dict={ + r'MULTI[\W_]?(SUBTITLES?|SUBS?)': {0:{Tag.SUBS_TERM}} + }), + ElementEntry(word='MULTISUB', categories={Tag.SUBS_TERM}), + ElementEntry(word='MULTISUBS', categories={Tag.SUBS_TERM}), + # Handle hyphenated forms: "multi-subs" — tokenizer splits on hyphens + # so these are handled by the MULTI regex above when tokens aren't split, + # but we need standalone entries for when they are split + ElementEntry(word='SUBS', categories={Tag.SUBS_TERM}), + ElementEntry(word='SUBX', categories=set(), regex_dict={ + r'SUBX[\W_]?\d{1,2}': {0: {Tag.SUBS_TERM}} + }), + + # Additional terms + ElementEntry(word='SRT', categories={Tag.SUBS_TERM}), + ElementEntry(word='VTT', categories={Tag.SUBS_TERM}), + ElementEntry(word='PGS', categories={Tag.SUBS_TERM}), + ElementEntry(word='CC', categories={Tag.SUBS_TERM}), # Closed captions + ElementEntry(word='SOFT', categories=set(), regex_dict={ + r'SOFT[\W_]?SUBS?': {0: {Tag.SUBS_TERM}} + }), +] \ No newline at end of file diff --git a/aniparse/keyword/VIDEO_RESOLUTION.py b/aniparse/keyword/VIDEO_RESOLUTION.py new file mode 100644 index 0000000..3400aba --- /dev/null +++ b/aniparse/keyword/VIDEO_RESOLUTION.py @@ -0,0 +1,17 @@ +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.core.token_tags import Tag + +video_resolution = [ + ElementEntry(word='P', categories=set(), regex_dict={ + r'\d{3,4}p': {0: {Tag.VIDEO_RESOLUTION}} + }), + ElementEntry(word='I', categories=set(), regex_dict={ + r'\d{3,4}i': {0: {Tag.VIDEO_RESOLUTION}} + }), + ElementEntry(word='K', categories=set(), regex_dict={ + r'\dK': {0: {Tag.VIDEO_RESOLUTION}} + }), + ElementEntry(word='X', categories=set(), regex_dict={ + r'(\d{3,4}x\d{3,4})': {0: {Tag.VIDEO_RESOLUTION}} + }) +] diff --git a/aniparse/keyword/VIDEO_TERM.py b/aniparse/keyword/VIDEO_TERM.py new file mode 100644 index 0000000..5cbc680 --- /dev/null +++ b/aniparse/keyword/VIDEO_TERM.py @@ -0,0 +1,74 @@ +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.core.token_tags import Tag + +video_term_prefix = [ + ElementEntry(word='HI', categories=set(), regex_dict={ + r'HI(\d{2}P?)': {0: {Tag.VIDEO_TERM}} + }), + ElementEntry(word='H', categories=set(), regex_dict={ + r'H[\W_]?26[45]': {0: {Tag.VIDEO_TERM}} + }), + ElementEntry(word='X', categories=set(), regex_dict={ + r'X\.?26[45]': {0: {Tag.VIDEO_TERM}} + }), + ElementEntry(word='HEVC', categories={Tag.VIDEO_TERM}, regex_dict={ + r'HEVC2?': {0: {Tag.VIDEO_TERM}} + }), + ElementEntry(word='DIVX', categories={Tag.VIDEO_TERM}, regex_dict={ + r'DIVX[\W_]?(\d(\.\d(\.\d)?)?)?': {0: {Tag.VIDEO_TERM}} + }), + ElementEntry(word='WMV', categories={Tag.VIDEO_TERM}, regex_dict={ + r'WMV[39]?': {0: {Tag.VIDEO_TERM}} + }), + ElementEntry(word='HQ', categories={Tag.VIDEO_TERM}), + ElementEntry(word='LQ', categories={Tag.VIDEO_TERM}), + ElementEntry(word='HD', categories={Tag.VIDEO_TERM}), + ElementEntry(word='SD', categories={Tag.VIDEO_TERM}), + ElementEntry(word='AVI', categories={Tag.VIDEO_TERM}), + ElementEntry(word='AV', categories=set(), regex_dict={ + r'AV[\W_]?1': {0: {Tag.VIDEO_TERM}} + }), + ElementEntry(word='AV1', categories={Tag.VIDEO_TERM}), + ElementEntry(word='RMVB', categories={Tag.VIDEO_TERM}), + ElementEntry(word='AVC', categories={Tag.VIDEO_TERM}), + ElementEntry(word='XVID', categories={Tag.VIDEO_TERM}), + + # Standalone codec entries (for when tokenizer keeps them as single tokens) + ElementEntry(word='X264', categories={Tag.VIDEO_TERM}), + ElementEntry(word='X265', categories={Tag.VIDEO_TERM}), + ElementEntry(word='H264', categories={Tag.VIDEO_TERM}), + ElementEntry(word='H265', categories={Tag.VIDEO_TERM}), + + # Additional codecs + ElementEntry(word='VP9', categories={Tag.VIDEO_TERM}), + ElementEntry(word='VP8', categories={Tag.VIDEO_TERM}), + ElementEntry(word='MPEG4', categories={Tag.VIDEO_TERM}), + ElementEntry(word='MPEG2', categories={Tag.VIDEO_TERM}), + ElementEntry(word='THEORA', categories={Tag.VIDEO_TERM}), + ElementEntry(word='PRORES', categories={Tag.VIDEO_TERM}), + ElementEntry(word='HDR', categories={Tag.VIDEO_TERM}), + ElementEntry(word='HDR10', categories={Tag.VIDEO_TERM}), + ElementEntry(word='DV', categories={Tag.VIDEO_TERM}), # Dolby Vision + ElementEntry(word='DOLBY', categories=set(), regex_dict={ + r'DOLBY[\W_]?VISION': {0: {Tag.VIDEO_TERM}} + }), +] + +video_term_suffix = [ + ElementEntry(word='FPS', categories=set(), regex_dict={ # Adjust max_length for possible decimals in FPS + r'\d{1,3}(\.\d{1,3})?FPS': {0: {Tag.VIDEO_TERM}} + }), + ElementEntry(word='BIT', categories=set(), regex_dict={ + r'\d{1,2}[\W_]?BITS?': {0: {Tag.VIDEO_TERM}} + }), + + # Common compound bit-depth tokens + ElementEntry(word='8BIT', categories={Tag.VIDEO_TERM}), + ElementEntry(word='10BIT', categories={Tag.VIDEO_TERM}), + ElementEntry(word='12BIT', categories={Tag.VIDEO_TERM}), + + # Hi10p / Hi444pp + ElementEntry(word='HI10P', categories={Tag.VIDEO_TERM}), + ElementEntry(word='HI10', categories={Tag.VIDEO_TERM}), + ElementEntry(word='HI444PP', categories={Tag.VIDEO_TERM}), +] diff --git a/aniparse/keyword/VOLUME_PREFIX.py b/aniparse/keyword/VOLUME_PREFIX.py new file mode 100644 index 0000000..a5119f5 --- /dev/null +++ b/aniparse/keyword/VOLUME_PREFIX.py @@ -0,0 +1,11 @@ +from aniparse.core.token_tags import Tag +from aniparse.abstraction.keyword_base import ElementEntry + +volume_prefix = [ + ElementEntry(word='VOL', categories={Tag.VOLUME}, regex_dict={ + r'VOL\.?': {0: {Tag.VOLUME}} + }), + ElementEntry(word='VOLUME', categories={Tag.VOLUME}, regex_dict={ + r'VOLUME\.?': {0: {Tag.VOLUME}} + }), +] diff --git a/aniparse/keyword/__init__.py b/aniparse/keyword/__init__.py new file mode 100644 index 0000000..d7aba43 --- /dev/null +++ b/aniparse/keyword/__init__.py @@ -0,0 +1,55 @@ +from aniparse.core import constant +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.core.token_tags import Tag +from aniparse.keyword.AUDIO_TERM import audio_term +from aniparse.keyword.BRACKET import brackets +from aniparse.keyword.CONTENT_TYPE import content_type +from aniparse.keyword.CONTEXT_DELIMITER import context_delimiters +from aniparse.keyword.CONTEXT_DEPENDENT import context_dependent_prefix +from aniparse.keyword.DELIMITER import delimiters +from aniparse.keyword.DEVICE_COMPATIBILITY import device_compatibility_prefix +from aniparse.keyword.EPISODE_PREFIX import episode_prefix +from aniparse.keyword.LANG import language +from aniparse.keyword.RELEASE_INFORMATION import release_information +from aniparse.keyword.RELEASE_VERSION import release_version +from aniparse.keyword.SEASON_PREFIX import season_prefix +from aniparse.keyword.SERIES_TYPE import series_type +from aniparse.keyword.SOURCE import source_prefix +from aniparse.keyword.VOLUME_PREFIX import volume_prefix +from aniparse.keyword.SUBS_TERM import subs_term_prefix +from aniparse.keyword.VIDEO_RESOLUTION import video_resolution +from aniparse.keyword.VIDEO_TERM import video_term_prefix, video_term_suffix + +DEFAULT_KEYWORD = [ + *audio_term, + *context_delimiters, + *content_type, + *device_compatibility_prefix, + *episode_prefix, + *delimiters, + *language, + *release_information, + *release_version, + *season_prefix, + *series_type, + *source_prefix, + *subs_term_prefix, + *video_term_prefix, + *video_resolution, + *volume_prefix, + *video_term_suffix, + *brackets, + *context_dependent_prefix, + ElementEntry(constant.CHECKSUM_PLACEHOLDER, set(), regex_dict={ + r"(? dict: + """Auto-generate dict from dataclass, recursing into nested dataclasses/lists.""" + result = {} + for f in fields(obj): + value = getattr(obj, f.name) + if skip_none and value is None: + continue + if isinstance(value, list): + converted = [item.to_dict() if hasattr(item, 'to_dict') else item for item in value] + if skip_none and not converted: + continue + result[f.name] = converted + elif hasattr(value, 'to_dict'): + result[f.name] = value.to_dict() + else: + result[f.name] = value + return result + + +def _from_dict(cls, data: dict): + """Auto-generate instance from dict, using field metadata for nested types.""" + kwargs = {} + for f in fields(cls): + if f.name not in data: + continue + deserializer = f.metadata.get("from_dict") + if deserializer: + kwargs[f.name] = deserializer(data[f.name]) + else: + kwargs[f.name] = data[f.name] + return cls(**kwargs) + + +@dataclass +class VideoResolution: + video_height: Optional[int] = None + video_width: Optional[int] = None + scan_method: Optional[str] = None + + def to_dict(self) -> dict: + return _to_dict(self) + + @staticmethod + def from_dict(data: dict) -> VideoResolution: + return _from_dict(VideoResolution, data) + + +@dataclass +class SequenceNumber: + number: int | float + total: Optional[int | float] = None + release_version: Optional[str] = None + title: Optional[str] = None + part: Optional[str] = None + alternative: Optional[List[SequenceNumber]] = field( + default_factory=list, + metadata={"from_dict": lambda v: [SequenceNumber.from_dict(alt) for alt in v]} + ) + + def to_dict(self) -> dict: + return _to_dict(self) + + @staticmethod + def from_dict(data: dict) -> SequenceNumber: + return _from_dict(SequenceNumber, data) + + +@dataclass +class SequenceIdentifierNumber(SequenceNumber): + identifier: Optional[str] = None + number: Optional[int | float] = None + alternative: Optional[List[SequenceIdentifierNumber]] = field( + default_factory=list, + metadata={"from_dict": lambda v: [SequenceIdentifierNumber.from_dict(alt) for alt in v]} + ) + + def to_dict(self) -> dict: + return _to_dict(self) + + @staticmethod + def from_dict(data: dict) -> SequenceIdentifierNumber: + return _from_dict(SequenceIdentifierNumber, data) + + +T = TypeVar('T', SequenceNumber, SequenceIdentifierNumber) + + +def _deserialize_sequence(seq: dict) -> Sequence: + """Dispatch a single sequence dict to SequenceRange, SequenceIdentifierNumber, or SequenceNumber.""" + if "start" in seq and "end" in seq: + return SequenceRange.from_dict(seq) + elif "identifier" in seq: + return SequenceIdentifierNumber.from_dict(seq) + else: + return SequenceNumber.from_dict(seq) + + +def _deserialize_sequence_list(v: list) -> list: + return [_deserialize_sequence(seq) for seq in v] + + +def _deserialize_identifier_sequence_list(v: list) -> list: + """For content_type: default to SequenceIdentifierNumber.""" + result = [] + for seq in v: + if "start" in seq and "end" in seq: + result.append(SequenceRange.from_dict(seq)) + else: + result.append(SequenceIdentifierNumber.from_dict(seq)) + return result + + +@dataclass +class SequenceRange(Generic[T]): + start: T + end: T + alternative: Optional[List[SequenceRange[T]]] = field(default_factory=list) + + def __post_init__(self): + if type(self.start) is not type(self.end): + raise ValueError("Start and end must be of the same type") + + def to_dict(self) -> dict: + return _to_dict(self) + + @staticmethod + def from_dict(data: dict) -> SequenceRange[T]: + start_data = data.get("start", {}) + end_data = data.get("end", {}) + + if "identifier" in start_data: + start = SequenceIdentifierNumber.from_dict(start_data) + else: + start = SequenceNumber.from_dict(start_data) + + if "identifier" in end_data: + end = SequenceIdentifierNumber.from_dict(end_data) + else: + end = SequenceNumber.from_dict(end_data) + + return SequenceRange(start=start, end=end) + + +Sequence = Union[T, SequenceRange[T]] + + +@dataclass +class SeriesInfo: + title: Optional[str] = None + type: Optional[str] = None + year: Optional[List[Sequence[SequenceNumber]]] = field( + default=None, + metadata={"from_dict": _deserialize_sequence_list} + ) + season: Optional[List[Sequence[SequenceNumber]]] = field( + default=None, + metadata={"from_dict": _deserialize_sequence_list} + ) + episode: Optional[List[Sequence[SequenceNumber]]] = field( + default=None, + metadata={"from_dict": _deserialize_sequence_list} + ) + volume: Optional[List[Sequence[SequenceNumber]]] = field( + default=None, + metadata={"from_dict": _deserialize_sequence_list} + ) + content_type: Optional[List[Sequence[SequenceIdentifierNumber]]] = field( + default=None, + metadata={"from_dict": _deserialize_identifier_sequence_list} + ) + + def to_dict(self) -> dict: + return _to_dict(self) + + @staticmethod + def from_dict(data: dict) -> SeriesInfo: + return _from_dict(SeriesInfo, data) + + +@dataclass +class Metadata: + file_name: str + audio_term: Optional[List[str]] = None + device_compatibility: Optional[List[str]] = None + file_extension: Optional[str] = None + file_checksum: Optional[str] = None + file_index: Optional[int] = None + language: Optional[List[str]] = None + video_resolution: Optional[List[VideoResolution]] = field( + default=None, + metadata={"from_dict": lambda v: [VideoResolution.from_dict(vr) for vr in v]} + ) + release_version: Optional[List[str]] = None + release_group: Optional[List[str]] = None + release_information: Optional[List[str]] = None + series: List[SeriesInfo] = field( + default=None, + metadata={"from_dict": lambda v: [SeriesInfo.from_dict(s) for s in v]} + ) + source: Optional[List[str]] = None + subs_term: Optional[List[str]] = None + video_term: Optional[List[str]] = None + other: Optional[List[str]] = None + unknown: Optional[List[str]] = None + + def to_dict(self) -> dict: + return _to_dict(self) + + @staticmethod + def from_dict(data: dict) -> Metadata: + return _from_dict(Metadata, data) diff --git a/aniparse/parser.py b/aniparse/parser.py deleted file mode 100644 index 29fc5ed..0000000 --- a/aniparse/parser.py +++ /dev/null @@ -1,812 +0,0 @@ -import logging - -from aniparse import parser_helper, parser_number, tokenizer -from aniparse.element import ElementCategory -from aniparse.parser_number import ParserNumber -from aniparse.token import TokenCategory, TokenFlags, Token -from aniparse.tokenizer import Tokenizer - -logger = logging.getLogger(__name__) - - -class Parser(Tokenizer, ParserNumber): - - def build_element(self, e_category: ElementCategory, token_begin=None, token_end=None) -> None: - """ - Set the element category for all tokens between token_begin and token_end. - - :param e_category: - :param token_begin: - :param token_end: - :return: - """ - for token in self.get_list(begin=token_begin, end=token_end): - if token.t_category == TokenCategory.UNKNOWN: - self.set_token_element(token, TokenCategory.IDENTIFIER, e_category) - elif token.t_category == TokenCategory.BRACKET: - self.set_token_element(token, TokenCategory.IDENTIFIER, e_category) - elif token.t_category == TokenCategory.DELIMITER: - delimiter = token.content - if self.options['keep_delimiters']: - self.set_token_element(token, TokenCategory.DELIMITER, e_category) - elif token != token_begin and token != token_end: - if delimiter == ',' or delimiter == '&': - self.set_token_element(token, TokenCategory.IDENTIFIER, e_category) - else: - token.content = " " - self.set_token_element(token, TokenCategory.IDENTIFIER, e_category) - else: - self.set_token_element(token, TokenCategory.IDENTIFIER, e_category) - - def is_anime_season_keyword(self, token) -> bool: - """ - Check if the token is a season keyword. - :param token: - :return: - """ - # check for tre previous tokens - previous_token = self.find_prev(token, TokenFlags.NOT_DELIMITER) - if previous_token: - number = parser_helper.get_number_from_ordinal(previous_token.content) - if number: - part_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - if part_token is not None and part_token.content.lower() == 'part': - part_number_token = self.find_next(part_token, TokenFlags.NOT_DELIMITER) - if part_number_token.content.isdigit(): - if self.options['season_part_as_unique']: - return False - part_token.t_category = TokenCategory.INVALID - part_number_token.t_category = TokenCategory.INVALID - - self.set_token_element(previous_token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_SEASON) - previous_token.content = number - return True - - # check for the next token - next_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - if next_token: - if next_token.content.isdigit(): - part_token = self.find_next(next_token, TokenFlags.NOT_DELIMITER) - if part_token is not None and part_token.content.lower() == 'part': - part_number_token = self.find_next(part_token, TokenFlags.NOT_DELIMITER) - if part_number_token.content.isdigit(): - if self.options['season_part_as_unique']: - return False - part_token.t_category = TokenCategory.INVALID - part_number_token.t_category = TokenCategory.INVALID - self.set_token_element(next_token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_SEASON) - return True - return False - - def parse(self) -> None: - """ - Parse the tokens. - First, the tokens are categorized. Then, find the indentifiable and searchable keyword. - After that, look for isolated number for the year and video resolution. Finally, look for episode number. - Once we have that information, we can determine the anime title, and episode title. - Lastly, we can check for the release group and validate the elements. - Any UNKNOWN token will be set to OTHER's category. - :return: - """ - if not self.tokenize(): - return None - - self.search_for_keywords() # search for unique keywords for [year, video resolution, crc32, etc] - - self.search_for_isolated_number() # search for isolated numbers for [season, anime year] - - self.search_for_episode_number() # search for episode number. If it not exist, then it can't have episode - - first_number = None - last_number = None - if self.counter.get(ElementCategory.EPISODE_NUMBER, 0) == 2: - first_number = self.find_next(None, element=ElementCategory.EPISODE_NUMBER) - last_number = self.find_next(first_number, element=ElementCategory.EPISODE_NUMBER) - elif self.counter.get(ElementCategory.VOLUME_NUMBER, 0) == 2: - first_number = self.find_next(None, element=ElementCategory.VOLUME_NUMBER) - last_number = self.find_next(first_number, element=ElementCategory.VOLUME_NUMBER) - if first_number and last_number: - tokens = list(self.get_list(flags=TokenFlags.UNKNOWN, begin=first_number, end=last_number)) - if len(tokens) == 1: - if tokens[0].content in "&": - self.set_token_element(tokens[0], TokenCategory.INVALID, ElementCategory.RANGE_SEPARATOR) - found_number = False - for token in self.tokens: - if token.t_category == TokenCategory.DELIMITER: - continue - - if token and parser_helper.find_number_in_string(token.content) is not None: - prev_token = self.find_prev(token, TokenFlags.NOT_DELIMITER) - if not prev_token or prev_token == token: - first_next_token = self.find_next(token) - first_not_delimiter_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - if first_next_token: - second_next_token = self.find_next(first_next_token, flags=TokenFlags.BRACKET | TokenFlags.DELIMITER) - if (second_next_token and - first_next_token.t_category == second_next_token.t_category == TokenCategory.DELIMITER and - first_next_token.content != second_next_token.content): - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.FILE_INDEX) - break - if first_not_delimiter_token and first_not_delimiter_token.t_category == TokenCategory.BRACKET: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.FILE_INDEX) - break - - if token.t_category == TokenCategory.UNKNOWN: - break - self.search_for_anime_title() - - self.search_for_episode_title() - - self.search_for_release_group() - - self.validate_elements() - - def search_for_anime_title(self) -> None: - """ - Search for the anime title. - First, we find not enclosed or unknown tokens. If we don't find any, then we - check if `check_title_enclosed` is True. If it is, then we check for enclosed tokens. - After that, we verify there is no Anime season, season prefix, episode number, volume number, - or anime type before the anime title if `title_before_episode` is True. - Finally, we can search the token_end for the anime title until it found an IDENTIFIER or BRACKET token. - - :return: - """ - enclosed_title = False - - token_begin = self.find(TokenFlags.NOT_ENCLOSED | TokenFlags.UNKNOWN) - if token_begin is None: - if self.options['check_title_enclosed']: - enclosed_title = True - token_begin = self.get(0) - skipped_previous_group = False - - while token_begin is not None: - token_begin = self.find_next(token_begin, TokenFlags.UNKNOWN | TokenFlags.IDENTIFIER) - if token_begin is None: - break - - if (token_begin.e_category == ElementCategory.ANIME_SEASON_PREFIX - or token_begin.e_category == ElementCategory.ANIME_SEASON - or token_begin.e_category == ElementCategory.EPISODE_PREFIX - or token_begin.e_category == ElementCategory.EPISODE_NUMBER - or token_begin.e_category == ElementCategory.ANIME_TYPE): - if self.options['title_before_episode']: - break - else: - continue - # Ignore groups that are composed of non-Latin characters - if parser_helper.is_mostly_latin_string(token_begin.content): - if skipped_previous_group: - break # Found it - # Get the first unknown token of the next group - token_begin = self.find_next(token_begin, TokenFlags.BRACKET) - skipped_previous_group = True - - if token_begin is None: - return - - # if we see season prefix, number, or episode prefix, number, before token begin, - # the stop searching for anime title - for token in self.get_list(end=token_begin): - if (self.options['title_before_episode'] - and (token.e_category == ElementCategory.ANIME_SEASON_PREFIX - or token.e_category == ElementCategory.ANIME_SEASON - or token.e_category == ElementCategory.EPISODE_PREFIX - or token.e_category == ElementCategory.EPISODE_NUMBER - or token.e_category == ElementCategory.ANIME_TYPE)): - return - - token_end = self.find_next(token_begin, TokenFlags.IDENTIFIER | ( - TokenFlags.BRACKET if enclosed_title else TokenFlags.NONE - )) - - # If within the interval there's an open bracket without its matching - # pair, move the upper endpoint back to the bracket - if not enclosed_title: - last_bracket = token_end - bracket_open = False - for token in self.get_list(TokenFlags.BRACKET, begin=token_begin, end=token_end): - last_bracket = token - bracket_open = not bracket_open - if bracket_open: - token_end = last_bracket - - # If the interval ends with an enclosed group (e.g. "Anime Title - # [Fansub]"), move the upper endpoint back to the beginning of the - # group. We ignore parentheses in order to keep certain groups (e.g. - # "(TV)") intact. - if not enclosed_title: - token = self.find_prev(token_end, TokenFlags.NOT_DELIMITER) - while token and token.t_category == TokenCategory.BRACKET and token.content != ")": - token = self.find_prev(token, TokenFlags.BRACKET) - if token: - token_end = token - token = self.find_prev(token_end, TokenFlags.NOT_DELIMITER) - # Not sure why this is here, but it breaks the parser if not here - token_end = self.find_prev(token_end, TokenFlags.VALID) - potential_type = None - anime_types = [] - for token in self.get_list(begin=token_begin, end=token_end): - if (parser_helper.is_dash_character(token.content) - or token.t_category == TokenCategory.DELIMITER - or token.content.upper() == "CLEAN"): - token_end = token - continue - keyword = self.keyword_manager.find(self.keyword_manager.normalize(token.content)) - if keyword: - element = keyword.e_category - if element == ElementCategory.ANIME_TYPE: - anime_types.append(token) - if token.content.upper() in ["ED", "NCED", "ENDING", - "OP", "NCOP", "OPENING", - "PV", "PREVIEW"]: - clean_token = self.find_prev(token, TokenFlags.UNKNOWN) - if clean_token and clean_token.content.upper() == "CLEAN": - token_end = self.find_prev(clean_token) - token.content = f"{clean_token.content} {token.content}" - self.remove_token(clean_token) - self.set_token_element(clean_token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_TYPE) - for anime_type in anime_types: - self.set_token_element(anime_type, TokenCategory.IDENTIFIER, ElementCategory.ANIME_TYPE) - if potential_type: - token_end = potential_type - break - previous_token = self.find_prev(token, TokenFlags.NOT_DELIMITER) - if previous_token and parser_helper.is_dash_character(previous_token.content): - next_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - if next_token: - if next_token.t_category != TokenCategory.UNKNOWN: - self.set_token_element(token, TokenCategory.IDENTIFIER, - ElementCategory.ANIME_TYPE) - break - if not potential_type: - potential_type = token_end - else: - break - if potential_type: - token_end = potential_type - if potential_type != token_end: - potential_type = None - anime_types = [] - token_end = token - if enclosed_title: - token_end = self.find_prev(token_end, TokenFlags.VALID) - self.build_element(ElementCategory.ANIME_TITLE, token_begin, - token_end) - - def search_for_episode_number(self) -> None: - """ - Search for the episode number. - First, we get all tokens that have number in it, Then we check for number pattern, buggy dash, equivalent number, - seperated number, isolated number, and finally, last number as the last resort. - :return: - """ - tokens = [] - for token in self.get_list(TokenFlags.UNKNOWN): - if parser_helper.find_number_in_string(token.content) is None: - continue - tokens.append(token) - - if not tokens: - return - - if self.search_for_number_patterns(tokens): - return - - tokens_original = tokens.copy() - tokens = [token for token in tokens if token.content.isdigit()] - - if not tokens: - # buggy dash, e.g. "Gunbuster-ep1" - self.search_for_buggy_dash(tokens_original) - return - - # e.g. "01 (176)", "29 (04)" - if self.search_for_equivalent_numbers(tokens): - return - - # e.g. " - 08" - if self.search_for_seperated_numbers(tokens): - return - - # e.g. "[12]", "(2006)" - if self.search_for_isolated_numbers(tokens): - return - - # consider using the last number as a last resort - if self.search_for_last_number(tokens): - return - - self.search_for_buggy_dash(tokens_original) - - def search_for_episode_title(self) -> None: - """ - Search for the episode title. - First, We find the UNKNOWN or NOT_ENCLOSED token. Then, we makesure there's no Audio/Video/Resolution/Source - before the token begin. After that, we find the IDENTIFIER or ENCLOSED token for token end. - :return: - """ - token_end = None - for _ in range(len(self.tokens)): - - # Find the first non-enclosed unknown token - if token_end: - token_begin = self.find_next( - token_end, TokenFlags.NOT_ENCLOSED | TokenFlags.UNKNOWN) - else: - token_begin = self.find( - TokenFlags.NOT_ENCLOSED | TokenFlags.UNKNOWN) - - if token_begin is None: - return - if token_begin.t_category == TokenCategory.IDENTIFIER: - continue - breaking = False - for token in self.get_list(end=token_begin): - # return if token before token_begin is Video Resolution, Audio Term, Video Term, etc - if token.e_category in [ - ElementCategory.AUDIO_TERM, - ElementCategory.DEVICE_COMPATIBILITY, - ElementCategory.FILE_CHECKSUM, - ElementCategory.FILE_EXTENSION, - ElementCategory.FILE_NAME, - ElementCategory.LANGUAGE, - ElementCategory.OTHER, - ElementCategory.RELEASE_INFORMATION, - ElementCategory.SOURCE, - ElementCategory.SUBTITLES, - ElementCategory.VIDEO_RESOLUTION, - ElementCategory.VIDEO_TERM - ]: - breaking = True - break - if breaking: - break - - if self.counter.get(ElementCategory.EPISODE_NUMBER, 0) == 1: - for token in self.get_list(begin=token_begin): - if token.e_category in [ - ElementCategory.ANIME_SEASON, - ElementCategory.ANIME_SEASON_PREFIX, - ElementCategory.ANIME_TITLE, - ElementCategory.ANIME_TITLE_ALT, - ElementCategory.EPISODE_NUMBER, - ElementCategory.EPISODE_PREFIX, - ElementCategory.EPISODE_PART, - ElementCategory.EPISODE_TOTAL, - ]: - breaking = True - break - - # Continue until a bracket or identifier is found - token_end = self.find_next( - token_begin, TokenFlags.BRACKET | TokenFlags.IDENTIFIER) - if breaking: - continue - - # Ignore if it's only a dash - if self.distance(token_begin, token_end) <= 2 and \ - parser_helper.is_dash_character(token_begin.content): - continue - - # If token end is a bracket, then we get the previous token to be - # included in the element - if token_end and token_end.t_category == TokenCategory.BRACKET: - token_end = self.find_prev(token_end, TokenFlags.VALID) - # Build episode title - self.build_element( - ElementCategory.EPISODE_TITLE, token_begin, token_end) - return - - def search_for_isolated_number(self) -> None: - """ - This function will search for isolated number for the year and video resolution. - :return: - """ - for token in self.get_list(TokenFlags.UNKNOWN): - if (not token.content.isdigit() - or not self.is_token_isolated(token)): - continue - - number = int(token.content) - if parser_number.ANIME_YEAR_MIN <= number <= parser_number.ANIME_YEAR_MAX: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_YEAR) - elif number == 480 or number == 720 or number == 1080 or number == 2160: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.VIDEO_RESOLUTION) - - def search_for_keywords(self) -> None: - """ - This function will search for keywords about: - - Anime season prefix - - Episode number prefix - - Volume number prefix - - Release Version - - File CRC32 - - Video resolution - - Any identifiable keywords, keywords.options["default"] - """ - prev = None - for token in self.get_list(TokenFlags.UNKNOWN): - word = token.content - word = word.strip(' -') - - if not word: - continue - - # skip if it is a number and not a CRC number - if word.isdigit() and len(word) != 8: - continue - - element = ElementCategory.UNKNOWN - keyword = self.keyword_manager.find(self.keyword_manager.normalize(word)) - if keyword: - element = keyword.e_category - - if not ElementCategory.is_searchable(element): - continue - if not keyword.options.searchable: - if element == ElementCategory.ANIME_TYPE: - previous_token = self.find_prev(token, TokenFlags.NOT_DELIMITER) - next_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - if not next_token: - self.set_token_element(token, TokenCategory.IDENTIFIER, element) - elif parser_helper.is_dash_character(next_token.content): - self.set_token_element(token, TokenCategory.IDENTIFIER, element) - elif next_token and previous_token and previous_token.content == next_token.content: - self.set_token_element(token, TokenCategory.IDENTIFIER, element) - continue - if not keyword.options.identifiable and not token.enclosed: - continue - if not keyword.options.valid: - if not prev or not parser_helper.is_dash_character(prev.content): - continue - - if element == ElementCategory.ANIME_SEASON_PREFIX: - if self.is_anime_season_keyword(token): - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_SEASON_PREFIX) - continue - elif (element == ElementCategory.EPISODE_PREFIX - and keyword.options.valid): - if self.is_extent_keyword(token, ElementCategory.EPISODE_NUMBER, prefix=True): - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_PREFIX) - continue - elif element == ElementCategory.VOLUME_PREFIX: - if self.is_extent_keyword(token, ElementCategory.VOLUME_NUMBER, prefix=True): - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.VOLUME_PREFIX) - continue - elif element == ElementCategory.RELEASE_VERSION: - # this is probably a version number - token.content = word[0] - self.set_token_element(token, TokenCategory.INVALID, ElementCategory.UNKNOWN) - new_token = Token(word[1:], TokenCategory.IDENTIFIER, ElementCategory.RELEASE_VERSION, - token.enclosed) - self.insert_after(token, new_token) - continue - elif element == ElementCategory.AUDIO_TERM: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.AUDIO_TERM) - continue - elif element == ElementCategory.DEVICE_COMPATIBILITY: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.DEVICE_COMPATIBILITY) - continue - elif element == ElementCategory.LANGUAGE: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.LANGUAGE) - continue - elif element == ElementCategory.OTHER: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.OTHER) - continue - elif element == ElementCategory.RELEASE_INFORMATION: - if prev and prev.content.lower() in ["the"]: - continue - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.RELEASE_INFORMATION) - continue - elif element == ElementCategory.SOURCE: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.SOURCE) - continue - elif element == ElementCategory.SUBTITLES: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.SUBTITLES) - continue - elif element == ElementCategory.VIDEO_TERM: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.VIDEO_TERM) - continue - # last resort to avoid false positives - if token.enclosed: - self.set_token_element(token, TokenCategory.IDENTIFIER, element) - else: - # we could have multiple valid crc and resolution keywords. - if parser_helper.is_crc32(word): - element = ElementCategory.FILE_CHECKSUM - if parser_helper.is_resolution(word): - element = ElementCategory.VIDEO_RESOLUTION - - if element != ElementCategory.UNKNOWN: - if keyword is None or keyword.options.identifiable: - self.set_token_element(token, TokenCategory.IDENTIFIER, element) - prev = token - - def search_for_release_group(self) -> None: - """ - This function will search for the release group. - It just look for the first UNKNOWN token that is enclosed. - :return: - """ - token_end = None - for _ in range(len(self.tokens)): - if token_end: - token_begin = self.find_next(token_end, TokenFlags.UNKNOWN) - else: - token_begin = self.find(TokenFlags.UNKNOWN) - if token_begin is None: - return - - # Continue until a bracket or identifier is found - token_end = self.find_next(token_begin, TokenFlags.BRACKET | TokenFlags.IDENTIFIER) - if token_end is None: - return - - if token_end.t_category != TokenCategory.BRACKET: - continue - - # Ignore if it's not the first non-delimiter token in group - previous_token = self.find_prev(token_begin, TokenFlags.NOT_DELIMITER) - if previous_token and previous_token.t_category != TokenCategory.BRACKET: - continue - - # Build release group, token end is a bracket, so we get the - # previous token to be included in the element - token_end = self.find_prev(token_end, TokenFlags.VALID) - # get all tokens between token_begin and token_end - tokens = self.get_list(begin=token_begin, end=token_end) - content = "".join(token.content for token in tokens) - replaced_token = Token(content, TokenCategory.IDENTIFIER, ElementCategory.RELEASE_GROUP, TokenFlags.NONE) - index = self.get_index(token_begin) - for token in tokens: - self.remove_token(token) - self.insert(index, replaced_token) - return - - def validate_elements(self) -> None: - """ - This function will validate the elements. - It will check the episode number for the episode number alternatives. - Then it will check for the episode title, to avoid anime type end up as episode title and some identifier token. - And finally it check for anime title in case it ends with `the` and have anime type. (e.g. `the movie`) - :return: - """ - if self.counter.get(ElementCategory.EPISODE_NUMBER, 0) > 1: - # get last episode number token - token_episode = self.find_prev(None, flags=TokenFlags.IDENTIFIER, element=ElementCategory.EPISODE_NUMBER) - token_start_episode = self.find_next(None, flags=TokenFlags.IDENTIFIER, - element=ElementCategory.EPISODE_NUMBER) - token_start = self.find_next(token_start_episode, flags=TokenFlags.MASK_CATEGORIES) - - tokens = self.get_list(begin=token_start, end=token_episode) - - multi = False - for token in tokens: - if token.e_category == ElementCategory.RANGE_SEPARATOR: - multi = True - break - if token.e_category == ElementCategory.EPISODE_NUMBER: - if float(token_start_episode.content) == float(token.content): - self.remove_token(token) - break - - if self.options['eps_lower_than_alt']: - if float(token_start_episode.content) > float(token.content): - token = token_start_episode - - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER_ALT) - break - - not_enclosed_tokens = [token_start_episode] - enclosed_tokens = [] - for token in tokens: - if token.e_category in [ElementCategory.EPISODE_NUMBER, ElementCategory.EPISODE_NUMBER_ALT]: - if token.enclosed: - enclosed_tokens.append(token) - else: - not_enclosed_tokens.append(token) - - if multi: - if len(enclosed_tokens) == len(not_enclosed_tokens) == 2: - - if self.options['eps_lower_than_alt']: - for idx, token in enumerate(enclosed_tokens): - if float(enclosed_tokens[idx].content) > float(not_enclosed_tokens[idx].content): - self.set_token_element(token, TokenCategory.IDENTIFIER, - ElementCategory.EPISODE_NUMBER_ALT) - else: - self.set_token_element(not_enclosed_tokens[idx], TokenCategory.IDENTIFIER, - ElementCategory.EPISODE_NUMBER_ALT) - else: - for token in enclosed_tokens: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER_ALT) - - # Fill the unknown tokens with the keywords - double_check = [] - for token in self.get_list(TokenFlags.UNKNOWN): - keyword = self.keyword_manager.find(self.keyword_manager.normalize(token.content)) - if keyword: - self.set_token_element(token, TokenCategory.IDENTIFIER, keyword.e_category) - - if token.content[0] in tokenizer.open_brackets: - double_check.append(token) - else: - for char in tokenizer.open_brackets: - if char in token.content: - index = token.content.index(char) - keyword = self.keyword_manager.find(self.keyword_manager.normalize(token.content[:index])) - if keyword: - new_token = Token(token.content[:index], TokenCategory.IDENTIFIER, keyword.e_category, - enclosed=token.enclosed) - self.insert(self.get_index(token), new_token) - token.content = token.content[index + 1:] - - double_check.append(token) - - if double_check: - if token not in double_check: - double_check.append(token) - if token.content[-1] in tokenizer.close_brackets: - start = double_check[0] - previous_token = self.find_prev(start, TokenFlags.NOT_DELIMITER) - - for tok in double_check: - if previous_token.t_category != TokenCategory.UNKNOWN: - if tok.content[0] in tokenizer.open_brackets: - tok.content = tok.content[1:] - if tok.content[-1] in tokenizer.close_brackets: - tok.content = tok.content[:-1] - tok.e_category = previous_token.e_category - tok.t_category = previous_token.t_category - double_check = [] - - tokens = self.get_list(elements=ElementCategory.EPISODE_TITLE) - real_token = [] - for token in tokens: - if not token.content.strip(" " + parser_helper.DASHES): - continue - if token.t_category in [TokenCategory.DELIMITER, TokenCategory.BRACKET]: - continue - real_token.append(token) - - if len(real_token) == 1: - keyword = self.keyword_manager.find(self.keyword_manager.normalize(real_token[0].content)) - if keyword: - self.set_token_element(real_token[0], TokenCategory.IDENTIFIER, keyword.e_category) - elif len(real_token) == 2: - keyword = self.keyword_manager.find(self.keyword_manager.normalize(real_token[1].content)) - if keyword and keyword.e_category == ElementCategory.ANIME_TYPE: - if (real_token[0].content.upper() == "CLEAN" - and real_token[1].content.upper() in ["ED", "NCED", "ENDING", - "OP", "NCOP", "OPENING"]): - self.set_token_element(real_token[0], TokenCategory.IDENTIFIER, keyword.e_category) - real_token[0].content += " " + real_token[1].content - self.remove_token(real_token[1]) - elif real_token: - keyword = self.keyword_manager.find(self.keyword_manager.normalize(real_token[-1].content)) - if keyword and keyword.e_category == ElementCategory.ANIME_TYPE: - self.set_token_element(real_token[-1], TokenCategory.IDENTIFIER, keyword.e_category) - - # Check for unknown tokens between Season and Episode - token_season = self.find_next(None, flags=TokenFlags.IDENTIFIER, - element=[ - ElementCategory.ANIME_SEASON_PREFIX, - ElementCategory.ANIME_SEASON, - ]) - token_type = self.find_next(None, flags=TokenFlags.IDENTIFIER, - element=[ - ElementCategory.ANIME_TYPE, - ]) - token_number = self.find_next(None, flags=TokenFlags.IDENTIFIER, - element=[ - ElementCategory.EPISODE_NUMBER, - ]) - if token_season and not token_season.enclosed: - token_end = token_type or token_number - if token_end: - actually_a_title = False - for token in self.get_list(TokenFlags.UNKNOWN, token_season, token_end): - if parser_helper.is_dash_character(token.content) or not token.content: - continue - actually_a_title = True - if actually_a_title: - # set token from end of anime title until season token to be anime title - anime_title = self.find_prev(token_season, flags=TokenFlags.IDENTIFIER) - for token in self.get_list(None, anime_title, token_end): - if token.e_category in [token_end.e_category]: - break - if token.e_category == ElementCategory.ANIME_SEASON: - token.content = str(int(token.content)) - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_TITLE) - - if token_type and not token_type.enclosed and token_number: - actually_a_title = False - for token in self.get_list(TokenFlags.UNKNOWN, token_type, token_number): - if parser_helper.is_dash_character(token.content) or not token.content: - continue - actually_a_title = True - if actually_a_title: - # set token from end of anime title until season token to be anime title - anime_title = self.find_prev(token_type, flags=TokenFlags.IDENTIFIER) - for token in self.get_list(None, anime_title, token_number): - if (token.e_category in [token_number.e_category, ElementCategory.ANIME_TYPE] - and token != token_type): - break - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_TITLE) - - # make sure there's no anime type in the episode title - tokens = self.get_list(elements=ElementCategory.EPISODE_TITLE) - is_other = False - changed = [] - for token in tokens: - keyword = self.keyword_manager.find(self.keyword_manager.normalize(token.content)) - if keyword: - if keyword.options.identifiable: - changed.append([token, keyword]) - is_other = True - elif is_other: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.OTHER) - else: - changed = [] - - for token, keyword in changed: - self.set_token_element(token, TokenCategory.IDENTIFIER, keyword.e_category) - - # Anime type `Movie` are invalid if the last token is `the` - token = self.find_prev(None, flags=TokenFlags.IDENTIFIER, element=ElementCategory.ANIME_TITLE) - if token: - if token.content.lower() == "the": - anime_type = self.find_next(token, flags=TokenFlags.IDENTIFIER, element=ElementCategory.ANIME_TYPE) - if anime_type: - tokens = self.get_list(begin=token, end=anime_type) - for token in tokens: - if token.t_category == TokenCategory.DELIMITER: - token.content = " " - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_TITLE) - - # Check for anime type batch. - anime_title_token = self.find_prev(None, flags=TokenFlags.IDENTIFIER, element=ElementCategory.ANIME_TITLE) - next_token = self.find_next(anime_title_token, flags=TokenFlags.IDENTIFIER | TokenFlags.BRACKET) - - if next_token and next_token.t_category == TokenCategory.BRACKET: - close_bracket = self.find_next(next_token, flags=TokenFlags.BRACKET) - tokens = {} - for token in self.get_list(None, next_token, close_bracket): - tokens.setdefault(token.e_category, []).append(token) - all_is_plus = [] - for token in tokens.get(ElementCategory.DELIMITER, []): - all_is_plus.append(token.content == "+") - all_is_plus = all(all_is_plus) and len(all_is_plus) > 0 - if all_is_plus: - buffer = [] - remove_later = [] - for token in self.get_list(None, next_token, close_bracket): - if token.t_category == TokenCategory.BRACKET: - continue - if token.e_category in ElementCategory.ANIME_SEASON: - self.batch.append(token) - continue - if token.e_category in ElementCategory.ANIME_SEASON_PREFIX: - continue - - if token.e_category == ElementCategory.DELIMITER: - if buffer: - content = " ".join([tok.content for tok in buffer]) - buffer[0].content = content - buffer[0].e_category = ElementCategory.OTHER - self.batch.append(buffer[0]) - remove_later.extend(buffer[1:]) - buffer = [] - else: - buffer.append(token) - if buffer: - content = " ".join([tok.content for tok in buffer]) - buffer[0].content = content - buffer[0].e_category = ElementCategory.OTHER - self.batch.append(buffer[0]) - remove_later.extend(buffer[1:]) - buffer = [] - for token in remove_later: - self.remove_token(token) diff --git a/aniparse/parser_helper.py b/aniparse/parser_helper.py deleted file mode 100644 index e5c7f09..0000000 --- a/aniparse/parser_helper.py +++ /dev/null @@ -1,90 +0,0 @@ -import re -from typing import Union - -import unicodedata - -DASHES = "-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D" -ESCAPED_DASHES = re.escape(DASHES) - - -def find_non_number_in_string(string) -> Union[int, None]: - any_number = [char.isdigit() for char in string] - if not all(any_number): - return any_number.index(False) - return None - - -def find_number_in_string(string) -> Union[int, None]: - any_number = [char.isdigit() for char in string] - if any(any_number): - return any_number.index(True) - return None - - -def get_number_from_ordinal(content) -> Union[str, None]: - ordinals = { - '1st': '1', 'First': '1', - '2nd': '2', 'Second': '2', - '3rd': '3', 'Third': '3', - '4th': '4', 'Fourth': '4', - '5th': '5', 'Fifth': '5', - '6th': '6', 'Sixth': '6', - '7th': '7', 'Seventh': '7', - '8th': '8', 'Eighth': '8', - '9th': '9', 'Ninth': '9' - } - return ordinals.get(str(content).lower(), None) - - -def is_crc32(string) -> bool: - if len(string) != 8: - return False - try: - int(string, 16) - except ValueError: - return False - return True - - -def is_dash_character(string) -> bool: - return string in DASHES - - -def is_latin_char(char): - return is_latin_char.cache.setdefault(char, 'LATIN' in unicodedata.name(char)) - - -is_latin_char.cache = {} - - -def is_mostly_latin_string(string: str): - if len(string) == 0: - return False - latin_length = len([char for char in string if is_latin_char(char)]) - return latin_length / len(string) >= 0.5 - - -def is_potential_number(text) -> bool: - """ - 1.0 -> False - 1 -> True - 1.1 -> True - 1.1.1 -> False - 01 -> True - 01.1 -> False - """ - try: - f_text = float(text) - # Evangelion 1.0: You Are (Not) Alone - if f_text.is_integer(): - return True - if str(f_text) != text: - return False - return True - except ValueError: - return False - - -def is_resolution(string) -> bool: - pattern = '\\d{3,4}([pP]|([xX\u00D7]\\d{3,4}))$' - return bool(re.match(pattern, string)) diff --git a/aniparse/parser_number.py b/aniparse/parser_number.py deleted file mode 100644 index e0b1e94..0000000 --- a/aniparse/parser_number.py +++ /dev/null @@ -1,702 +0,0 @@ -import re -from typing import List - -from aniparse import parser_helper -from aniparse.element import ElementCategory -from aniparse.keyword import Keyword -from aniparse.token import Token, TokenCategory, TokenFlags, Tokens - -ANIME_YEAR_MIN = 1900 -ANIME_YEAR_MAX = 2100 - - -# noinspection DuplicatedCode -class EpisodePattern(Tokens): - _re_japanese_counter = re.compile(r"^(第)?(\d+)" # prefix - r"(?:([" + parser_helper.ESCAPED_DASHES + - r" ~&+]?)" # prefix separator - r"(\d+))?" # number - r"([話弾])$", # prefix - flags=re.IGNORECASE) - _re_number_sign = re.compile(r"^(#)(\d+(?:\.\d+)?)" # sign and number - r"(?:([" + parser_helper.ESCAPED_DASHES + - r" ~&+]?)" # separator - r"(\d+(?:\.\d+)?))?" # 2nd number - r"(?:(v)(\d+))?$", # version - flags=re.IGNORECASE) - _re_season_episode = re.compile(r"^(?:(\d+)|(s)(\d+)" # Season prefix or number - r"(?:([" + parser_helper.ESCAPED_DASHES + - r" ~&+])" # separator - r"(s)?(\d+))?)" # 2nd season prefix or number - # Season "1", "s01", "s01-02", "s01~02" - r"(?:(x)|([" + parser_helper.ESCAPED_DASHES + - r" ._\-x:])?(e))" # Season-Episode separator - r"(\d+(?:\.\d+)?)(?:(v)(\d+))?" # Episode "01", "01v2" - r"(?:([" + parser_helper.ESCAPED_DASHES + - r"~&+])(e)?" # Episode range separator - r"(\d+(?:\.\d+)?)(?:(v)(\d+))?)?$", # Episode "01", "01v2" - flags=re.IGNORECASE) - - def is_match_japanese_counter_pattern(self, token: Token, word: str) -> bool: - """ - Checking for japanese style counter pattern - - e.g. "01話", "第01話", "第02-03話" - pattern - [] - """ - match = self._re_japanese_counter.match(word) - if match: - if match.group(5): - self.insert_tokens(token, match.group(5), TokenCategory.IDENTIFIER, ElementCategory.UNKNOWN) - if match.group(4): - self.insert_tokens(token, match.group(4), TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - if match.group(3): - self.insert_tokens(token, match.group(3), TokenCategory.INVALID, ElementCategory.RANGE_SEPARATOR) - if match.group(2): - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - token.content = match.group(2) - if match.group(1): - new_token = Token(match.group(1), TokenCategory.IDENTIFIER, ElementCategory.EPISODE_PREFIX, - token.enclosed) - self.insert_before(token, new_token) - return True - return False - - def is_match_number_sign_pattern(self, token: Token, word: str) -> bool: - """ - Checking for number sign pattern - e.g. "#01", "#02-03v2" - pattern - [][] - """ - match = self._re_number_sign.match(word) - if match: - if match.group(6): - self.insert_tokens(token, match.group(6), TokenCategory.IDENTIFIER, ElementCategory.RELEASE_VERSION) - if match.group(5): - self.insert_tokens(token, match.group(5), TokenCategory.INVALID, ElementCategory.UNKNOWN) - if match.group(4): - self.insert_tokens(token, match.group(4), TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - if match.group(3): - self.insert_tokens(token, match.group(3), TokenCategory.INVALID, ElementCategory.RANGE_SEPARATOR) - if match.group(2): - self.insert_tokens(token, match.group(2), TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_PREFIX) - - token.content = match.group(1) - return True - return False - - def is_match_season_and_episode_pattern(self, token: Token, word: str) -> bool: - """ - Checking for season and episode pattern - e.g "2x01", "2x01v2" - e.g "s01e03", "s01e03v2" - e.g "s01-02xe001-150", "s01-02xe001-150v2", "s01-02xe001v3-150v2" - e.g "s01-s02xe001-150", "s01-s02xe001-150v2", "s01-s02xe001v3-150v2" - e.g "s01e03-s02e04", "s01e03-s02e04v2", "s01e03v4-s02e04v2" - e.g "s01e03-04", "s01e03-04v2", "s01e03v4-04v2" - assuming, previous was season: "01-02e03", "01-02e03v2", "01v3-s02e03v2" - pattern: - (|[[season_prefix]]) - (|[range_separator]) - [v][[episode_prefix][v]] - """ - - match = self._re_season_episode.match(word) - - if match: - # check for lower bound < upper bound - season_lower_bound = match.group(1) or match.group(3) - season_upper_bound = match.group(6) - episode_lower_bound = match.group(10) - episode_upper_bound = match.group(15) - - if season_upper_bound and int(season_lower_bound) > int(season_upper_bound): - return False - if episode_upper_bound and int(episode_lower_bound) > int(episode_upper_bound): - return False - try: - if int(season_lower_bound) == 0 and token.enclosed: - # Avoid fansubbers [0x539] as season pattern - return False - except ValueError: - pass - # in reverse order to keep the order of tokens - if match.group(17): - self.insert_tokens(token, match.group(17), TokenCategory.IDENTIFIER, ElementCategory.RELEASE_VERSION) - if match.group(16): - self.insert_tokens(token, match.group(16), TokenCategory.INVALID, ElementCategory.UNKNOWN) - if match.group(15): - self.insert_tokens(token, episode_upper_bound, TokenCategory.IDENTIFIER, - ElementCategory.EPISODE_NUMBER) - if match.group(14): - self.insert_tokens(token, match.group(14), TokenCategory.IDENTIFIER, ElementCategory.EPISODE_PREFIX) - if match.group(13): - self.insert_tokens(token, match.group(13), TokenCategory.INVALID, ElementCategory.RANGE_SEPARATOR) - if match.group(12): - self.insert_tokens(token, match.group(12), TokenCategory.IDENTIFIER, ElementCategory.RELEASE_VERSION) - if match.group(11): - self.insert_tokens(token, match.group(11), TokenCategory.INVALID, ElementCategory.UNKNOWN) - if match.group(10): - self.insert_tokens(token, match.group(10), TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - if match.group(9): - self.insert_tokens(token, match.group(9), TokenCategory.IDENTIFIER, ElementCategory.EPISODE_PREFIX) - if match.group(8): - self.insert_tokens(token, match.group(8), TokenCategory.INVALID, ElementCategory.RANGE_SEPARATOR) - if match.group(7): - self.insert_tokens(token, match.group(7), TokenCategory.INVALID, ElementCategory.RANGE_SEPARATOR) - if match.group(6): - self.insert_tokens(token, match.group(6), TokenCategory.IDENTIFIER, ElementCategory.ANIME_SEASON) - if match.group(5): - self.insert_tokens(token, match.group(5), TokenCategory.IDENTIFIER, - ElementCategory.ANIME_SEASON_PREFIX) - if match.group(4): - self.insert_tokens(token, match.group(4), TokenCategory.INVALID, ElementCategory.RANGE_SEPARATOR) - if match.group(2): - if match.group(3): - self.insert_tokens(token, match.group(3), TokenCategory.IDENTIFIER, ElementCategory.ANIME_SEASON) - token.content = match.group(2) - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_SEASON_PREFIX) - elif match.group(1): - token.content = match.group(1) - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_SEASON) - return True - return False - - -class VolumePattern(Tokens): - pass - - -class Pattern(EpisodePattern, VolumePattern): - """ - Main pattern that apply for Episode, Volume, and Season. - """ - _re_match_multi_number = re.compile(r"(\d+(?:\.\d+)?)(?:(v)(\d+))?" # Episode number, release version - r"([" + parser_helper.ESCAPED_DASHES + - r" ~&+])" # Range separator - r"(\d+(?:\.\d+)?)(?:(v)(\d+))?$", # Episode number, release version - flags=re.IGNORECASE) - _re_match_single_number = re.compile((r'^(\d+(?:\.\d+)?)' # Episode number - r'(?:(v)(\d+))?$'), # Release version - flags=re.IGNORECASE) - - def is_match_multi_numbers_pattern(self, token: Token, word: str, category: ElementCategory) -> bool: - """ - Check if the word is a multi numbers pattern. - e.g. "01-02", "01-02v2", "01v2-02v2", "01v2-02", "04.5-05.5", "04.5-05.5v2", "04.5v2-05.5v2", "04.5v2-05.5" - pattern = [v][v] - """ - match = self._re_match_multi_number.match(word) - if match: - lower_bound = match.group(1) - upper_bound = match.group(5) - # Avoid matching expressions such as "009-1" or "5-2" - if int(lower_bound) < int(upper_bound): - - # In reverse order to keep the order of tokens - if match.group(7): - self.insert_tokens(token, match.group(7), TokenCategory.IDENTIFIER, - ElementCategory.RELEASE_VERSION) - if match.group(6): - self.insert_tokens(token, match.group(6), TokenCategory.INVALID, ElementCategory.UNKNOWN) - # match group 5 is the upper bound - self.insert_tokens(token, upper_bound, TokenCategory.IDENTIFIER, category) - # match group 4 is the range separator - self.insert_tokens(token, match.group(4), TokenCategory.INVALID, ElementCategory.RANGE_SEPARATOR) - if match.group(3): - self.insert_tokens(token, match.group(3), TokenCategory.IDENTIFIER, - ElementCategory.RELEASE_VERSION) - if match.group(2): - self.insert_tokens(token, match.group(2), TokenCategory.INVALID, ElementCategory.UNKNOWN) - - # match.group(1) is the lower bound - self.set_token_element(token, TokenCategory.IDENTIFIER, category) - token.content = lower_bound - return True - return False - - def is_match_number_patterns(self, token: Token, word: str, category: ElementCategory, - prefix: bool = False) -> bool: - """ - Check if the word have a valid numbered pattern. - """ - if parser_helper.is_potential_number(word) and not prefix: - # If the word only a number and the prefix is not set, then try to check the previous token - previous_token = self.find_prev(token, TokenFlags.IDENTIFIER | TokenFlags.UNKNOWN) - if not previous_token: - return True - if previous_token.content.lower() in ["part"]: - return False - if previous_token.t_category in [TokenCategory.DELIMITER, TokenCategory.BRACKET]: - self.set_token_element(previous_token, TokenCategory.INVALID, ElementCategory.UNKNOWN) - self.set_token_element(token, TokenCategory.IDENTIFIER, category) - return True - if parser_helper.is_dash_character(previous_token.content): - self.set_token_element(token, TokenCategory.IDENTIFIER, category) - return True - keyword = self.keyword_manager.find(self.keyword_manager.normalize(previous_token.content)) - if keyword: - if keyword.e_category in [ElementCategory.ANIME_SEASON, ElementCategory.ANIME_TYPE, - ElementCategory.EPISODE_PREFIX]: - self.set_token_element(previous_token, TokenCategory.IDENTIFIER, keyword.e_category) - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - elif keyword.e_category in [ElementCategory.VOLUME_PREFIX]: - self.set_token_element(previous_token, TokenCategory.IDENTIFIER, keyword.e_category) - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.VOLUME_NUMBER) - elif keyword.e_category in [ElementCategory.ANIME_SEASON_PREFIX]: - if not self.options['season_part_as_unique']: - self.set_token_element(previous_token, TokenCategory.IDENTIFIER, keyword.e_category) - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_SEASON) - # check if the next token is an unknown token and the content is "-" - # if true, then it possibly an episode number - next_token = self.find_next(token, TokenFlags.UNKNOWN | TokenFlags.IDENTIFIER) - if next_token: - if next_token.e_category in [ - ElementCategory.AUDIO_TERM, - ElementCategory.VIDEO_TERM, - ElementCategory.VIDEO_RESOLUTION, - ElementCategory.EPISODE_TITLE, - ElementCategory.RELEASE_INFORMATION, - ElementCategory.RELEASE_VERSION, - ElementCategory.SOURCE, - ElementCategory.SUBTITLES, - ]: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - return True - if previous_token == token and parser_helper.is_dash_character(next_token.content): - # found episode number-title separator - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - return True - return False - - word = word.strip(' ' + parser_helper.DASHES) - numeric_front = word[0].isdigit() - numeric_back = word[-1].isdigit() - - # e.g. "01v2" - if numeric_front and numeric_back: - if self.is_match_single_number_pattern(token, word, category): - return True - if self.is_match_multi_numbers_pattern(token, word, category): - return True - if self.is_match_season_and_episode_pattern(token, word): - return True - # only valid for episode numbers - if category == ElementCategory.EPISODE_NUMBER: - if numeric_back: - if self.is_match_number_sign_pattern(token, word): - return True - if self.is_match_japanese_counter_pattern(token, word): - return True - if prefix: - if self.is_match_partial_number_patterns(token, word): - return True - return False - - def is_match_partial_number_patterns(self, token: Token, word: str) -> bool: - """ - Check if the word have a valid partial numbered pattern. - e.g. "4a", "111C - """ - non_number_begin = parser_helper.find_non_number_in_string(word) - suffix = word[non_number_begin:] - - if len(suffix) == 1 and suffix.isalpha(): - token.content = word[:non_number_begin] - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - - number = word[non_number_begin:] - self.insert_tokens(token, number, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_PART) - return True - return False - - def is_match_single_number_pattern(self, token: Token, word: str, category: ElementCategory) -> bool: - """ - Check if the word have a valid single numbered pattern. - e.g. "01", "01v2", "07.5", "07.5v2" - pattern = [v] - """ - match = self._re_match_single_number.match(word) - if match: - # in reverse order to keep the order of tokens - if match.group(3): - self.insert_tokens(token, match.group(3), TokenCategory.IDENTIFIER, ElementCategory.RELEASE_VERSION) - if match.group(2): - self.insert_tokens(token, match.group(2), TokenCategory.INVALID, ElementCategory.UNKNOWN) - - token.content = match.group(1) - self.set_token_element(token, TokenCategory.IDENTIFIER, category) - return True - return False - - -class ParserNumber(Pattern): - _re_search_buggy_dash = re.compile(r"(" + "|".join(parser_helper.DASHES) + r")", - flags=re.IGNORECASE) - - def is_extent_keyword(self, token: Token, category: ElementCategory, prefix: bool) -> bool: - next_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - if next_token and next_token.t_category == TokenCategory.UNKNOWN: - if parser_helper.find_number_in_string(next_token.content) is not None: - # Check for episode, and volume number - if category in [ElementCategory.EPISODE_NUMBER, ElementCategory.VOLUME_NUMBER]: - if self.is_match_number_patterns(next_token, next_token.content, category, prefix): - return True - return False - - def is_number_comes_after_prefix(self, token: Token) -> bool: - number_begin = parser_helper.find_number_in_string(token.content) - prefix = token.content[:number_begin] - - keyword = self.keyword_manager.find( - self.keyword_manager.normalize(prefix) - ) - if keyword: - if keyword.e_category not in [ElementCategory.EPISODE_PREFIX, ElementCategory.VOLUME_PREFIX, - ElementCategory.ANIME_SEASON_PREFIX, ElementCategory.ANIME_TYPE]: - return False - - number = token.content[number_begin:] - if keyword.e_category == ElementCategory.EPISODE_PREFIX: - category = ElementCategory.EPISODE_NUMBER - elif keyword.e_category == ElementCategory.VOLUME_PREFIX: - category = ElementCategory.VOLUME_NUMBER - elif keyword.e_category == ElementCategory.ANIME_SEASON_PREFIX: - category = ElementCategory.ANIME_SEASON - additional_prefix = parser_helper.find_non_number_in_string(number) - if additional_prefix: - left_over = number[additional_prefix:] - new_number_begin = parser_helper.find_number_in_string(left_over) - key = left_over[:new_number_begin] - lo_keyword = self.keyword_manager.find( - self.keyword_manager.normalize(key) - ) - if (lo_keyword - and lo_keyword.e_category == ElementCategory.ANIME_TYPE): - number = number[:additional_prefix] - else: - if key.upper() == "S": - new_token = Token(prefix, - TokenCategory.IDENTIFIER, - ElementCategory.ANIME_SEASON_PREFIX, - token.enclosed) - self.insert_before(token, new_token) - new_token = Token(number[:additional_prefix], - TokenCategory.IDENTIFIER, - ElementCategory.ANIME_SEASON, - token.enclosed) - self.insert_before(token, new_token) - keyword = Keyword(ElementCategory.ANIME_TYPE, keyword.options) - category = ElementCategory.EPISODE_NUMBER - prefix = key - number = left_over[new_number_begin:] - del left_over - elif keyword.e_category == ElementCategory.ANIME_TYPE: - category = ElementCategory.EPISODE_NUMBER - else: - return False - - if self.is_match_number_patterns(token, number, category, True): - new_token = Token(prefix, TokenCategory.IDENTIFIER, keyword.e_category, token.enclosed) - self.insert_before(token, new_token) - try: - left_token = Token(left_over, TokenCategory.UNKNOWN, ElementCategory.UNKNOWN, token.enclosed) - self.insert_after(token, left_token) - self.is_number_comes_after_prefix(left_token) - except NameError: - pass - return True - else: - number = token.content[number_begin:] - if prefix and prefix[-1].lower() == "v" and number.isdigit(): - keyword = self.keyword_manager.find( - self.keyword_manager.normalize(prefix[:-1]) - ) - if keyword and keyword.e_category in [ElementCategory.EPISODE_PREFIX, ElementCategory.VOLUME_PREFIX, - ElementCategory.ANIME_SEASON_PREFIX, ElementCategory.ANIME_TYPE]: - token.content = prefix[:-1] - self.set_token_element(token, TokenCategory.IDENTIFIER, keyword.e_category) - new_token = Token(prefix[-1], TokenCategory.INVALID, ElementCategory.UNKNOWN, token.enclosed) - self.insert_after(token, new_token) - new_token = Token(number, TokenCategory.IDENTIFIER, ElementCategory.RELEASE_VERSION, token.enclosed) - self.insert_after(token, new_token) - return True - return False - - def is_number_comes_before_another_number(self, token: Token) -> bool: - """ - Check if the number comes before another number. - """ - separator_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - - if separator_token: - separator = separator_token.content - if separator == "&" or str(separator).lower() == "of": - other_token = self.find_next(separator_token, TokenFlags.NOT_DELIMITER) - if other_token and other_token.content.isdigit(): - if separator == "&": - self.set_token_element(other_token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - - elif str(separator).lower() == "of": - self.set_token_element(other_token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_TOTAL) - - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - self.set_token_element(separator_token, TokenCategory.INVALID, ElementCategory.RANGE_SEPARATOR) - return True - return False - - def search_for_buggy_dash(self, tokens: List[Token]) -> None: - """ - This is the last resort to fix the dash problem. - e.g. "Gunbuster-ep1" -> "Gunbuster - ep1" - - :param tokens: - :return: - """ - for token in tokens: - splitted = self._re_search_buggy_dash.split(token.content) - placeholder = [] - new_token = Token("", token.t_category, token.e_category, token.enclosed) - potential_tokens = [] - for text in splitted: - if parser_helper.find_number_in_string(text) is not None: - if new_token.content: - # first and last dashes put on separate token - stripped_text = new_token.content.strip(parser_helper.DASHES) - dashes = new_token.content.split(stripped_text) - if dashes[0]: - placeholder.append(Token(dashes[0], TokenCategory.INVALID, - ElementCategory.UNKNOWN, token.enclosed)) - new_token.content = stripped_text - placeholder.append(new_token) - if dashes[1]: - placeholder.append(Token(dashes[1], TokenCategory.INVALID, - ElementCategory.UNKNOWN, token.enclosed)) - - new_token = Token("", token.t_category, token.e_category, token.enclosed) - number_token = Token(text, token.t_category, token.e_category, token.enclosed) - placeholder.append(number_token) - potential_tokens.append(number_token) - else: - new_token.content += text - - if new_token.content: - stripped_text = new_token.content.strip(parser_helper.DASHES) - dashes = new_token.content.split(stripped_text) - if dashes[0]: - placeholder.append(Token(dashes[0], TokenCategory.INVALID, - ElementCategory.UNKNOWN, token.enclosed)) - new_token.content = stripped_text - placeholder.append(new_token) - if dashes[1]: - placeholder.append(Token(dashes[1], TokenCategory.INVALID, - ElementCategory.UNKNOWN, token.enclosed)) - - if placeholder: - placeholder.reverse() - for i_token in placeholder: - self.insert_after(token, i_token) - self.remove_token(token) - - is_found = False - for potential_token in potential_tokens: - if self.is_number_comes_after_prefix(potential_token): - is_found = True - elif self.search_for_last_number([potential_token]): - is_found = True - - if not is_found: - self.insert_after(placeholder[-1], token) - for i_token in placeholder: - self.remove_token(i_token) - return - - def search_for_equivalent_numbers(self, tokens: List[Token]) -> bool: - """ - Search for equivalent numbers. - """ - is_found = False - for token in tokens: - if self.is_token_isolated(token): - continue - - previous_token = self.find_prev(token, TokenFlags.NOT_DELIMITER) - if previous_token and previous_token.e_category in [ElementCategory.ANIME_SEASON, - ElementCategory.ANIME_SEASON_PREFIX]: - token.t_category = TokenCategory.IDENTIFIER - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - - is_found = True - continue - next_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - if next_token is None or next_token.t_category != TokenCategory.UNKNOWN: - continue - - # Check if it's an isolated number - if not self.is_token_isolated(next_token): - continue - if not next_token.content.isdigit(): - continue - - episode = min(token, next_token, key=lambda t: t.content) - continue_episode = max(token, next_token, key=lambda t: t.content) - - self.set_token_element(episode, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - self.set_token_element(continue_episode, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - is_found = True - return is_found - - def search_for_isolated_numbers(self, tokens: List[Token]) -> bool: - """ - Search for isolated numbers. - """ - is_found = False - for token in tokens: - if not token.enclosed: - continue - if not self.is_token_isolated(token): - continue - if token.content.isdigit(): - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - - is_found = True - return is_found - - def search_for_last_number(self, tokens: List[Token]) -> bool: - """ - Search for the last number. - """ - is_found = False - for token in tokens: - token_index = self.get_index(token) - - # assuming the episode number always come after the title - if token_index == 0: - continue - - if token.enclosed: - continue - - # Ignore if it's the first non-enclosed, non-delimiter token - if all([t.enclosed or t.t_category == TokenCategory.DELIMITER - for t in self.get_list()[:token_index]]): - continue - - # Ignore if the previous token is "Movie" or "Part" - previous_token = self.find_prev(token, TokenFlags.NOT_DELIMITER) - if previous_token and str(previous_token.content).lower() in ["part"]: - continue - previous_token = self.find_prev(token, TokenFlags.UNKNOWN | TokenFlags.BRACKET) - if not previous_token or previous_token == token: # , because previous_token most likely same as token - continue - if not parser_helper.is_dash_character(previous_token.content): - next_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - if next_token and parser_helper.is_dash_character(next_token.content): - try: - f_text = float(token.content) - if f_text.is_integer(): - if str(int(f_text)) != token.content: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - is_found = True - elif str(f_text) != token.content: - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - is_found = True - except ValueError: - pass - - continue - if token.content.isdigit(): - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - is_found = True - return is_found - - def search_for_number_patterns(self, tokens: List[Token]) -> bool: - """ - Search for number patterns. - """ - is_found = False - for token in tokens: - numeric_front = token.content[0].isdigit() - - if not numeric_front: - # e.g. "EP.1", "Vol.1" - if self.is_number_comes_after_prefix(token): - is_found = True - elif self.is_number_comes_before_another_number(token): - is_found = True - - if token.t_category == TokenCategory.UNKNOWN: - # look for other patterns - if is_found and token.content.upper() == "3D": - continue - # if previous token is a dash, it is most likely an episode number - prefix = False - previous_token = self.find_prev(token, TokenFlags.NOT_DELIMITER) - if previous_token: - keyword = self.keyword_manager.find( - self.keyword_manager.normalize(previous_token.content)) - prev_is_dash = parser_helper.is_dash_character(previous_token.content) - else: - prev_is_dash = False - - if prev_is_dash: - prefix = True - else: - # in case the separator not a dash, - # we need to check if the next token are the same with the previous token - next_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - - # the number are at the beginning of the string - if next_token: - next_is_dash = parser_helper.is_dash_character(next_token.content) - if (previous_token.content == next_token.content - or (previous_token == token and next_is_dash) - or (previous_token.t_category == next_token.t_category == TokenCategory.BRACKET - and self.counter.get(ElementCategory.EPISODE_NUMBER))): - prefix = True - else: - # 02 is most likely an episode number - # but 2 is not guaranteed - try: - is_number = float(token.content) - if is_number.is_integer(): - if str(int(is_number)) != token.content: - prefix = True - else: - if str(is_number) != token.content: - prefix = True - except ValueError: - # in case it 04v2, then it most likely an episode number - prefix = True - - if not prefix: - if keyword and keyword.e_category == ElementCategory.ANIME_TYPE: - prefix = True - - if self.is_match_number_patterns(token, token.content, ElementCategory.EPISODE_NUMBER, prefix): - is_found = True - if prefix: - if keyword and keyword.e_category == ElementCategory.ANIME_TYPE: - self.set_token_element(previous_token, TokenCategory.IDENTIFIER, ElementCategory.ANIME_TYPE) - return is_found - - def search_for_seperated_numbers(self, tokens: List[Token]) -> bool: - """ - Search for seperated numbers. - """ - is_found = False - for token in tokens: - previous_token = self.find_prev(token, TokenFlags.NOT_DELIMITER) - - if previous_token is None or previous_token.t_category != TokenCategory.UNKNOWN: - continue - - if parser_helper.is_dash_character(previous_token.content) and token.content.isdigit(): - self.set_token_element(token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_NUMBER) - self.set_token_element(previous_token, TokenCategory.IDENTIFIER, ElementCategory.EPISODE_PREFIX) - - is_found = True - return is_found diff --git a/aniparse/rule/__init__.py b/aniparse/rule/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aniparse/rule/possibilities/__init__.py b/aniparse/rule/possibilities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aniparse/rule/possibilities/bracket_content_rule.py b/aniparse/rule/possibilities/bracket_content_rule.py new file mode 100644 index 0000000..89870d3 --- /dev/null +++ b/aniparse/rule/possibilities/bracket_content_rule.py @@ -0,0 +1,66 @@ +from aniparse.core import constant +from aniparse.abstraction.parser_base import PossibilityRule, AbstractParser +from aniparse.core.token_tags import Tag + + +class BracketContentPossibilityRule(PossibilityRule): + """Scan all bracket groups and tag unknown content as release group candidates.""" + + @classmethod + def apply(cls, parser: AbstractParser): + tokens = parser.tokens + token_list = list(tokens) + + bracket_groups = [] + i = 0 + while i < len(token_list): + token = token_list[i] + if Tag.BRACKET in token.possibilities and token.content in constant.OPEN_BRACKETS: + inside = [] + close_idx = None + expected_close = constant.BRACKET_PAIRS.get(token.content) + for j in range(i + 1, len(token_list)): + t = token_list[j] + if Tag.BRACKET in t.possibilities and t.content == expected_close: + close_idx = j + break + inside.append(t) + if close_idx is not None: + bracket_groups.append((i, inside, close_idx)) + i = close_idx + 1 + continue + i += 1 + + for group_idx, (open_pos, inside_tokens, close_pos) in enumerate(bracket_groups): + if not inside_tokens: + continue + + # Skip first bracket at position 0 — handled by ReleaseGroupRule + if open_pos == 0: + continue + + # Check if all non-delimiter tokens are unknown + has_metadata = False + has_unknown = False + for t in inside_tokens: + non_structural = {k for k in t.possibilities + if k not in (Tag.DELIMITER, Tag.CONTEXT_DELIMITER, Tag.BRACKET)} + if not non_structural and not t.possibilities: + has_unknown = True + elif non_structural: + metadata_labels = Tag.get_optional_info() + if any(k in metadata_labels for k in non_structural): + has_metadata = True + else: + has_unknown = True + + if has_unknown and not has_metadata: + first_at_zero = bracket_groups[0][0] == 0 + is_last = (group_idx == len(bracket_groups) - 1) + # Skip release group tagging if first bracket already serves as release group + if first_at_zero: + continue + score = 1.0 if is_last else 0.5 + for t in inside_tokens: + if not t.possibilities or Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + t.add_possibility(Tag.RELEASE_GROUP, base_score=score) diff --git a/aniparse/rule/possibilities/checksum_placeholder_rule.py b/aniparse/rule/possibilities/checksum_placeholder_rule.py new file mode 100644 index 0000000..54c7304 --- /dev/null +++ b/aniparse/rule/possibilities/checksum_placeholder_rule.py @@ -0,0 +1,15 @@ +import re + +from aniparse.core import constant +from aniparse.abstraction.parser_base import PossibilityRule, AbstractParser + + +class ChecksumPlaceholderPossibilityRule(PossibilityRule): + @classmethod + def apply(cls, parser: AbstractParser): + for entry in parser.word_list_manager.find(constant.CHECKSUM_PLACEHOLDER): + for regex_expression in entry.regex_dict: + for match in re.finditer(regex_expression, parser.filename, re.IGNORECASE): + matched_tokens = parser.get_matched_tokens_range(match.start(), match.end()) + if matched_tokens: + parser.process_entry(match.group(), matched_tokens, entry) diff --git a/aniparse/rule/possibilities/context_delimiter_rule.py b/aniparse/rule/possibilities/context_delimiter_rule.py new file mode 100644 index 0000000..f76c7bd --- /dev/null +++ b/aniparse/rule/possibilities/context_delimiter_rule.py @@ -0,0 +1,32 @@ +from aniparse.abstraction.parser_base import PossibilityRule, AbstractParser +from aniparse.core.token_tags import Tag + + +class ContextDelimiterPossibilityRule(PossibilityRule): + @classmethod + def apply(cls, parser: AbstractParser): + profile = parser.delimiter_profile + + if profile.file_index_end is not None: + first_token = parser.tokens.tokens[0] if parser.tokens.tokens else None + if first_token and first_token.content.isdigit(): + first_token.add_possibility(Tag.FILE_INDEX) + + for token in parser.tokens: + if Tag.CONTEXT_DELIMITER not in token.possibilities and Tag.DELIMITER not in token.possibilities: + continue + + if Tag.DELIMITER in token.possibilities: + # Skip ABA promotion when delimiter is uniform and matches primary + if profile.is_uniform and token.content == profile.primary: + continue + + prev_token = parser.tokens.find_prev(token) + next_token = parser.tokens.find_next(token) + + if prev_token and next_token: + if (Tag.DELIMITER in prev_token.possibilities and + Tag.DELIMITER in next_token.possibilities and + prev_token.content == next_token.content != token.content): + token.remove_possibility(Tag.DELIMITER) + token.add_possibility(Tag.CONTEXT_DELIMITER) diff --git a/aniparse/rule/possibilities/context_dependent_expansion_rule.py b/aniparse/rule/possibilities/context_dependent_expansion_rule.py new file mode 100644 index 0000000..05779c3 --- /dev/null +++ b/aniparse/rule/possibilities/context_dependent_expansion_rule.py @@ -0,0 +1,74 @@ +from typing import Union + +from aniparse.core import constant +from aniparse.abstraction.parser_base import PossibilityRule, AbstractParser +from aniparse.core.token_tags import Tag +from aniparse.core.token import Token + + +class ContextDependentExpansionPossibilityRule(PossibilityRule): + @classmethod + def apply(cls, parser: AbstractParser): + for token in parser.tokens: + if not token or not token.content: + continue + + if Tag.CONTEXT_DEPENDENT not in token.possibilities: + continue + + word = token.content.upper() + + # Range connectors (+ and &) — propagate prev possibilities to next token + if word in parser.config.range_connectors: + cls.handle_plus(parser, token) + continue + + # If token is a delimiter char, convert to DELIMITER + if word in constant.DELIMITERS: + token.remove_possibility(Tag.CONTEXT_DEPENDENT) + token.add_possibility(Tag.DELIMITER) + continue + + # If the keyword entry has regex patterns (designed for glued forms + # like PART3), but appears separated from a digit (e.g. "part 2"), + # handle based on context: in brackets → SEQUENCE_PART, in title → demote number. + entry = token.possibilities[Tag.CONTEXT_DEPENDENT].element + if entry and entry.regex_dict: + next_token = cls.get_next_relevant_token(parser, token) + if next_token and next_token.content.isdigit(): + if token.bracket_group: + token.add_possibility(Tag.SEQUENCE_PART) + else: + next_token.remove_possibility(Tag.SEQUENCE_NUMBER) + + # Generic fallback: remove CONTEXT_DEPENDENT → title candidate. + token.remove_possibility(Tag.CONTEXT_DEPENDENT) + + @classmethod + def handle_plus(cls, parser: AbstractParser, token: Token): + prev_token = cls.get_previous_relevant_token(parser, token) + next_token = cls.get_next_relevant_token(parser, token) + + if prev_token and next_token: + for category, descriptors in prev_token.possibilities.items(): + if not next_token.content.isdigit() and category in [ + Tag.SEQUENCE_NUMBER, + ]: + continue + next_token.add_possibility(descriptors.descriptor) + + @staticmethod + def get_next_relevant_token(parser: AbstractParser, current_token: Token) -> Union[Token, None]: + for next_token in parser.tokens.loop_forward(current_token): + if next_token.content in constant.DELIMITERS or next_token.content in constant.BRACKETS: + continue + return next_token + return None + + @staticmethod + def get_previous_relevant_token(parser, current_token) -> Union[Token, None]: + for prev_token in parser.tokens.loop_backward(current_token): + if prev_token.content in constant.DELIMITERS or prev_token.content in constant.BRACKETS: + continue + return prev_token + return None diff --git a/aniparse/rule/possibilities/number_rule.py b/aniparse/rule/possibilities/number_rule.py new file mode 100644 index 0000000..be40462 --- /dev/null +++ b/aniparse/rule/possibilities/number_rule.py @@ -0,0 +1,146 @@ +from typing import Union + +from aniparse.config import ParserConfig +from aniparse.core import constant +from aniparse.abstraction.parser_base import PossibilityRule, AbstractParser +from aniparse.core.token_tags import Tag +from aniparse.core.token import Token + + +def get_number_from_ordinal(content, config: ParserConfig) -> str: + lower = str(content).lower() + # Word ordinals (first, second, ...) + val = config.ordinals.get(lower) + if val: + return val + # Numeric ordinals (1st, 22nd, ...) — strip suffix + for suffix in config.ordinal_suffixes: + if lower.endswith(suffix): + num_part = lower[:-len(suffix)] + if num_part.isdigit(): + return num_part + return "" + + +class NumberPossibilityRule(PossibilityRule): + @classmethod + def apply(cls, parser: AbstractParser): + config = parser.config + for token in parser.tokens: + if Tag.SEQUENCE_PREFIX in token.possibilities: + # Check for ordinal number like 2nd season + for prev_token in parser.tokens.loop_backward(token): + if prev_token.content in constant.DELIMITERS: + continue + if get_number_from_ordinal(prev_token.content, config).isdigit(): + prev_token.add_possibility(Tag.SEQUENCE_NUMBER) + break + + # SEQUENCE_NUMBER + EPISODE_PREFIX + SEQUENCE_NUMBER → + # the preceding number is a season number + poss = token.possibilities[Tag.SEQUENCE_PREFIX] + if poss.descriptor == Tag.EPISODE: + prev = cls.prev_non_delimiter(parser, token) + nxt = cls.next_non_delimiter(parser, token) + if (prev and Tag.SEQUENCE_NUMBER in prev.possibilities + and nxt and Tag.SEQUENCE_NUMBER in nxt.possibilities): + prev.possibilities[Tag.SEQUENCE_NUMBER].descriptor = Tag.SEASON + + # Reduce checking for number that most likely a file checksum + if token.content.isdigit() and Tag.FILE_CHECKSUM not in token.possibilities: + num_val_year = int(token.content) + if config.year_min <= num_val_year <= config.year_max: + token.add_possibility(Tag.SERIES_YEAR) + + token.add_possibility(Tag.SEQUENCE_NUMBER) + + # Check for episode part suffix (e.g. "111c" → "c" is part) + next_tok = cls.next_non_delimiter(parser, token) + if (next_tok and len(next_tok.content) == 1 + and next_tok.content.isalpha() + and next_tok.index == token.index + len(token.content) + and Tag.SEQUENCE_PREFIX not in next_tok.possibilities): + next_tok.add_possibility(Tag.SEQUENCE_PART, base_score=1.5) + + # Common bare resolution values (e.g. "1080" without "p") + num_val = int(token.content) + if num_val in constant.COMMON_RESOLUTIONS: + token.add_possibility(Tag.VIDEO_RESOLUTION) + + # Remove EPISODE_PREFIX if wrongly added before + if Tag.SEQUENCE_PREFIX in token.possibilities: + token.remove_possibility(Tag.SEQUENCE_PREFIX) + + prev_token = cls.prev_non_delimiter(parser, token) + next_token = cls.next_non_delimiter(parser, token) + + if not prev_token or not next_token: + # File index only can be at the start or the end + token.add_possibility(Tag.FILE_INDEX) + + if prev_token and prev_token.content not in constant.BRACKETS: + if Tag.SEQUENCE_PREFIX in prev_token.possibilities: + token.add_possibility(Tag.SEQUENCE_NUMBER) + + if next_token and next_token.content not in constant.BRACKETS: + for episode_range in parser.tokens.loop_forward(token, next_token): + if not episode_range: + break + + if episode_range.content not in config.range_separator: + continue + if ( + next_token.content.isdigit() or + Tag.SEQUENCE_NUMBER in next_token.possibilities or + Tag.SEQUENCE_PREFIX in next_token.possibilities or + Tag.SEQUENCE_NUMBER in next_token.possibilities + ): + if Tag.SEQUENCE_RANGE not in next_token.possibilities: + episode_range.add_possibility(Tag.SEQUENCE_RANGE) + next_token.add_possibility(Tag.SEQUENCE_NUMBER) + + if get_number_from_ordinal(token.content + next_token.content, config).isdigit(): + token.add_possibility(Tag.SEQUENCE_NUMBER) + token.remove_possibility(Tag.SEQUENCE_NUMBER) + next_token.add_possibility(Tag.SEQUENCE_NUMBER) + + if next_token.content.isdigit(): + # check if there's a token in between + if parser.tokens.get_index(next_token) - parser.tokens.get_index(token) > 1: + skipped_word = parser.filename[token.index + len(token.content):next_token.index] + if len(skipped_word) == 3 and skipped_word[0] == skipped_word[2]: + skipped_word = skipped_word[1] + if constant.DOT in skipped_word: + if (token.content > next_token.content and int(token.content) >= 2 + and Tag.VIDEO_TERM not in token.possibilities): + token.add_possibility(Tag.AUDIO_TERM, base_score=1.5) + next_token.add_possibility(Tag.AUDIO_TERM, base_score=1.5) + # Tag the dot between them as AUDIO_TERM too + for mid in parser.tokens.loop_forward(token): + if mid is next_token: + break + if Tag.DELIMITER in mid.possibilities and mid.content == constant.DOT: + mid.add_possibility(Tag.AUDIO_TERM, base_score=0.5) + elif Tag.AUDIO_TERM in token.possibilities: + next_token.add_possibility(Tag.AUDIO_TERM) + for mid in parser.tokens.loop_forward(token): + if mid is next_token: + break + if Tag.DELIMITER in mid.possibilities and mid.content == constant.DOT: + mid.add_possibility(Tag.AUDIO_TERM, base_score=0.5) + + @staticmethod + def next_non_delimiter(parser: AbstractParser, current_token: Token) -> Union[Token, None]: + for next_token in parser.tokens.loop_forward(current_token): + if Tag.DELIMITER in next_token.possibilities: + continue + return next_token + return None + + @staticmethod + def prev_non_delimiter(parser, current_token) -> Union[Token, None]: + for prev_token in parser.tokens.loop_backward(current_token): + if Tag.DELIMITER in prev_token.possibilities: + continue + return prev_token + return None diff --git a/aniparse/rule/possibilities/release_group_rule.py b/aniparse/rule/possibilities/release_group_rule.py new file mode 100644 index 0000000..e5cf175 --- /dev/null +++ b/aniparse/rule/possibilities/release_group_rule.py @@ -0,0 +1,320 @@ +from aniparse.core import constant +from aniparse.core.constant import BASE_METADATA_LABELS +from aniparse.abstraction.parser_base import PossibilityRule, AbstractParser +from aniparse.core.token_tags import Tag + + +class ReleaseGroupPossibilityRule(PossibilityRule): + """Identify release group from the first and last bracket groups, plus trailing unbracketed.""" + + METADATA_LABELS = BASE_METADATA_LABELS | { + Tag.SEQUENCE_PREFIX, Tag.SEQUENCE_NUMBER, + Tag.FILE_INDEX, Tag.YEAR, Tag.TYPE, + } + + @staticmethod + def _is_metadata_bracket(inside_tokens, strong_labels): + """Return True if all non-delimiter tokens have only strong metadata possibilities.""" + for t in inside_tokens: + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + continue + if not t.possibilities: + return False # unknown token = not pure metadata + if not (t.possibilities.keys() <= strong_labels): + return False + return True + + @classmethod + def _check_trailing_release_group(cls, token_list): + """Check for trailing unbracketed release group like '-dhd' or '-ank' at end.""" + # Walk backwards from end, skip extension-like tokens + end = len(token_list) - 1 + # Skip file extension (last unknown after dot delimiter) — only if it looks like an extension (2-4 chars) + last_tok = token_list[end] + if (end >= 2 and not last_tok.possibilities + and len(last_tok.content) <= 4 + and Tag.DELIMITER in token_list[end - 1].possibilities + and token_list[end - 1].content == constant.DOT + and last_tok.content.lower() in constant.FILE_EXTENSIONS): + end -= 2 + + # Collect trailing UNKNOWN tokens (the group name) + trailing = [] + i = end + while i >= 0: + t = token_list[i] + if not t.possibilities: + trailing.append(t) + i -= 1 + elif Tag.DELIMITER in t.possibilities and t.content.strip(): + trailing.append(t) + i -= 1 + else: + break + + + if not trailing: + return + + if i < 0: + return + + # The token at position i is the first non-trailing token (metadata or delimiter) + # Find the last real metadata token before trailing + last_meta_idx = None + last_meta_is_pure_sequence = False + for k in range(i, -1, -1): + pk = token_list[k].possibilities.keys() + if Tag.DELIMITER in pk or Tag.CONTEXT_DELIMITER in pk: + continue + if not pk or Tag.TITLE in pk: + continue + # Found a metadata token + sequence_like = {Tag.SEQUENCE_NUMBER, Tag.FILE_INDEX, Tag.YEAR} + if pk <= sequence_like: + last_meta_is_pure_sequence = True + else: + last_meta_idx = k + break + + # Only tag as release group if preceded by real metadata (not just episode number) + + if last_meta_idx is not None and not last_meta_is_pure_sequence: + for t in trailing: + if not t.possibilities: + t.add_possibility(Tag.RELEASE_GROUP, base_score=0.7) + + @classmethod + def _check_comma_separated_release_group(cls, bracket_groups, strong_metadata): + """Detect release group in delimiter-separated bracket: '(group, 720p)' or '[hqr.remux]'.""" + for _, inside, _ in bracket_groups: + if not inside: + continue + # Split inside tokens by comma or dot + sections = [] + current = [] + for t in inside: + if Tag.DELIMITER in t.possibilities and t.content.strip() and t.content != constant.UNDERSCORE: + if current: + sections.append(current) + current = [] + else: + current.append(t) + if current: + sections.append(current) + if len(sections) < 2: + continue + # Check each section: is it all unknown or all metadata? + unknown_sections = [] + has_metadata_section = False + for section in sections: + words = [t for t in section + if Tag.DELIMITER not in t.possibilities] + if not words: + continue + all_unknown = all(not t.possibilities for t in words) + all_meta = all( + t.possibilities and t.possibilities.keys() <= (strong_metadata | {Tag.DELIMITER, Tag.CONTEXT_DELIMITER, Tag.SEQUENCE_PART}) + for t in words + ) + if all_unknown: + unknown_sections.append(section) + elif all_meta: + has_metadata_section = True + # If we have at least one unknown section AND at least one metadata section, + # tag the unknown section as release group + if unknown_sections and has_metadata_section: + for section in unknown_sections: + for t in section: + if not t.possibilities or Tag.DELIMITER in t.possibilities: + t.add_possibility(Tag.RELEASE_GROUP, base_score=0.8) + return # only tag one bracket + + @classmethod + def _check_trailing_release_group_after_brackets(cls, token_list): + """Detect trailing release group after the last bracket: ']_-_thora'.""" + end = len(token_list) - 1 + + # Skip trailing version-like tokens (e.g., "v2" → CONTEXT_DELIMITER + SEQUENCE_NUMBER) + i = end + while i >= 0: + t = token_list[i] + pk = t.possibilities.keys() + if Tag.RELEASE_VERSION in pk: + i -= 1 + continue + # Skip version context delimiter (e.g., "v" before version number) and its number + if Tag.CONTEXT_DELIMITER in pk and Tag.RELEASE_VERSION not in pk: + i -= 1 + continue + if Tag.SEQUENCE_NUMBER in pk and Tag.DELIMITER not in pk and not t.possibilities.keys() - {Tag.SEQUENCE_NUMBER, Tag.FILE_INDEX}: + # Number after version context delimiter — check if preceded by one + if i > 0 and Tag.CONTEXT_DELIMITER in token_list[i - 1].possibilities: + i -= 1 + continue + if Tag.DELIMITER in pk and not t.content.strip(): + i -= 1 + continue + break + + # Collect trailing unknown tokens + trailing = [] + while i >= 0: + t = token_list[i] + if not t.possibilities: + trailing.append(t) + i -= 1 + elif Tag.DELIMITER in t.possibilities and t.content.strip(): + trailing.append(t) + i -= 1 + else: + break + + if not trailing or i < 0: + return + + # Now expect: optional delimiters/dashes, then a closing bracket + j = i + found_close_bracket = False + while j >= 0: + t = token_list[j] + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + j -= 1 + continue + if Tag.BRACKET in t.possibilities and t.content in constant.CLOSE_BRACKETS: + found_close_bracket = True + break + + if found_close_bracket: + for t in trailing: + if not t.possibilities: + t.add_possibility(Tag.RELEASE_GROUP, base_score=0.8) + + @classmethod + def apply(cls, parser: AbstractParser): + + tokens = parser.tokens + token_list = list(tokens) + + # Handle broken bracket pattern: ]text] at start of filename + # (missing opening [ — treat as release group) + if (len(token_list) >= 3 + and Tag.BRACKET in token_list[0].possibilities + and token_list[0].content in constant.CLOSE_BRACKETS): + # Find the next close bracket to close this broken bracket + for j in range(1, len(token_list)): + t = token_list[j] + if Tag.BRACKET in t.possibilities and t.content in constant.CLOSE_BRACKETS: + # Tag everything between as release group + for k in range(1, j): + token_list[k].add_possibility(Tag.RELEASE_GROUP, base_score=1.5) + break + if t.category == Tag.DELIMITER and t.content.strip() == '': + break # space before close ] means it's not a bracket group + + # Find all bracket groups + bracket_groups = [] + i = 0 + while i < len(token_list): + token = token_list[i] + if Tag.BRACKET in token.possibilities and token.content in constant.OPEN_BRACKETS: + inside = [] + close_idx = None + expected_close = constant.BRACKET_PAIRS.get(token.content) + for j in range(i + 1, len(token_list)): + t = token_list[j] + if Tag.BRACKET in t.possibilities and t.content == expected_close: + close_idx = j + break + inside.append(t) + if close_idx is not None: + bracket_groups.append((i, inside, close_idx)) + i = close_idx + 1 + continue + i += 1 + + + if not bracket_groups: + # Still check for trailing release group in bracketless filenames + + cls._check_trailing_release_group(token_list) + return + + # Process leading bracket groups and last bracket + STRONG_METADATA = BASE_METADATA_LABELS | { + Tag.SEQUENCE_NUMBER, Tag.YEAR, Tag.FILE_CHECKSUM, + Tag.RELEASE_INFORMATION, Tag.FILE_INDEX, + } + candidates = [] + first_open_pos, first_inside, _ = bracket_groups[0] + # Treat first bracket as release group if at position 0, + # or preceded by only a single word + dot (e.g., "evobot.[group]") + first_bracket_is_rg = first_open_pos == 0 and first_inside + if not first_bracket_is_rg and first_inside and first_open_pos > 0: + pre_tokens = token_list[:first_open_pos] + # Single word + dot before bracket (no spaces) + if (len(pre_tokens) == 2 + and not pre_tokens[0].possibilities + and Tag.DELIMITER in pre_tokens[1].possibilities + and pre_tokens[1].content.strip()): + first_bracket_is_rg = True + if first_bracket_is_rg: + candidates.append((first_inside, 1.5)) + # Check for consecutive brackets immediately after the first + # (e.g., [group1][group2][group3]_title...) + for bg_idx in range(1, len(bracket_groups)): + bg_open_pos, bg_inside, _ = bracket_groups[bg_idx] + prev_close_idx = bracket_groups[bg_idx - 1][2] + # Check if only delimiters between previous close bracket and this open bracket + all_delim = True + for j in range(prev_close_idx + 1, bg_open_pos): + t = token_list[j] + if Tag.DELIMITER not in t.possibilities: + all_delim = False + break + if all_delim and bg_inside and not cls._is_metadata_bracket(bg_inside, STRONG_METADATA): + # Skip long bracket groups (>4 words) — these are likely titles, not release groups + word_count = sum(1 for t in bg_inside + if Tag.DELIMITER not in t.possibilities + and Tag.CONTEXT_DELIMITER not in t.possibilities) + if word_count <= 4: + candidates.append((bg_inside, 1.3)) + else: + break + + # Last bracket: only if no first-bracket release group candidate + has_first_bracket_rg = first_open_pos == 0 and first_inside + if len(bracket_groups) > 1 and not has_first_bracket_rg: + for idx in range(len(bracket_groups) - 1, 0, -1): + _, inside, _ = bracket_groups[idx] + if inside and not cls._is_metadata_bracket(inside, STRONG_METADATA): + candidates.append((inside, 1.0)) + break + + for inside_tokens, score in candidates: + if score == 1.5: + # First bracket at position 0: tag all content as release group candidate + # Positional scoring will boost this further + for token in inside_tokens: + token.add_possibility(Tag.RELEASE_GROUP, base_score=1.5) + else: + # Last bracket: only tag if contains unknown tokens without metadata + has_unknown = any(not t.possibilities for t in inside_tokens) + # Check for strong metadata (not just language — groups can contain language codes) + WEAK_LABELS = {Tag.DELIMITER, Tag.CONTEXT_DELIMITER, Tag.LANGUAGE, Tag.CONTEXT_DEPENDENT, + Tag.SEQUENCE_NUMBER, Tag.FILE_INDEX, Tag.SUBS_TERM} + has_strong_metadata = any( + t.possibilities and not (t.possibilities.keys() <= WEAK_LABELS) + for t in inside_tokens + ) + if has_unknown and not has_strong_metadata: + for token in inside_tokens: + token.add_possibility(Tag.RELEASE_GROUP, base_score=score) + + # Check for comma-separated mixed brackets: "(group name, 720p)" + # Tag the unknown section as RELEASE_GROUP + cls._check_comma_separated_release_group(bracket_groups, STRONG_METADATA) + + # Trailing unbracketed release group after last bracket: "-GroupName" at end + # Only if preceded by a closing bracket (with delimiters between) + cls._check_trailing_release_group_after_brackets(token_list) diff --git a/aniparse/rule/possibilities/title_rule.py b/aniparse/rule/possibilities/title_rule.py new file mode 100644 index 0000000..287e47c --- /dev/null +++ b/aniparse/rule/possibilities/title_rule.py @@ -0,0 +1,1204 @@ +from aniparse.core import constant +from aniparse.core.constant import BASE_METADATA_LABELS +from aniparse.abstraction.parser_base import PossibilityRule, AbstractParser +from aniparse.core.token_tags import Tag + +class TitlePossibilityRule(PossibilityRule): + """Identify title tokens from consecutive UNKNOWN tokens outside brackets.""" + + # Labels that indicate metadata (not title) + METADATA_LABELS = BASE_METADATA_LABELS | { + Tag.SEQUENCE_PREFIX, + Tag.RELEASE_GROUP, + } + + # SEQUENCE_NUMBER alone is not enough to stop title — needs structural evidence + SEQUENCE_ONLY_LABELS = {Tag.SEQUENCE_NUMBER} + + # Labels that are ambiguous in title context (could be title words) + AMBIGUOUS_IN_TITLE = {Tag.LANGUAGE, Tag.SUBS_TERM, Tag.RELEASE_INFORMATION, Tag.EXTRA_INFO} + + @classmethod + def apply(cls, parser: AbstractParser): + tokens = parser.tokens + + extension_token = cls._find_extension_token(tokens) + + bracket_depth = 0 + title_candidates = [] + found_metadata = False + found_metadata_type = None # track what kind of metadata stopped us + + token_list = tokens.tokens + skip_until = -1 + for idx, token in enumerate(token_list): + if idx <= skip_until: + continue + + # Track bracket nesting via possibilities + if Tag.BRACKET in token.possibilities: + if token.content in constant.OPEN_BRACKETS: + # Check if bracket content should be part of title + if bracket_depth == 0 and not found_metadata and title_candidates: + bracket_tokens, end_idx = cls._get_title_bracket_content( + token_list, idx, cls.METADATA_LABELS | cls.SEQUENCE_ONLY_LABELS) + if bracket_tokens is not None: + title_candidates.extend(bracket_tokens) + skip_until = end_idx + continue + # Release group bracket before real title text: + # clear pre-bracket if unknown text follows after bracket + # (e.g., "evobot.[group]_real_title_here") + if (cls._bracket_has_release_group(token_list, idx) + and cls._has_unknown_after_bracket(token_list, idx)): + found_metadata = True + found_metadata_type = Tag.RELEASE_GROUP + title_candidates.clear() + bracket_depth += 1 + elif token.content in constant.CLOSE_BRACKETS: + bracket_depth = max(0, bracket_depth - 1) + continue + + if bracket_depth > 0: + continue + + # Check if this token has any metadata possibilities + has_metadata = bool(token.possibilities.keys() & cls.METADATA_LABELS) + has_seq_only = (not has_metadata and + token.possibilities.keys() & cls.SEQUENCE_ONLY_LABELS == {Tag.SEQUENCE_NUMBER}) + + # Delimiters — include in title if we're collecting + if Tag.DELIMITER in token.possibilities and not has_metadata and not has_seq_only: + if title_candidates and not found_metadata: + title_candidates.append(token) + continue + + # Context delimiter (dash) + if Tag.CONTEXT_DELIMITER in token.possibilities and not has_metadata and not has_seq_only: + if title_candidates: + # Respect zone assignment: if this dash transitions to metadata zone, stop title + if token.zone == constant.ZONE_TRANSITION: + next_tok_zone = tokens.find_next(token) + while next_tok_zone and Tag.DELIMITER in next_tok_zone.possibilities: + next_tok_zone = tokens.find_next(next_tok_zone) + if next_tok_zone and next_tok_zone.zone == constant.ZONE_METADATA: + found_metadata = True + continue + next_tok = tokens.find_next(token) + # Look ahead: skip delimiters to find next meaningful token + while next_tok and Tag.DELIMITER in next_tok.possibilities: + next_tok = tokens.find_next(next_tok) + # Stop title if next token is a sequence prefix (like "episode", "ep") + if next_tok and Tag.SEQUENCE_PREFIX in next_tok.possibilities: + found_metadata = True + continue + if next_tok and Tag.SEQUENCE_NUMBER in next_tok.possibilities: + # But only stop if that next token has a prefix before it + has_prefix = False + prev = tokens.find_prev(next_tok) + while prev and Tag.DELIMITER in prev.possibilities: + prev = tokens.find_prev(prev) + if prev and (Tag.SEQUENCE_PREFIX in prev.possibilities or + Tag.CONTEXT_DELIMITER in prev.possibilities): + has_prefix = True + if has_prefix: + found_metadata = True + continue + title_candidates.append(token) + continue + + # Metadata token — title region is over + if has_metadata: + # Exception: single-char prefix after apostrophe is possessive, not metadata + if (Tag.SEQUENCE_PREFIX in token.possibilities and len(token.content) == 1 + and title_candidates and title_candidates[-1].content == constant.APOSTROPHE): + title_candidates.append(token) + continue + # Exception: SEQUENCE_PREFIX after ordinal in title (e.g. "2nd season") + # Only when followed by dash+number, not bare number + if Tag.SEQUENCE_PREFIX in token.possibilities and title_candidates: + prev_meaningful = [t for t in title_candidates if Tag.DELIMITER not in t.possibilities] + if (prev_meaningful and cls._is_ordinal(prev_meaningful[-1].content, parser.config) + and cls._next_number_after_dash(token, tokens)): + title_candidates.append(token) + continue + # Exception: TYPE or ambiguous labels in title flow without context delimiter + # — no dash separating from previous title, preceded by plain text + ambiguous_labels = token.possibilities.keys() & (cls.AMBIGUOUS_IN_TITLE | {Tag.TYPE}) + if (ambiguous_labels and title_candidates + and not cls._context_delim_before(token, tokens) + and cls._prev_is_unknown(token, tokens)): + title_candidates.append(token) + continue + found_metadata = True + if Tag.RELEASE_GROUP in token.possibilities: + found_metadata_type = Tag.RELEASE_GROUP + elif Tag.LANGUAGE in token.possibilities or Tag.SUBS_TERM in token.possibilities: + found_metadata_type = Tag.LANGUAGE + continue + + # Bare SEQUENCE_NUMBER: decide if it's title or episode + if has_seq_only and (token.content.isdigit() or cls._is_ordinal(token.content, parser.config)): + # Decimal continuation: if connected to previous title number via "." + # (e.g., "1.0", "3.33"), always include in title + if (title_candidates and len(title_candidates) >= 2 + and Tag.DELIMITER in title_candidates[-1].possibilities + and title_candidates[-1].content == constant.DOT): + # Check if before the "." there was a SEQUENCE_NUMBER in title + prev_num = None + for tc in reversed(title_candidates[:-1]): + if Tag.DELIMITER in tc.possibilities: + continue + if Tag.SEQUENCE_NUMBER in tc.possibilities: + prev_num = tc + break + if prev_num is not None: + title_candidates.append(token) + continue + # Ordinal followed by SEQUENCE_PREFIX = metadata (e.g. "2nd season 24") + # unless next number is after a dash (e.g. "2nd season - 00") + if cls._is_ordinal(token.content, parser.config): + next_meaningful = cls._next_meaningful_token(token, tokens) + if next_meaningful and Tag.SEQUENCE_PREFIX in next_meaningful.possibilities: + if not cls._next_number_after_dash(next_meaningful, tokens): + found_metadata = True + continue + # If immediately preceded by TYPE keyword in title, this number is part of title + # (e.g., "movie 2", "season 2") — but not "the tv 01" + prev_type_in_title = False + for t in reversed(title_candidates): + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + continue + if Tag.TYPE in t.possibilities: + prev_type_in_title = True + break + if prev_type_in_title or cls._prev_is_title_number_continuation(title_candidates): + # Bare numbers after TYPE-in-title are title (e.g., "movie 9") + # Also numbers after connectors following title numbers (e.g., "movies 8 & 10") + # Zero-padded single digits are still episode (handled above) + is_zero_padded = (len(token.content) == 2 + and token.content[0] == '0' + and token.content[1].isdigit()) + if not is_zero_padded: + title_candidates.append(token) + continue + # If previous title token is a short word glued directly to this number + # (e.g., "R2", "S1", "ex01"), treat as compound title word + if title_candidates: + prev_title = title_candidates[-1] + if (not prev_title.possibilities + and len(prev_title.content) <= 3 + and prev_title.content.isalpha() + and prev_title.index + len(prev_title.content) == token.index): + title_candidates.append(token) + continue + # Stop if preceded by structural prefix (like "ep", "episode", dash) + # Glued dashes (no delimiter on either side) are not structural + has_prefix = False + for t in title_candidates[-3:]: + if not t: + continue + if Tag.SEQUENCE_PREFIX in t.possibilities: + has_prefix = True + break + if Tag.CONTEXT_DELIMITER in t.possibilities: + # Check if this dash is structural (has delimiter/space on at least one side) + t_end = t.index + len(t.content) + t_prev = None + t_next_tok = None + for tt in title_candidates: + if tt.index + len(tt.content) == t.index: + t_prev = tt + if tt.index == t_end: + t_next_tok = tt + is_structural = ((t_prev and Tag.DELIMITER in t_prev.possibilities) or + (t_next_tok and Tag.DELIMITER in t_next_tok.possibilities)) + if is_structural: + has_prefix = True + break + if has_prefix: + found_metadata = True + continue + # Look ahead: if non-metadata text follows, number is likely title + # But zero-padded numbers (e.g. "03") are almost always episodes + if not found_metadata and not token.content.startswith("0") and cls._has_text_after(token, tokens): + title_candidates.append(token) + continue + # No text follows — but check if episode structure exists later + # If so, this number is likely title (e.g. "fairy tail 2 - 52") + if not found_metadata and cls._has_episode_after(token, tokens): + title_candidates.append(token) + continue + # No text follows, no episode elsewhere — stop title + # Exception: year-like numbers (4 digits, in year range) that are + # glued to preceding text may be part of event names (e.g. "festa 2018") + if not found_metadata: + if (len(token.content) == 4 and token.content.isdigit() + and 1900 <= int(token.content) <= 2099 + and title_candidates + and not token.content.startswith("0")): + title_candidates.append(token) + continue + found_metadata = True + continue + + # Ambiguous SEQUENCE_PART (e.g. "d" in "3d"): include in title + # if preceding token was included and text follows + if (not found_metadata + and token.possibilities.keys() == {Tag.SEQUENCE_PART} + and title_candidates): + title_candidates.append(token) + continue + + # TYPE keyword (movie, special, gekijouban) in title zone: + # include if followed eventually by unknown text, or if no structural + # break (context delimiter) separates it from preceding title text + if not found_metadata and Tag.TYPE in token.possibilities: + # Inflected forms (OVAs, Movies, Specials) are title-leaning — + # only metadata if bracket-isolated + type_poss = token.possibilities[Tag.TYPE] + if type_poss.element and type_poss.element.canonical and not token.bracket_group: + title_candidates.append(token) + continue + # If TYPE is followed by a zero-padded number, it's metadata (e.g., "movie 02") + # Bare numbers (9, 12) without context delimiter are title ("movie 9") + next_meaningful = cls._next_meaningful_token(token, tokens) + if next_meaningful and Tag.SEQUENCE_NUMBER in next_meaningful.possibilities: + is_zero_padded = (len(next_meaningful.content) == 2 + and next_meaningful.content[0] == '0' + and next_meaningful.content[1].isdigit()) + after_num = cls._next_meaningful_token(next_meaningful, tokens) + text_follows = after_num and not after_num.possibilities + has_article = title_candidates and cls._prev_is_article(title_candidates) + is_year = Tag.YEAR in next_meaningful.possibilities or Tag.SERIES_YEAR in next_meaningful.possibilities + if is_zero_padded and not text_follows and not has_article and not is_year: + found_metadata = True + continue + if cls._has_unknown_text_eventually(token, tokens): + title_candidates.append(token) + continue + # Include TYPE in title when no context delimiter separates it + # from preceding plain text (e.g., "cells at work! special", + # "aika zero ova - 01", "magical star kanon 100% OVA[DVD]") + if (title_candidates + and not cls._context_delim_before(token, tokens) + and cls._prev_is_unknown(token, tokens)): + title_candidates.append(token) + continue + # TYPE preceded by running title text and followed by bracket containing year: + # e.g. "The Animated Movie (1994)" — "Movie" is part of the title + if title_candidates and cls._prev_is_non_type_text(title_candidates): + next_m = cls._next_meaningful_token(token, tokens) + if next_m and Tag.BRACKET in next_m.possibilities: + # Check if the bracket group contains a year + if cls._bracket_contains_year(token_list, token_list.index(next_m)): + title_candidates.append(token) + continue + # TYPE not followed by number, preceded by title text, followed by + # context delimiter then non-numeric content → part of the title + # (e.g., "school idol movie - pv" but NOT "ova - 01") + if title_candidates and cls._context_delim_after(token, tokens): + # Check what follows the context delimiter + after_delim = cls._first_after_context_delim(token, tokens) + if after_delim and not (Tag.SEQUENCE_NUMBER in after_delim.possibilities): + title_candidates.append(token) + continue + # TYPE without text after = metadata boundary + found_metadata = True + continue + + if found_metadata: + # If no title collected yet and we see unknown text after release group, + # restart title search (e.g., ]akihitosubs] elfen lied...) + if (not title_candidates and not token.possibilities + and found_metadata_type in (Tag.RELEASE_GROUP, Tag.LANGUAGE)): + found_metadata = False + found_metadata_type = None + # fall through to unknown token handling below + else: + continue + + # CONTEXT_DEPENDENT (e.g., "&") — include in title when between title tokens + if (Tag.CONTEXT_DEPENDENT in token.possibilities and title_candidates + and not found_metadata): + title_candidates.append(token) + continue + + # Empty possibilities = UNKNOWN token + if not token.possibilities: + if token is extension_token: + continue + title_candidates.append(token) + + # Trim trailing/leading delimiters + while title_candidates and Tag.DELIMITER in title_candidates[-1].possibilities: + title_candidates.pop() + while title_candidates and Tag.DELIMITER in title_candidates[0].possibilities: + title_candidates.pop(0) + while title_candidates and Tag.CONTEXT_DELIMITER in title_candidates[-1].possibilities: + title_candidates.pop() + while title_candidates and Tag.CONTEXT_DELIMITER in title_candidates[0].possibilities: + title_candidates.pop(0) + + # Apply TITLE possibility + for token in title_candidates: + if not token.possibilities: + token.add_possibility(Tag.SERIES_TITLE, base_score=1.0) + elif Tag.BRACKET in token.possibilities: + # Bracket included in title (e.g., "[not]", "(not)") + token.add_possibility(Tag.SERIES_TITLE, base_score=0.5) + elif Tag.RELEASE_GROUP in token.possibilities and not (token.possibilities.keys() & cls.METADATA_LABELS - {Tag.RELEASE_GROUP}): + # Release group candidate inside bracket that's part of title + # But if release_group_rule already gave it a high score, don't override + rg_score = token.possibilities[Tag.RELEASE_GROUP].score + token.add_possibility(Tag.SERIES_TITLE, base_score=1.0 if rg_score >= 1.5 else 1.5) + elif Tag.DELIMITER in token.possibilities or Tag.CONTEXT_DELIMITER in token.possibilities: + token.add_possibility(Tag.SERIES_TITLE, base_score=0.5) + elif token.possibilities.keys() & cls.SEQUENCE_ONLY_LABELS: + # Bare number included in title — add TITLE as competing possibility + # Higher score (1.5) because heuristic determined it's likely title + token.add_possibility(Tag.SERIES_TITLE, base_score=1.5) + elif token.possibilities.keys() == {Tag.SEQUENCE_PART}: + # Ambiguous part suffix included in title (e.g. "d" in "3d") + token.add_possibility(Tag.SERIES_TITLE, base_score=2.0) + elif Tag.TYPE in token.possibilities: + # TYPE keyword included in title (e.g. "gekijouban", "movie" as part of title) + token.add_possibility(Tag.SERIES_TITLE, base_score=1.5) + elif Tag.SEQUENCE_PREFIX in token.possibilities: + # Possessive 's or other prefix included in title context + token.add_possibility(Tag.SERIES_TITLE, base_score=1.5) + elif token.possibilities.keys() & cls.AMBIGUOUS_IN_TITLE: + # Ambiguous keyword included in title flow (e.g. "ita" in "bokura ga ita") + # Use moderate score — regex-confirmed keywords (1.5) should override + token.add_possibility(Tag.SERIES_TITLE, base_score=1.0) + elif Tag.CONTEXT_DEPENDENT in token.possibilities: + # Context-dependent tokens like "&" included in title + token.add_possibility(Tag.SERIES_TITLE, base_score=1.5) + + # If no title found by first pass, check for bracket-enclosed title + if not title_candidates: + cls._detect_bracket_title(tokens) + + # Second pass: detect episode titles (UNKNOWN tokens after last SEQUENCE_NUMBER, outside brackets) + cls._detect_episode_title(tokens, extension_token) + + # Third pass: detect episode title in brackets immediately after episode number + cls._detect_bracket_episode_title(tokens) + + @staticmethod + def _get_title_bracket_content(token_list, open_idx, metadata_labels): + """Check if bracket content should be included in title. + + Returns (list of tokens, close_idx) if the bracket content has no metadata + and text follows, or (None, -1) if it should not be included. + """ + open_tok = token_list[open_idx] + inside = [] + close_idx = None + expected_close = constant.BRACKET_PAIRS.get(open_tok.content) + for j in range(open_idx + 1, len(token_list)): + t = token_list[j] + if Tag.BRACKET in t.possibilities and t.content == expected_close: + close_idx = j + break + inside.append(t) + if close_idx is None or not inside: + return None, -1 + # Check if any inside token has metadata possibilities + # Check if bracket contains metadata (→ not title content). + # RELEASE_GROUP alone is not considered metadata — it could be a title word. + # If the bracket has a mix of unknown and low-confidence metadata tokens, + # it's likely title content (e.g., "the final season" where "season" is a keyword). + meaningful = [t for t in inside + if Tag.DELIMITER not in t.possibilities + and Tag.CONTEXT_DELIMITER not in t.possibilities] + metadata_count = 0 + for t in meaningful: + non_rg_metadata = t.possibilities.keys() & metadata_labels - {Tag.RELEASE_GROUP} + if non_rg_metadata: + metadata_count += 1 + rg_poss = t.possibilities.get(Tag.RELEASE_GROUP) + if rg_poss and rg_poss.score >= 1.5: + return None, -1 + # Reject if all meaningful tokens are metadata (e.g., "(TV)", "(2008)", "(DVD)") + if meaningful and metadata_count == len(meaningful): + return None, -1 + # Check if this bracket belongs in the title: + # (a) title text follows, (b) bracket is glued to preceding title, + # or (c) context delimiter follows (indicating episode/metadata after title) + has_text_after = False + has_context_delim_after = False + for j in range(close_idx + 1, len(token_list)): + t = token_list[j] + if Tag.DELIMITER in t.possibilities and Tag.CONTEXT_DELIMITER not in t.possibilities: + continue + if Tag.CONTEXT_DELIMITER in t.possibilities: + has_context_delim_after = True + break + if Tag.BRACKET in t.possibilities: + break + if not t.possibilities: + has_text_after = True + break + # Check if bracket is glued (no delimiter between last title token and open bracket) + is_glued = (open_idx > 0 and + Tag.DELIMITER not in token_list[open_idx - 1].possibilities and + Tag.BRACKET not in token_list[open_idx - 1].possibilities) + if not has_text_after and not is_glued and not has_context_delim_after: + return None, -1 + result = [open_tok] + inside + [token_list[close_idx]] + return result, close_idx + + @classmethod + def _prev_is_title_number_continuation(cls, title_candidates): + """Check if title candidates end with a connector after a number (e.g., '8 &'). + + This detects the pattern TYPE + number + connector, where the next number + should also be part of the title (e.g., 'movies 8 & 10'). + """ + # Walk back: expect connector, then digit + state = 'expect_connector' + for t in reversed(title_candidates): + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + continue + if state == 'expect_connector' and Tag.CONTEXT_DEPENDENT in t.possibilities: + state = 'expect_digit' + continue + if state == 'expect_digit' and t.content.isdigit(): + return True + return False + return False + + @classmethod + def _prev_is_article(cls, title_candidates): + """Check if the last non-delimiter title candidate is a short lowercase function word. + + Articles and prepositions in titles are typically lowercase and short + (e.g., "the", "no", "of"). When such a word directly precedes a TYPE + keyword, the TYPE is likely part of the title (e.g., "the movie"). + Uppercase words like "Z" or content words like "work" don't qualify. + """ + for t in reversed(title_candidates): + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + continue + return (not t.possibilities + and t.content.islower() + and 2 <= len(t.content) <= 3) + return False + + @classmethod + def _has_text_after(cls, token, tokens): + """Check if non-metadata UNKNOWN text follows this token before next structural break.""" + next_tok = tokens.find_next(token) + # Skip delimiters, ambiguous sequence parts, and decimal-connected numbers + while next_tok and (Tag.DELIMITER in next_tok.possibilities + or next_tok.possibilities.keys() == {Tag.SEQUENCE_PART}): + next_tok = tokens.find_next(next_tok) + # Skip decimal-connected SEQUENCE_NUMBER (e.g., "3" "." "33" → look past "33") + if (next_tok and next_tok.possibilities.keys() & cls.SEQUENCE_ONLY_LABELS + and next_tok.content.isdigit()): + prev = tokens.find_prev(next_tok) + if prev and Tag.DELIMITER in prev.possibilities and prev.content == constant.DOT: + # This number is connected via "." to the previous — treat as decimal, skip + next_tok = tokens.find_next(next_tok) + while next_tok and (Tag.DELIMITER in next_tok.possibilities + or next_tok.possibilities.keys() == {Tag.SEQUENCE_PART}): + next_tok = tokens.find_next(next_tok) + if not next_tok: + return False + # Unknown token, TYPE-only, or TITLE = text continues + if not next_tok.possibilities or next_tok.possibilities.keys() == {Tag.TYPE}: + return True + if Tag.TITLE in next_tok.possibilities: + return True + # Context delimiter (dash) — check what follows + if Tag.CONTEXT_DELIMITER in next_tok.possibilities: + # Look past the dash for either episode structure or plain text + # Only include as title if the number doesn't look like an episode + # (no leading zero) and text follows the dash + if not token.content.startswith("0"): + past_dash = tokens.find_next(next_tok) + while past_dash and Tag.DELIMITER in past_dash.possibilities: + past_dash = tokens.find_next(past_dash) + if past_dash and not past_dash.possibilities: + return True # Plain unknown text after dash + return cls._has_episode_after(next_tok, tokens) + # Bracket — look past it for episode evidence + if Tag.BRACKET in next_tok.possibilities: + return cls._has_episode_after(next_tok, tokens) + # Sequence prefix after this number means episode is elsewhere + if Tag.SEQUENCE_PREFIX in next_tok.possibilities: + return True + # Other metadata = structural break + if next_tok.possibilities.keys() & cls.METADATA_LABELS: + return False + if next_tok.possibilities.keys() & cls.SEQUENCE_ONLY_LABELS: + return False + return False + + @classmethod + def _has_episode_after(cls, start_tok, tokens): + """Check if there's episode structure (prefix+number or dash+number) after this point.""" + tok = start_tok + in_bracket = False + after_dash = Tag.CONTEXT_DELIMITER in start_tok.possibilities + while tok: + tok = tokens.find_next(tok) + if not tok: + return False + if Tag.BRACKET in tok.possibilities: + if tok.content in constant.OPEN_BRACKETS: + in_bracket = True + elif tok.content in constant.CLOSE_BRACKETS: + in_bracket = False + continue + if in_bracket: + continue + if Tag.DELIMITER in tok.possibilities: + continue + # Found a sequence prefix outside brackets — episode exists later + if Tag.SEQUENCE_PREFIX in tok.possibilities: + return True + # Found a context delimiter — mark it, look for number after + if Tag.CONTEXT_DELIMITER in tok.possibilities: + after_dash = True + continue + # Number after a dash = episode evidence (but not release version like "v2") + if after_dash and Tag.SEQUENCE_NUMBER in tok.possibilities: + if Tag.RELEASE_VERSION not in tok.possibilities: + return True + after_dash = False + return False + + @classmethod + def _detect_episode_title(cls, tokens, extension_token): + """Tag UNKNOWN tokens after the last SEQUENCE_NUMBER as episode title.""" + token_list = tokens.tokens + last_seq_idx = -1 + in_bracket = False + for i, token in enumerate(token_list): + if Tag.BRACKET in token.possibilities: + if token.content in constant.OPEN_BRACKETS: + in_bracket = True + elif token.content in constant.CLOSE_BRACKETS: + in_bracket = False + continue + if in_bracket: + continue + if Tag.SEQUENCE_NUMBER in token.possibilities: + last_seq_idx = i + anchor_idx = last_seq_idx + stop_before_idx = -1 # if backed up, don't include the original last seq token + anchor_backed_up = False + + # If the last SEQUENCE_NUMBER is embedded in text (UNKNOWN on both sides), + # step back to the previous SEQUENCE_NUMBER as anchor + if anchor_idx >= 0: + anchor_tok = token_list[anchor_idx] + has_unknown_before = False + has_unknown_after = False + for j in range(anchor_idx - 1, -1, -1): + t = token_list[j] + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + continue + if not t.possibilities or Tag.TITLE in t.possibilities: + has_unknown_before = True + break + for j in range(anchor_idx + 1, len(token_list)): + t = token_list[j] + if Tag.DELIMITER in t.possibilities: + continue + if Tag.BRACKET in t.possibilities: + break + if not t.possibilities: + has_unknown_after = True + break + if has_unknown_before: + # Find previous SEQUENCE_NUMBER outside brackets + prev_seq_idx = -1 + in_bracket2 = False + for j in range(anchor_idx - 1, -1, -1): + t = token_list[j] + if Tag.BRACKET in t.possibilities: + if t.content in constant.CLOSE_BRACKETS: + in_bracket2 = True + elif t.content in constant.OPEN_BRACKETS: + in_bracket2 = False + continue + if in_bracket2: + continue + if Tag.SEQUENCE_NUMBER in t.possibilities: + prev_seq_idx = j + break + if prev_seq_idx >= 0: + anchor_backed_up = True + if not has_unknown_after: + # Stop before the last seq number if preceded by a structural + # context delimiter (dash with spaces around it) + has_context_delim_before = False + for j in range(anchor_idx - 1, -1, -1): + t = token_list[j] + if Tag.DELIMITER in t.possibilities and Tag.CONTEXT_DELIMITER not in t.possibilities: + continue + if Tag.CONTEXT_DELIMITER in t.possibilities: + has_context_delim_before = True + break + if has_context_delim_before: + stop_before_idx = anchor_idx + anchor_idx = prev_seq_idx + + if anchor_idx < 0: + return + + # Collect UNKNOWN tokens after anchor sequence number, outside brackets + ep_title_candidates = [] + in_bracket = False + for i in range(anchor_idx + 1, len(token_list)): + token = token_list[i] + if Tag.BRACKET in token.possibilities: + if token.content in constant.OPEN_BRACKETS: + in_bracket = True + elif token.content in constant.CLOSE_BRACKETS: + in_bracket = False + continue + if in_bracket: + continue + if token is extension_token: + continue + + has_seq = Tag.SEQUENCE_NUMBER in token.possibilities + has_metadata = bool(token.possibilities.keys() & (cls.METADATA_LABELS - {Tag.FILE_INDEX})) + # SEQUENCE_NUMBER inside episode title: check before metadata break + if has_seq and not has_metadata: + # Stop before the original last seq number when anchor was backed up + if stop_before_idx >= 0 and i >= stop_before_idx: + break + if ep_title_candidates: + if cls._has_unknown_text_after_in_eptitle(token, token_list, i, extension_token): + ep_title_candidates.append(token) + continue + # Number at the end of episode title (only brackets/delimiters follow) + if cls._only_brackets_after(token_list, i, extension_token): + ep_title_candidates.append(token) + continue + break + if has_metadata: + # Content keywords (opening, ending, part) can be part of episode title + if ep_title_candidates and cls._is_content_keyword(token): + ep_title_candidates.append(token) + continue + # Possessive 's: single "s" right after apostrophe is not a real prefix + if (Tag.SEQUENCE_PREFIX in token.possibilities + and len(token.content) == 1 + and ep_title_candidates + and ep_title_candidates[-1].content == constant.APOSTROPHE): + ep_title_candidates.append(token) + continue + break # hit metadata, stop + + if (not token.possibilities or token.possibilities.keys() == {Tag.CONTEXT_DEPENDENT} + or (token.possibilities.keys() == {Tag.TITLE})): + ep_title_candidates.append(token) + elif token.possibilities.keys() <= {Tag.FILE_INDEX} and ep_title_candidates: + # Small number (FILE_INDEX) after text — include in episode title (e.g. "part 1") + ep_title_candidates.append(token) + elif Tag.DELIMITER in token.possibilities or Tag.CONTEXT_DELIMITER in token.possibilities: + if ep_title_candidates: + ep_title_candidates.append(token) + elif not token.possibilities.keys() - {Tag.DELIMITER, Tag.CONTEXT_DELIMITER}: + # Plain delimiter before any ep title tokens — skip but don't break + continue + elif Tag.TYPE in token.possibilities: + # TYPE in episode title zone: treat as title text (e.g., "special day") + ep_title_candidates.append(token) + elif ep_title_candidates and cls._is_content_keyword(token): + # Content keywords (e.g. "opening", "part") can be part of episode title + ep_title_candidates.append(token) + elif not ep_title_candidates and cls._is_content_keyword(token): + # Content keywords before any text — skip, might start ep title after + continue + + # Trim trailing/leading delimiters + while ep_title_candidates and (Tag.DELIMITER in ep_title_candidates[-1].possibilities or Tag.CONTEXT_DELIMITER in ep_title_candidates[-1].possibilities): + ep_title_candidates.pop() + while ep_title_candidates and (Tag.DELIMITER in ep_title_candidates[0].possibilities or Tag.CONTEXT_DELIMITER in ep_title_candidates[0].possibilities): + ep_title_candidates.pop(0) + + if not ep_title_candidates: + return + + # Don't tag as episode title if already tagged as series title + # (unless anchor was backed up, meaning these are between season and episode) + if not anchor_backed_up: + for token in ep_title_candidates: + if Tag.TITLE in token.possibilities: + return + + for ti, token in enumerate(ep_title_candidates): + if (not token.possibilities or token.possibilities.keys() == {Tag.CONTEXT_DEPENDENT} + or token.possibilities.keys() == {Tag.TITLE}): + token.add_possibility(Tag.SEQUENCE_TITLE, base_score=1.0) + elif Tag.SEQUENCE_NUMBER in token.possibilities: + # Number embedded in episode title (e.g., "data_01_login") + token.add_possibility(Tag.SEQUENCE_TITLE, base_score=1.5) + elif Tag.DELIMITER in token.possibilities or Tag.CONTEXT_DELIMITER in token.possibilities: + token.add_possibility(Tag.SEQUENCE_TITLE, base_score=0.5) + elif cls._is_content_keyword(token): + token.add_possibility(Tag.SEQUENCE_TITLE, base_score=2.0) + elif (Tag.SEQUENCE_PREFIX in token.possibilities and len(token.content) == 1 + and ti > 0 and ep_title_candidates[ti - 1].content == constant.APOSTROPHE): + # Possessive 's — include in episode title + token.add_possibility(Tag.SEQUENCE_TITLE, base_score=2.0) + elif token.possibilities.keys() <= {Tag.FILE_INDEX}: + # Small number included in episode title (e.g. "part 1") + token.add_possibility(Tag.SEQUENCE_TITLE, base_score=1.5) + + @classmethod + def _only_brackets_after(cls, token_list, current_idx, extension_token): + """Check if only brackets, delimiters, and extension follow this token.""" + for j in range(current_idx + 1, len(token_list)): + t = token_list[j] + if t is extension_token: + continue + if Tag.DELIMITER in t.possibilities: + continue + if Tag.BRACKET in t.possibilities: + return True + return False # something else follows + return True # end of tokens + + @classmethod + def _has_unknown_text_after_in_eptitle(cls, token, token_list, current_idx, extension_token): + """Check if unknown text follows this SEQUENCE_NUMBER in the episode title context.""" + for j in range(current_idx + 1, len(token_list)): + t = token_list[j] + if t is extension_token: + continue + if Tag.BRACKET in t.possibilities: + return False + if Tag.DELIMITER in t.possibilities: + continue + if not t.possibilities: + return True # Unknown text follows + return False + return False + + @classmethod + def _has_unknown_text_eventually(cls, token, tokens): + """Check if unknown text follows, looking past numbers, delimiters, and context delimiters.""" + next_tok = tokens.find_next(token) + while next_tok and (Tag.DELIMITER in next_tok.possibilities + or Tag.CONTEXT_DELIMITER in next_tok.possibilities + or Tag.SEQUENCE_NUMBER in next_tok.possibilities): + next_tok = tokens.find_next(next_tok) + if not next_tok: + return False + if Tag.BRACKET in next_tok.possibilities: + return False + return not next_tok.possibilities + + @classmethod + def _has_unknown_text_after(cls, token, tokens): + """Check if truly unknown (no possibilities) text follows this token.""" + next_tok = tokens.find_next(token) + while next_tok and (Tag.DELIMITER in next_tok.possibilities + or Tag.CONTEXT_DELIMITER in next_tok.possibilities): + next_tok = tokens.find_next(next_tok) + if not next_tok: + return False + return not next_tok.possibilities + + @classmethod + def _next_meaningful_token(cls, token, tokens): + """Find next non-delimiter token.""" + tok = tokens.find_next(token) + while tok and Tag.DELIMITER in tok.possibilities: + tok = tokens.find_next(tok) + return tok + + @classmethod + def _next_number_after_dash(cls, token, tokens): + """Check if the next SEQUENCE_NUMBER is preceded by a context delimiter (dash).""" + tok = tokens.find_next(token) + while tok and Tag.DELIMITER in tok.possibilities: + tok = tokens.find_next(tok) + if not tok: + return False + # If next meaningful token is a context delimiter, the number follows after + if Tag.CONTEXT_DELIMITER in tok.possibilities: + return True + # If next meaningful token is a bare number (no dash), return False + return False + + @staticmethod + def _is_ordinal(content: str, config=None) -> bool: + """Check if token is an ordinal number like '1st', '2nd', '3rd', '4th'.""" + if config and config.ordinal_suffixes: + lower = content.lower() + for suffix in config.ordinal_suffixes: + if lower.endswith(suffix) and lower[:-len(suffix)].isdigit(): + return True + return False + from aniparse.rule.possibilities.number_rule import get_number_from_ordinal + from aniparse.config import ParserConfig + return bool(get_number_from_ordinal(content, ParserConfig())) + + @staticmethod + def _bracket_contains_year(token_list, open_idx): + """Check if a bracket group starting at open_idx contains a YEAR token.""" + for j in range(open_idx + 1, len(token_list)): + t = token_list[j] + if Tag.BRACKET in t.possibilities and t.content in constant.CLOSE_BRACKETS: + break + if Tag.YEAR in t.possibilities or Tag.SERIES_YEAR in t.possibilities: + return True + return False + + @classmethod + def _prev_is_non_type_text(cls, title_candidates): + """Check if the last non-delimiter title candidate is plain text (not TYPE or article).""" + for t in reversed(title_candidates): + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + continue + # Must be plain unknown text (no TYPE possibility) and not a function word + if not t.possibilities and t.content.islower() and 2 <= len(t.content) <= 3: + return False + return not t.possibilities or (Tag.TYPE not in t.possibilities) + return False + + @classmethod + def _prev_is_unknown(cls, token, tokens): + """Check if the previous non-delimiter token has no possibilities (plain text).""" + prev = tokens.find_prev(token) + while prev and Tag.DELIMITER in prev.possibilities: + prev = tokens.find_prev(prev) + if not prev: + return False + return not prev.possibilities + + @classmethod + @classmethod + def _followed_by_structural_break(cls, token, tokens): + """Check if a context delimiter, bracket, or end-of-tokens follows this token.""" + nxt = tokens.find_next(token) + while nxt and Tag.DELIMITER in nxt.possibilities: + nxt = tokens.find_next(nxt) + if not nxt: + return False # end of tokens — no structural break + return (Tag.CONTEXT_DELIMITER in nxt.possibilities + or Tag.BRACKET in nxt.possibilities) + + @classmethod + def _context_delim_before(cls, token, tokens): + """Check if a context delimiter exists between this token and its previous non-delimiter neighbor.""" + prev = tokens.find_prev(token) + while prev and Tag.DELIMITER in prev.possibilities: + prev = tokens.find_prev(prev) + if prev and Tag.CONTEXT_DELIMITER in prev.possibilities: + return True + return False + + @staticmethod + def _is_content_keyword(token): + """Check if a token is a content keyword (CONTENT_TYPE, CONTENT_IDENTIFIER, or SEQUENCE_PREFIX with content descriptor).""" + if Tag.CONTENT_TYPE in token.possibilities or Tag.CONTENT_IDENTIFIER in token.possibilities: + return True + poss = token.possibilities.get(Tag.SEQUENCE_PREFIX) + if poss and poss.descriptor in (Tag.CONTENT_TYPE, Tag.CONTENT_IDENTIFIER): + return True + return False + + @classmethod + def _number_immediately_after(cls, token, tokens): + """Check if the next non-delimiter token is a SEQUENCE_NUMBER.""" + nxt = tokens.find_next(token) + while nxt and Tag.DELIMITER in nxt.possibilities and Tag.SEQUENCE_NUMBER not in nxt.possibilities: + nxt = tokens.find_next(nxt) + if nxt and Tag.SEQUENCE_NUMBER in nxt.possibilities: + return True + return False + + @classmethod + def _context_delim_after(cls, token, tokens): + """Check if a context delimiter exists after this token (skipping plain delimiters).""" + nxt = tokens.find_next(token) + while nxt and Tag.DELIMITER in nxt.possibilities and Tag.CONTEXT_DELIMITER not in nxt.possibilities: + nxt = tokens.find_next(nxt) + if nxt and Tag.CONTEXT_DELIMITER in nxt.possibilities: + return True + return False + + @classmethod + def _first_after_context_delim(cls, token, tokens): + """Find the first meaningful token after the next context delimiter.""" + nxt = tokens.find_next(token) + found_delim = False + while nxt: + if Tag.CONTEXT_DELIMITER in nxt.possibilities: + found_delim = True + elif Tag.DELIMITER in nxt.possibilities: + pass + elif found_delim: + return nxt + else: + break + nxt = tokens.find_next(nxt) + return None + + @staticmethod + def _has_unknown_after_bracket(token_list, open_idx): + """Check if unknown text follows after the bracket group starting at open_idx.""" + open_tok = token_list[open_idx] + expected_close = constant.BRACKET_PAIRS.get(open_tok.content) + close_idx = None + for j in range(open_idx + 1, len(token_list)): + if Tag.BRACKET in token_list[j].possibilities and token_list[j].content == expected_close: + close_idx = j + break + if close_idx is None: + return False + for j in range(close_idx + 1, len(token_list)): + t = token_list[j] + if Tag.DELIMITER in t.possibilities: + continue + return not t.possibilities # True if unknown text, False otherwise + return False + + @staticmethod + def _bracket_has_release_group(token_list, open_idx): + """Check if bracket content is a release group (has RELEASE_GROUP possibility).""" + open_tok = token_list[open_idx] + expected_close = constant.BRACKET_PAIRS.get(open_tok.content) + for j in range(open_idx + 1, len(token_list)): + t = token_list[j] + if Tag.BRACKET in t.possibilities and t.content == expected_close: + break + if Tag.RELEASE_GROUP in t.possibilities: + return True + return False + + @staticmethod + def _find_extension_token(tokens): + """Find the file extension token — last UNKNOWN after last dot.""" + all_tokens = tokens.tokens + if len(all_tokens) < 3: + return None + last = all_tokens[-1] + second_last = all_tokens[-2] + if (not last.possibilities + and Tag.DELIMITER in second_last.possibilities + and second_last.content == constant.DOT): + return last + return None + + @classmethod + def _detect_bracket_title(cls, tokens): + """Detect title inside a bracket group when no outside-bracket title was found. + + Pattern: [release_group][long title text] ep02 [metadata] + A bracket group with many unknown words before episode markers is a title. + """ + token_list = tokens.tokens + + # Find bracket groups + bracket_groups = [] + i = 0 + while i < len(token_list): + t = token_list[i] + if Tag.BRACKET in t.possibilities and t.content in constant.OPEN_BRACKETS: + inside = [] + close_idx = None + expected_close = constant.BRACKET_PAIRS.get(t.content) + for j in range(i + 1, len(token_list)): + tj = token_list[j] + if Tag.BRACKET in tj.possibilities and tj.content == expected_close: + close_idx = j + break + inside.append(tj) + if close_idx is not None: + bracket_groups.append((i, inside, close_idx)) + i = close_idx + 1 + continue + i += 1 + + if len(bracket_groups) < 2: + return + + # Look for a bracket group that: + # (a) contains mostly unknown text (>4 non-delimiter words) + # (b) is followed by episode markers or end of filename + for bg_idx, (open_idx, inside, close_idx) in enumerate(bracket_groups): + if bg_idx == 0: + continue # skip first (likely release_group) + + word_tokens = [t for t in inside + if Tag.DELIMITER not in t.possibilities + and Tag.CONTEXT_DELIMITER not in t.possibilities] + if len(word_tokens) < 4: + continue + + # Check: mostly unknown tokens (no strong metadata) + unknown_count = 0 + metadata_count = 0 + for t in word_tokens: + if not t.possibilities or Tag.RELEASE_GROUP in t.possibilities: + unknown_count += 1 + elif t.possibilities.keys() & (cls.METADATA_LABELS | {Tag.SEQUENCE_NUMBER}): + metadata_count += 1 + else: + unknown_count += 1 + + if unknown_count < 4 or metadata_count > unknown_count: + continue + + # Check what follows: episode markers, bracket metadata, or end + has_episode_after = False + has_metadata_bracket_after = False + for j in range(close_idx + 1, len(token_list)): + t = token_list[j] + if Tag.DELIMITER in t.possibilities: + continue + if Tag.SEQUENCE_PREFIX in t.possibilities: + has_episode_after = True + if Tag.SEQUENCE_NUMBER in t.possibilities: + has_episode_after = True + if Tag.BRACKET in t.possibilities and t.content in constant.OPEN_BRACKETS: + has_metadata_bracket_after = True + break + + # Allow bracket title when episodes follow, or when only metadata brackets follow + # (e.g., [group][title][480p][v0]) + if not has_episode_after and not has_metadata_bracket_after: + continue + + # Tag inside tokens as title — use high scores to override false metadata + for t in inside: + if not t.possibilities: + t.add_possibility(Tag.SERIES_TITLE, base_score=2.0) + elif Tag.BRACKET in t.possibilities: + t.add_possibility(Tag.SERIES_TITLE, base_score=0.5) + elif Tag.DELIMITER in t.possibilities and (not t.content.strip() or t.content == constant.UNDERSCORE): + t.add_possibility(Tag.SERIES_TITLE, base_score=0.5) + else: + # Override any metadata (audio_term, sequence_number, release_group, etc.) + t.add_possibility(Tag.SERIES_TITLE, base_score=2.0) + # Also tag the bracket tokens themselves for compose to handle + token_list[open_idx].add_possibility(Tag.SERIES_TITLE, base_score=0.5) + token_list[close_idx].add_possibility(Tag.SERIES_TITLE, base_score=0.5) + return # only one bracket title + + @classmethod + def _detect_bracket_episode_title(cls, tokens): + """Detect episode title inside brackets immediately after episode number. + + Pattern: ... 13v0 (uncensored director's cut) [metadata] ... + If a bracket group right after the last episode contains mostly unknown tokens, + tag them as episode title. + """ + token_list = tokens.tokens + # Find last SEQUENCE_NUMBER outside brackets + last_seq_idx = -1 + in_bracket = False + for i, token in enumerate(token_list): + if Tag.BRACKET in token.possibilities: + if token.content in constant.OPEN_BRACKETS: + in_bracket = True + elif token.content in constant.CLOSE_BRACKETS: + in_bracket = False + continue + if in_bracket: + continue + if Tag.SEQUENCE_NUMBER in token.possibilities: + last_seq_idx = i + + if last_seq_idx < 0: + return + + # Already has episode title from _detect_episode_title? Skip. + for token in token_list[last_seq_idx + 1:]: + if Tag.TITLE in token.possibilities: + poss = token.possibilities.get(Tag.TITLE) + if poss and poss.descriptor == Tag.SEQUENCE_TITLE: + return + + # Find first bracket group after last episode (skip delimiters, release_version) + open_idx = None + for i in range(last_seq_idx + 1, len(token_list)): + t = token_list[i] + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + continue + if Tag.RELEASE_VERSION in t.possibilities: + continue + if Tag.BRACKET in t.possibilities and t.content in constant.OPEN_BRACKETS: + open_idx = i + break + break # non-bracket, non-delimiter found → no bracket ep title + + if open_idx is None: + return + + # Collect tokens inside this bracket + inside = [] + close_idx = None + expected_close = constant.BRACKET_PAIRS.get(token_list[open_idx].content) + for j in range(open_idx + 1, len(token_list)): + t = token_list[j] + if Tag.BRACKET in t.possibilities and t.content == expected_close: + close_idx = j + break + inside.append(t) + + if not inside or close_idx is None: + return + + # Only parenthesized brackets, not square/curly (those are metadata/release_group) + if token_list[open_idx].content not in constant.OPEN_PARENS: + return + + # Check: all non-delimiter tokens must be unknown or RELEASE_INFORMATION + # (no video_term, audio_term, source, resolution, sequence_number, etc.) + _hard_metadata = {Tag.VIDEO_TERM, Tag.AUDIO_TERM, Tag.SOURCE, Tag.VIDEO_RESOLUTION, + Tag.FILE_CHECKSUM, Tag.SEQUENCE_NUMBER, Tag.SEQUENCE_PREFIX, + Tag.LANGUAGE, Tag.SUBS_TERM} + unknown_count = 0 + for ti, t in enumerate(inside): + if Tag.DELIMITER in t.possibilities: + continue + # Possessive 's after apostrophe is not real metadata + is_possessive_s = (Tag.SEQUENCE_PREFIX in t.possibilities + and len(t.content) == 1 and ti > 0 + and inside[ti - 1].content == constant.APOSTROPHE) + if not is_possessive_s and t.possibilities.keys() & _hard_metadata: + return # has real metadata → not an episode title + unknown_count += 1 + + if unknown_count == 0: + return + + # Skip if all inside tokens already have RELEASE_GROUP possibility + # (the bracket is likely a release group, not episode title) + all_rg = all( + Tag.RELEASE_GROUP in t.possibilities or Tag.DELIMITER in t.possibilities + for t in inside + ) + if all_rg: + return + + # Require at least one RELEASE_INFORMATION token to distinguish from release groups + # (episode titles in brackets usually have words like "uncensored", "uncut", etc.) + has_release_info = any(Tag.RELEASE_INFORMATION in t.possibilities for t in inside) + if not has_release_info: + return + + # Tag all inside tokens as episode title + for ti, t in enumerate(inside): + if not t.possibilities: + t.add_possibility(Tag.SEQUENCE_TITLE, base_score=1.0) + elif Tag.RELEASE_INFORMATION in t.possibilities: + t.add_possibility(Tag.SEQUENCE_TITLE, base_score=2.0) + elif Tag.DELIMITER in t.possibilities: + t.add_possibility(Tag.SEQUENCE_TITLE, base_score=0.5) + elif (Tag.SEQUENCE_PREFIX in t.possibilities and len(t.content) == 1 + and ti > 0 and inside[ti - 1].content == constant.APOSTROPHE): + # Possessive 's — include in episode title + t.add_possibility(Tag.SEQUENCE_TITLE, base_score=2.0) diff --git a/aniparse/rule/possibilities/year_rule.py b/aniparse/rule/possibilities/year_rule.py new file mode 100644 index 0000000..f937343 --- /dev/null +++ b/aniparse/rule/possibilities/year_rule.py @@ -0,0 +1,48 @@ +from aniparse.core import constant +from aniparse.abstraction.parser_base import PossibilityRule, AbstractParser +from aniparse.core.token_tags import Tag + + +class YearPossibilityRule(PossibilityRule): + """Boost SERIES_YEAR score for numbers alone inside parentheses.""" + + @classmethod + def apply(cls, parser: AbstractParser): + tokens = parser.tokens + token_list = list(tokens) + + for i, token in enumerate(token_list): + if Tag.BRACKET not in token.possibilities: + continue + if token.content not in constant.OPEN_BRACKETS: + continue + + # Collect tokens inside bracket until close + inside_tokens = [] + close_idx = None + expected_close = constant.BRACKET_PAIRS.get(token.content) + for j in range(i + 1, len(token_list)): + t = token_list[j] + if Tag.BRACKET in t.possibilities and t.content == expected_close: + close_idx = j + break + inside_tokens.append(t) + + if close_idx is None or not inside_tokens: + continue + + # Check if there's exactly one non-delimiter token inside + non_delim = [t for t in inside_tokens + if Tag.DELIMITER not in t.possibilities + and Tag.CONTEXT_DELIMITER not in t.possibilities] + + if len(non_delim) != 1: + continue + + candidate = non_delim[0] + if not candidate.content.isdigit(): + continue + + val = int(candidate.content) + if parser.config.year_min <= val <= parser.config.year_max: + candidate.add_possibility(Tag.SERIES_YEAR, base_score=2.0) diff --git a/aniparse/rule/possibilities/zone_assignment_rule.py b/aniparse/rule/possibilities/zone_assignment_rule.py new file mode 100644 index 0000000..794ca0d --- /dev/null +++ b/aniparse/rule/possibilities/zone_assignment_rule.py @@ -0,0 +1,176 @@ +from aniparse.core import constant +from aniparse.core.constant import ( + ZONE_TITLE, ZONE_TRANSITION, ZONE_METADATA, BASE_METADATA_LABELS, +) +from aniparse.abstraction.parser_base import PossibilityRule, AbstractParser +from aniparse.core.token_tags import Tag + +METADATA_LABELS = BASE_METADATA_LABELS | { + Tag.RELEASE_INFORMATION, Tag.RELEASE_VERSION_PREFIX, Tag.EXTRA_INFO, +} + + +class ZoneAssignmentPossibilityRule(PossibilityRule): + @classmethod + def apply(cls, parser: AbstractParser): + tokens = parser.tokens.tokens + + # Pass 1: assign zones + zone = ZONE_TITLE + depth = 0 + seen_non_bracket = False # have we seen any non-bracket content yet? + zone_stack = [] # save zone before entering brackets + + for i, token in enumerate(tokens): + c = token.content + + if c in constant.OPEN_BRACKETS: + token.zone = ZONE_TRANSITION + zone_stack.append(zone) + depth += 1 + continue + if c in constant.CLOSE_BRACKETS: + token.zone = ZONE_TRANSITION + depth -= 1 + if depth <= 0: + depth = 0 + # Restore pre-bracket zone + zone = zone_stack.pop() if zone_stack else ZONE_TITLE + # If we haven't seen non-bracket content yet, stay in title zone + if not seen_non_bracket: + zone = ZONE_TITLE + continue + + if depth > 0: + token.zone = ZONE_METADATA + continue + + # Track that we've seen non-bracket content + if not (len(c) == 1 and c in constant.DELIMITERS): + seen_non_bracket = True + + # Context delimiter — check if followed by metadata + if Tag.CONTEXT_DELIMITER in token.possibilities: + if cls._followed_by_metadata(tokens, i): + token.zone = ZONE_TRANSITION + zone = ZONE_METADATA + elif zone == ZONE_TITLE and cls._followed_by_comma_list(tokens, i): + token.zone = ZONE_TRANSITION + zone = ZONE_METADATA + else: + token.zone = zone # title-internal dash + continue + + # SEQUENCE_PREFIX + number → structural boundary + if Tag.SEQUENCE_PREFIX in token.possibilities and cls._has_following_number(tokens, i): + token.zone = ZONE_METADATA + zone = ZONE_METADATA + continue + + token.zone = zone + + # Pass 2: clean up possibilities based on zones + for i, token in enumerate(tokens): + # TYPE/SEQUENCE_PREFIX followed by number → boost to beat LANGUAGE + if (Tag.LANGUAGE in token.possibilities + and (Tag.SEQUENCE_PREFIX in token.possibilities + or Tag.TYPE in token.possibilities) + and cls._has_following_number(tokens, i)): + if Tag.SEQUENCE_PREFIX in token.possibilities: + token.add_score(Tag.SEQUENCE_PREFIX, 1.0) + if Tag.TYPE in token.possibilities: + token.add_score(Tag.TYPE, 1.0) + # SEQUENCE_PREFIX without following number → remove prefix + # But only for season/episode/volume prefixes, not content types (e.g. "clean ending") + if (token.zone in (ZONE_TITLE, ZONE_METADATA) + and Tag.SEQUENCE_PREFIX in token.possibilities + and not cls._has_following_number(tokens, i)): + poss = token.possibilities[Tag.SEQUENCE_PREFIX] + if poss.descriptor in (Tag.SEASON, Tag.EPISODE, Tag.VOLUME): + token.remove_possibility(Tag.SEQUENCE_PREFIX) + + # RELEASE_INFORMATION in title zone → remove unless preceded by number + # or confirmed by regex (score >= 1.5) + if (token.zone == ZONE_TITLE + and Tag.RELEASE_INFORMATION in token.possibilities): + if (not cls._preceded_by_number(tokens, i) + and token.possibilities[Tag.RELEASE_INFORMATION].score < 1.5): + token.remove_possibility(Tag.RELEASE_INFORMATION) + + @staticmethod + def _followed_by_metadata(tokens, idx): + """Check if tokens after idx (skipping delimiters) start with metadata.""" + for j in range(idx + 1, len(tokens)): + t = tokens[j] + if len(t.content) == 1 and t.content in constant.DELIMITERS: + continue + # Brackets count as metadata boundary + if t.content in constant.OPEN_BRACKETS: + return True + # Check for SEQUENCE_PREFIX — content types count as metadata directly + if Tag.SEQUENCE_PREFIX in t.possibilities: + poss = t.possibilities[Tag.SEQUENCE_PREFIX] + if poss.descriptor in (Tag.CONTENT_TYPE, Tag.CONTENT_IDENTIFIER): + return True + # Season/episode/volume need a following number + for k in range(j + 1, len(tokens)): + t2 = tokens[k] + if len(t2.content) == 1 and t2.content in constant.DELIMITERS: + continue + return Tag.SEQUENCE_NUMBER in t2.possibilities + return False + # Bare SEQUENCE_NUMBER + if Tag.SEQUENCE_NUMBER in t.possibilities: + return True + # Any metadata label + for label in t.possibilities: + if label in METADATA_LABELS: + return True + return False + return False + + @staticmethod + def _has_following_number(tokens, idx): + """Check if token at idx is followed by a SEQUENCE_NUMBER (skipping delimiters). + Returns False if the number is more likely a VIDEO_RESOLUTION.""" + for j in range(idx + 1, len(tokens)): + t = tokens[j] + if len(t.content) == 1 and t.content in constant.DELIMITERS: + continue + if Tag.SEQUENCE_NUMBER not in t.possibilities: + return False + # If VIDEO_RESOLUTION is at least as strong, this isn't an episode number + vr = t.possibilities.get(Tag.VIDEO_RESOLUTION) + sn = t.possibilities.get(Tag.SEQUENCE_NUMBER) + if vr and sn and vr.score >= sn.score: + return False + return True + return False + + @staticmethod + def _followed_by_comma_list(tokens, idx): + """Check if text after idx contains commas before the next context delimiter or bracket.""" + comma_count = 0 + word_count = 0 + for j in range(idx + 1, len(tokens)): + t = tokens[j] + if t.content in constant.OPEN_BRACKETS or t.content in constant.CLOSE_BRACKETS: + break + if (Tag.CONTEXT_DELIMITER in t.possibilities + and Tag.DELIMITER not in t.possibilities): + break # hit a structural dash + if t.content == constant.COMMA: + comma_count += 1 + elif len(t.content) > 1 and t.content.isalpha(): + word_count += 1 + return comma_count >= 2 and word_count >= 3 + + @staticmethod + def _preceded_by_number(tokens, idx): + """Check if token at idx is preceded by a number (skipping delimiters).""" + for j in range(idx - 1, -1, -1): + t = tokens[j] + if len(t.content) == 1 and t.content in constant.DELIMITERS: + continue + return t.content.isdigit() + return False diff --git a/aniparse/rule/scoring/__init__.py b/aniparse/rule/scoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aniparse/rule/scoring/audio_term_score_rule.py b/aniparse/rule/scoring/audio_term_score_rule.py new file mode 100644 index 0000000..ac81e7f --- /dev/null +++ b/aniparse/rule/scoring/audio_term_score_rule.py @@ -0,0 +1,7 @@ +from aniparse.abstraction.score_base import MetadataScoreRule +from aniparse.core.token_tags import Tag + + +class AudioTermScoreRule(MetadataScoreRule): + descriptorType = Tag.AUDIO_TERM + skip_labels = {Tag.FILE_CHECKSUM} diff --git a/aniparse/rule/scoring/contextual_score_rule.py b/aniparse/rule/scoring/contextual_score_rule.py new file mode 100644 index 0000000..8125341 --- /dev/null +++ b/aniparse/rule/scoring/contextual_score_rule.py @@ -0,0 +1,126 @@ +"""Score adjustments based on token rhythm and surrounding context. + +Key principle: context delimiters (dashes) are the strongest structural signal. +A keyword token flowing in title-zone text without structural separation is +likely part of the title, not metadata. +""" +from aniparse.core.constant import ZONE_TITLE, ZONE_METADATA +from aniparse.abstraction.score_base import ScoreRule +from aniparse.core.token import Token, Tokens +from aniparse.core.token_tags import Tag + +# Labels that can be false positives in title context +AMBIGUOUS_IN_TITLE = { + Tag.LANGUAGE, Tag.SUBS_TERM, Tag.RELEASE_INFORMATION, + Tag.EXTRA_INFO, +} + + +class ContextualScoreRule(ScoreRule): + """Penalize ambiguous metadata labels when token is in title flow. + + A token is in "title flow" when: + - It's in the title zone + - Its neighbors (skipping delimiters) are unknown/title tokens + - No context delimiter separates it from those neighbors + """ + descriptorType = Tag.LANGUAGE + categoryType = Tag.LANGUAGE + + @classmethod + def apply(cls, token: Token, tokens: Tokens): + # Boost LANGUAGE when neighbor tokens also have LANGUAGE possibility (metadata context) + if (token.zone == ZONE_METADATA + and Tag.LANGUAGE in token.possibilities): + neighbor_lang_count = 0 + for t in tokens.loop_backward(token): + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + continue + if Tag.BRACKET in t.possibilities: + break + if Tag.LANGUAGE in t.possibilities: + neighbor_lang_count += 1 + break + for t in tokens.loop_forward(token): + if Tag.DELIMITER in t.possibilities or Tag.CONTEXT_DELIMITER in t.possibilities: + continue + if Tag.BRACKET in t.possibilities: + break + if Tag.LANGUAGE in t.possibilities: + neighbor_lang_count += 1 + break + if neighbor_lang_count > 0: + token.add_score(Tag.LANGUAGE, 0.5 * neighbor_lang_count) + + # Only act on ambiguous labels in title zone + if token.zone != ZONE_TITLE: + return + + ambiguous = token.possibilities.keys() & AMBIGUOUS_IN_TITLE + if not ambiguous: + return + + # Check if token is in title flow: surrounded by title/unknown tokens + # with no context delimiter between + prev_ctx = cls._prev_meaningful(token, tokens) + next_ctx = cls._next_meaningful(token, tokens) + + prev_is_title = (prev_ctx and ( + not prev_ctx.possibilities # unknown + or Tag.TITLE in prev_ctx.possibilities + or Tag.SERIES_TITLE in prev_ctx.possibilities + )) + next_is_title = (next_ctx and ( + not next_ctx.possibilities + or Tag.TITLE in next_ctx.possibilities + or Tag.SERIES_TITLE in next_ctx.possibilities + or Tag.SEQUENCE_NUMBER in next_ctx.possibilities # number following title + )) + + # Check for context delimiter between this token and neighbors + has_context_delim_before = cls._has_context_delim_between(token, tokens, backward=True) + has_context_delim_after = cls._has_context_delim_between(token, tokens, backward=False) + + if prev_is_title and not has_context_delim_before: + # Token flows from title without structural break → penalize metadata + for label in ambiguous: + token.add_score(label, -1.5) + + if next_is_title and not has_context_delim_after: + # Token flows into title without structural break → penalize metadata + for label in ambiguous: + token.add_score(label, -1.0) + + @staticmethod + def _prev_meaningful(token: Token, tokens: Tokens): + """Find previous non-delimiter token.""" + for t in tokens.loop_backward(token): + if Tag.DELIMITER in t.possibilities: + continue + if Tag.BRACKET in t.possibilities: + return None + return t + return None + + @staticmethod + def _next_meaningful(token: Token, tokens: Tokens): + """Find next non-delimiter token.""" + for t in tokens.loop_forward(token): + if Tag.DELIMITER in t.possibilities: + continue + if Tag.BRACKET in t.possibilities: + return None + return t + return None + + @staticmethod + def _has_context_delim_between(token: Token, tokens: Tokens, backward: bool): + """Check if there's a context delimiter between this token and its neighbor.""" + loop = tokens.loop_backward(token) if backward else tokens.loop_forward(token) + for t in loop: + if Tag.DELIMITER in t.possibilities: + continue + if Tag.CONTEXT_DELIMITER in t.possibilities: + return True + return False + return False diff --git a/aniparse/rule/scoring/file_checksum_score_rule.py b/aniparse/rule/scoring/file_checksum_score_rule.py new file mode 100644 index 0000000..af8fb1b --- /dev/null +++ b/aniparse/rule/scoring/file_checksum_score_rule.py @@ -0,0 +1,44 @@ +from aniparse.core import constant +from aniparse.abstraction.score_base import MetadataScoreRule +from aniparse.core.token_tags import Tag +from aniparse.core.token import Token, Tokens + + +class FileChecksumScoreRule(MetadataScoreRule): + descriptorType = Tag.FILE_CHECKSUM + skip_labels = set() + + @classmethod + def apply(cls, token: Token, tokens: Tokens): + super().apply(token, tokens) + cls._boost_checksum_neighbors(token, tokens) + + VALID_CHECKSUM_LENGTHS = {8} # CRC32 + + @classmethod + def _boost_checksum_neighbors(cls, token: Token, tokens: Tokens): + """Boost score when adjacent tokens also have FILE_CHECKSUM and concatenated length is valid.""" + # Collect the full contiguous FILE_CHECKSUM span by walking both directions + span = [token] + for neighbor in tokens.loop_backward(token): + if Tag.DELIMITER in neighbor.possibilities: + continue + if Tag.FILE_CHECKSUM in neighbor.possibilities: + span.insert(0, neighbor) + continue + break + for neighbor in tokens.loop_forward(token): + if Tag.DELIMITER in neighbor.possibilities: + continue + if Tag.FILE_CHECKSUM in neighbor.possibilities: + span.append(neighbor) + continue + break + + if len(span) > 1: + concat_len = sum(len(t.content) for t in span) + if concat_len in cls.VALID_CHECKSUM_LENGTHS: + # Strong boost — checksum with valid CRC32 length + token.add_score(cls.categoryType, 2.0) + else: + token.add_score(cls.categoryType, -0.5) diff --git a/aniparse/rule/scoring/file_index_score_rule.py b/aniparse/rule/scoring/file_index_score_rule.py new file mode 100644 index 0000000..be0032e --- /dev/null +++ b/aniparse/rule/scoring/file_index_score_rule.py @@ -0,0 +1,109 @@ +from aniparse.abstraction.score_base import ScoreRule +from aniparse.core.token_tags import Tag +from aniparse.core.token import Token, Tokens + + +class FileIndexRule(ScoreRule): + descriptorType = Tag.FILE_INDEX + + @classmethod + def apply(cls, token: Token, tokens: Tokens): + if not token.content.isdigit(): + return + + cls.apply_previous_token_rules(token, tokens) + cls.apply_next_token_rules(token, tokens) + + @classmethod + def apply_previous_token_rules(cls, token: Token, tokens: Tokens): + for prev_token in tokens.loop_backward(token): + if Tag.BRACKET in prev_token.possibilities: + continue + if Tag.CONTEXT_DELIMITER in prev_token.possibilities: + continue + if Tag.DELIMITER in prev_token.possibilities: + continue + break + else: + last_delimiter = None + first_next_token = None + second_next_token = None + same_delimiter_in_a_row = False + + for next_token in tokens.loop_forward(token): + if not first_next_token: + first_next_token = next_token + if Tag.DELIMITER in next_token.possibilities: + last_delimiter = next_token.content + continue + + if Tag.DELIMITER in next_token.possibilities: + if next_token.content == last_delimiter: + same_delimiter_in_a_row = True + else: + token.add_score(cls.categoryType, 0.5) + last_delimiter = next_token.content + same_delimiter_in_a_row = False + else: + if same_delimiter_in_a_row: + token.add_score(cls.categoryType, 0.25) + + if not second_next_token: + second_next_token = next_token + if Tag.BRACKET in next_token.possibilities: + token.add_score(cls.categoryType, 0.5) + break + if Tag.CONTEXT_DELIMITER in next_token.possibilities: + token.add_score(cls.categoryType, 0.25) + last_delimiter = next_token.content + continue + + if Tag.DELIMITER not in next_token.possibilities: + break + + @classmethod + def apply_next_token_rules(cls, token: Token, tokens: Tokens): + for next_token in tokens.loop_forward(token): + if Tag.BRACKET in next_token.possibilities: + continue + if Tag.CONTEXT_DELIMITER in next_token.possibilities: + continue + if Tag.DELIMITER in next_token.possibilities: + continue + break + else: + last_delimiter = None + first_prev_token = None + second_prev_token = None + same_delimiter_in_a_row = False + for prev_token in tokens.loop_backward(token): + if not first_prev_token: + first_prev_token = prev_token + if Tag.DELIMITER in prev_token.possibilities: + last_delimiter = prev_token.content + continue + + if Tag.DELIMITER in prev_token.possibilities: + if prev_token.content == last_delimiter: + same_delimiter_in_a_row = True + else: + token.add_score(cls.categoryType, 0.25) + last_delimiter = prev_token.content + same_delimiter_in_a_row = False + continue + else: + if same_delimiter_in_a_row: + token.add_score(cls.categoryType, 0.25) + + if not second_prev_token: + second_prev_token = prev_token + if Tag.BRACKET in prev_token.possibilities: + token.add_score(cls.categoryType, 0.5) + break + if Tag.CONTEXT_DELIMITER in prev_token.possibilities: + token.add_score(cls.categoryType, 0.25) + last_delimiter = prev_token.content + continue + + if Tag.DELIMITER not in prev_token.possibilities: + break diff --git a/aniparse/rule/scoring/positional_score_rule.py b/aniparse/rule/scoring/positional_score_rule.py new file mode 100644 index 0000000..7e4d3de --- /dev/null +++ b/aniparse/rule/scoring/positional_score_rule.py @@ -0,0 +1,40 @@ +from aniparse.abstraction.score_base import ScoreRule +from aniparse.core.token import Token, Tokens +from aniparse.core.token_tags import Tag + +# Labels that represent sequence/episode metadata +SEQUENCE_LABELS = {Tag.SEQUENCE_NUMBER, Tag.SEQUENCE_PREFIX} + + +class PositionalScoreRule(ScoreRule): + """Adjust scores based on where a token sits in the filename structure. + + Uses bracket_group info from rhythm analysis to boost/dampen possibilities + based on positional statistics: + - First bracket at position 0 strongly correlates with release group + - Sequence numbers/prefixes are very unlikely in the first bracket + - Release groups are unlikely in non-first brackets + """ + descriptorType = Tag.RELEASE_GROUP + categoryType = Tag.RELEASE_GROUP + + @classmethod + def apply(cls, token: Token, tokens: Tokens): + bg = token.bracket_group + if bg is None: + return + + if bg.starts_filename and bg.is_first: + # First bracket at position 0: statistically release group + token.add_score(Tag.RELEASE_GROUP, 1.0) + # Dampen sequence labels — very unlikely here + for label in SEQUENCE_LABELS: + if label in token.possibilities: + token.add_score(label, -1.0) + # Dampen year — unlikely in first bracket + if Tag.YEAR in token.possibilities: + token.add_score(Tag.YEAR, -0.5) + else: + # Non-first brackets: release group is less likely + if Tag.RELEASE_GROUP in token.possibilities: + token.add_score(Tag.RELEASE_GROUP, -0.5) diff --git a/aniparse/rule/scoring/sequence_number_score_rule.py b/aniparse/rule/scoring/sequence_number_score_rule.py new file mode 100644 index 0000000..be64303 --- /dev/null +++ b/aniparse/rule/scoring/sequence_number_score_rule.py @@ -0,0 +1,125 @@ +from aniparse.abstraction.score_base import ScoreRule +from aniparse.core.token_tags import Tag +from aniparse.core.token import Tokens, Token + + +class EpisodeNumberScore(ScoreRule): + descriptorType = Tag.SEQUENCE_NUMBER + + @classmethod + def apply(cls, start_token, tokens): + if not start_token.content.isdigit(): + return + + # If this number is part of a regex-matched video/audio term (base_score >= 1.5), + # it's likely "8" in "8-bit" or "5.1" in "5.1ch" — reduce sequence scoring + for alt_label in (Tag.VIDEO_TERM, Tag.AUDIO_TERM): + poss = start_token.possibilities.get(alt_label) + if poss and poss.score >= 1.5: + # Still apply rules but with reduced impact — skip bracket bonus + cls.apply_previous_token_rules(start_token, tokens) + cls.apply_next_token_rules(start_token, tokens) + return + + cls.apply_in_bracket_rules(start_token, tokens) + cls.apply_previous_token_rules(start_token, tokens) + cls.apply_next_token_rules(start_token, tokens) + + @classmethod + def apply_in_bracket_rules(cls, start_token: Token, tokens: Tokens): + for prev_token in tokens.loop_backward(start_token): + if Tag.BRACKET in prev_token.possibilities: + for next_token in tokens.loop_forward(start_token): + if Tag.BRACKET in next_token.possibilities: + start_token.add_score(cls.categoryType, 0.25) + break + if Tag.DELIMITER not in next_token.possibilities: + break + start_token.add_score(cls.categoryType, 0.25) + break + if Tag.DELIMITER not in prev_token.possibilities: + break + + @classmethod + def apply_previous_token_rules(cls, start_token: Token, tokens: Tokens): + for prev_token in tokens.loop_backward(start_token): + if Tag.BRACKET in prev_token.possibilities: + start_token.add_score(cls.categoryType, 0.25) + break + + if (Tag.CONTEXT_DELIMITER in prev_token.possibilities + and Tag.SEQUENCE_RANGE not in prev_token.possibilities): + start_token.add_score(cls.categoryType, 0.5) + + if Tag.SEQUENCE_PREFIX in prev_token.possibilities: + start_token.add_score(cls.categoryType, 1) + prev_token.add_score(Tag.SEQUENCE_PREFIX, 0.5) + break # Stop walking after finding prefix + + if Tag.TYPE in prev_token.possibilities: + # Only boost if TYPE doesn't also have a title possibility + if Tag.TITLE not in prev_token.possibilities: + start_token.add_score(cls.categoryType, 1) + break # Number after TYPE (e.g. "movie 02") + + if Tag.SEQUENCE_RANGE in prev_token.possibilities: + # If this number has TITLE possibility, the range might be in the title + # (e.g., "class 5-2") — apply reduced scoring + title_poss = start_token.possibilities.get(Tag.TITLE) + range_boost = 0.25 if not title_poss else 0.1 + for next_token in tokens.loop_forward(start_token): + if Tag.BRACKET in next_token.possibilities: + start_token.add_score(cls.categoryType, range_boost) + + if Tag.CONTEXT_DELIMITER in next_token.possibilities: + start_token.add_score(cls.categoryType, range_boost) + break + + if Tag.DELIMITER not in next_token.possibilities: + break + else: + start_token.add_score(cls.categoryType, range_boost) + prev_token.add_score(Tag.SEQUENCE_RANGE, 0.5) + + if (Tag.DELIMITER not in prev_token.possibilities + and Tag.SEQUENCE_PREFIX not in prev_token.possibilities): + break + + @classmethod + def apply_next_token_rules(cls, start_token: Token, tokens: Tokens): + for next_token in tokens.loop_forward(start_token): + + if Tag.BRACKET in next_token.possibilities: + start_token.add_score(cls.categoryType, 0.25) + break + + if (Tag.CONTEXT_DELIMITER in next_token.possibilities + and Tag.SEQUENCE_RANGE not in next_token.possibilities): + # Reduce boost if this number is also a title candidate + title_poss = start_token.possibilities.get(Tag.TITLE) + boost = 0.5 if title_poss else 1 + start_token.add_score(cls.categoryType, boost) + + if Tag.SEQUENCE_RANGE in next_token.possibilities: + for next_next_token in tokens.loop_forward(next_token): + if Tag.BRACKET in next_next_token.possibilities: + break + + if Tag.SEQUENCE_NUMBER in next_next_token.possibilities: + # If the range endpoint also has TITLE possibility, + # this may be a title like "009-1" — reduce boost + title_poss = start_token.possibilities.get(Tag.TITLE) + end_title = next_next_token.possibilities.get(Tag.TITLE) + if title_poss and end_title and end_title.score >= 1.0: + start_token.add_score(cls.categoryType, 0.25) + else: + start_token.add_score(cls.categoryType, 1) + next_token.add_score(Tag.SEQUENCE_RANGE, 0.5) + next_next_token.add_score(Tag.SEQUENCE_NUMBER, 0.5) + break + + if Tag.DELIMITER not in next_next_token.possibilities: + break + + if Tag.DELIMITER not in next_token.possibilities: + break diff --git a/aniparse/rule/scoring/series_year_score_rule.py b/aniparse/rule/scoring/series_year_score_rule.py new file mode 100644 index 0000000..db3b063 --- /dev/null +++ b/aniparse/rule/scoring/series_year_score_rule.py @@ -0,0 +1,66 @@ +from aniparse.abstraction.score_base import ScoreRule +from aniparse.core.token_tags import Tag +from aniparse.core.token import Tokens, Token + + +class SeriesYearScore(ScoreRule): + descriptorType = Tag.SERIES_YEAR + + @classmethod + def apply(cls, token: Token, tokens: Tokens): + if not token.content.isdigit(): + return + # Year range is already gated by NumberRule/YearRule via config; + # if SERIES_YEAR possibility exists, the value is in range. + token.add_score(cls.categoryType, 1.0) + cls.apply_in_bracket_rules(token, tokens) + cls.between_context_delimiter(token, tokens) + cls.apply_alone_in_brackets(token, tokens) + + @classmethod + def apply_alone_in_brackets(cls, start_token: Token, tokens: Tokens): + """Strong bonus when a year-range number is alone in brackets, e.g. (1994).""" + # Check backward: only delimiters until bracket + found_open = False + for prev_token in tokens.loop_backward(start_token): + if Tag.BRACKET in prev_token.possibilities: + found_open = True + break + if Tag.DELIMITER not in prev_token.possibilities: + return + if not found_open: + return + # Check forward: only delimiters until bracket + for next_token in tokens.loop_forward(start_token): + if Tag.BRACKET in next_token.possibilities: + start_token.add_score(cls.categoryType, 1.5) + return + if Tag.DELIMITER not in next_token.possibilities: + return + + @classmethod + def apply_in_bracket_rules(cls, start_token: Token, tokens: Tokens): + for prev_token in tokens.loop_backward(start_token): + if Tag.BRACKET in prev_token.possibilities: + for next_token in tokens.loop_forward(start_token): + if Tag.BRACKET in next_token.possibilities: + start_token.add_score(cls.categoryType, 0.5) + break + if Tag.DELIMITER not in next_token.possibilities: + break + break + if Tag.DELIMITER not in prev_token.possibilities: + break + + @classmethod + def between_context_delimiter(cls, start_token: Token, tokens: Tokens): + for prev_token in tokens.loop_backward(start_token): + if Tag.CONTEXT_DELIMITER in prev_token.possibilities: + start_token.add_score(cls.categoryType, 0.5) + if Tag.DELIMITER not in prev_token.possibilities: + break + for next_token in tokens.loop_forward(start_token): + if Tag.CONTEXT_DELIMITER in next_token.possibilities: + start_token.add_score(cls.categoryType, 0.5) + if Tag.DELIMITER not in next_token.possibilities: + break diff --git a/aniparse/rule/scoring/video_resolution_score_rule.py b/aniparse/rule/scoring/video_resolution_score_rule.py new file mode 100644 index 0000000..1d56bfc --- /dev/null +++ b/aniparse/rule/scoring/video_resolution_score_rule.py @@ -0,0 +1,158 @@ +from math import gcd + +from aniparse.abstraction.score_base import ScoreRule +from aniparse.core.token_tags import Tag +from aniparse.core.token import Token, Tokens +from aniparse.core import constant +from aniparse.core.constant import COMMON_RESOLUTIONS + + +class VideoResolutionScoreRule(ScoreRule): + descriptorType = Tag.VIDEO_RESOLUTION + + @classmethod + def apply(cls, token: Token, tokens: Tokens): + if not token.content.isdigit(): + return + + video_resolution_tokens = cls.get_video_resolution_tokens(token, tokens) + if video_resolution_tokens: + cls.apply_common_patterns(video_resolution_tokens) + cls.check_neighbor_tokens(token, tokens, video_resolution_tokens) + + @classmethod + def check_neighbor_tokens(cls, start_token: Token, tokens: Tokens, video_resolution_tokens: list[Token]): + # Strong boost for common resolutions inside brackets (e.g. [720], [1080]) + if start_token.content.isdigit() and int(start_token.content) in COMMON_RESOLUTIONS: + prev_is_bracket = False + next_is_bracket = False + for pt in tokens.loop_backward(start_token): + if Tag.DELIMITER in pt.possibilities: + continue + if Tag.BRACKET in pt.possibilities: + prev_is_bracket = True + break + for nt in tokens.loop_forward(start_token): + if Tag.DELIMITER in nt.possibilities: + continue + if Tag.BRACKET in nt.possibilities: + next_is_bracket = True + break + if prev_is_bracket and next_is_bracket: + for t in video_resolution_tokens: + t.add_score(cls.categoryType, 1.0) + + for prev_token in tokens.loop_backward(start_token): + if Tag.BRACKET in prev_token.possibilities: + for t in video_resolution_tokens: + t.add_score(cls.categoryType, 0.25) + break + if Tag.additional_video_information(prev_token.possibilities): + for t in video_resolution_tokens: + t.add_score(cls.categoryType, 0.25) + for possibility in prev_token.possibilities: + if Tag.additional_video_information([possibility]): + prev_token.add_score(possibility, 0.25) + break + if Tag.DELIMITER not in prev_token.possibilities: + break + + for next_token in tokens.loop_forward(start_token): + if Tag.BRACKET in next_token.possibilities: + for t in video_resolution_tokens: + t.add_score(cls.categoryType, 0.25) + break + if Tag.additional_video_information(next_token.possibilities): + for t in video_resolution_tokens: + t.add_score(cls.categoryType, 0.25) + for possibility in next_token.possibilities: + if Tag.additional_video_information([possibility]): + next_token.add_score(possibility, 0.25) + break + if Tag.DELIMITER not in next_token.possibilities: + break + + @classmethod + def apply_common_patterns(cls, video_resolution_tokens: list[Token]): + number_tokens = [t for t in video_resolution_tokens if t.content.isdigit()] + for t in number_tokens: + if int(t.content) > 100: + t.add_score(cls.categoryType, 0.25) + else: + t.add_score(cls.categoryType, -0.5) + if len(number_tokens) == 2: + a = int(number_tokens[0].content) + b = int(number_tokens[1].content) + divisor = gcd(a, b) + simplified_a = a // divisor + simplified_b = b // divisor + if simplified_a < simplified_b: + simplified_a, simplified_b = simplified_b, simplified_a + aspect_ratios = [ + (4, 3), (16, 9), (185, 100), (235, 100), + (239, 100), (240, 100), (21, 9), + ] + for aspect_ratio in aspect_ratios: + ratio = simplified_a / simplified_b + common_ratio_float = aspect_ratio[0] / aspect_ratio[1] + tolerance = 0.05 * common_ratio_float + error = abs(ratio - common_ratio_float) + if error <= tolerance: + for t in video_resolution_tokens: + t.add_score(Tag.VIDEO_RESOLUTION, round(0.25 * (1 - error), 2)) + break + + if not number_tokens: + return + + if len(video_resolution_tokens) == 1: + if int(number_tokens[0].content) > 100: + video_resolution_tokens[0].add_score(cls.categoryType, 0.25) + elif len(video_resolution_tokens) == 2: + if int(number_tokens[0].content) > 100: + video_resolution_tokens[0].add_score(cls.categoryType, 0.25) + if video_resolution_tokens[1].content in constant.VIDEO_RESOLUTION_SUFFIXES: + video_resolution_tokens[0].add_score(cls.categoryType, 0.5) + video_resolution_tokens[1].add_score(cls.categoryType, 0.5) + if video_resolution_tokens[1].content in constant.VIDEO_RESOLUTION_MULTIPLIER_SUFFIXES: + video_resolution_tokens[0].add_score(cls.categoryType, 0.25) + video_resolution_tokens[1].add_score(cls.categoryType, 0.25) + + @classmethod + def get_video_resolution_tokens(cls, start_token: Token, tokens: Tokens): + video_resolution_tokens = [] + for prev_token in tokens.loop_backward(start_token): + if (Tag.VIDEO_RESOLUTION not in prev_token.possibilities + and Tag.DELIMITER not in prev_token.possibilities): + break + if Tag.VIDEO_RESOLUTION in prev_token.possibilities: + video_resolution_tokens.append(prev_token) + video_resolution_tokens.reverse() + video_resolution_tokens.append(start_token) + for next_token in tokens.loop_forward(start_token): + if Tag.VIDEO_RESOLUTION not in next_token.possibilities: + break + video_resolution_tokens.append(next_token) + return video_resolution_tokens + + @classmethod + def apply_in_bracket_rules(cls, start_token: Token, tokens: Tokens): + potential_video_resolution_divider = True + for prev_token in tokens.loop_backward(start_token): + if Tag.BRACKET in prev_token.possibilities: + for next_token in tokens.loop_forward(start_token): + if Tag.BRACKET in next_token.possibilities: + start_token.add_score(cls.categoryType, 0.25) + break + if (potential_video_resolution_divider + and len(next_token.content) == 1 + and not next_token.content.isdigit()): + potential_video_resolution_divider = False + continue + if (Tag.DELIMITER not in next_token.possibilities + and not Tag.additional_video_information(next_token.possibilities)): + break + start_token.add_score(cls.categoryType, 0.25) + break + if Tag.DELIMITER not in prev_token.possibilities: + break diff --git a/aniparse/rule/scoring/video_term_score_rule.py b/aniparse/rule/scoring/video_term_score_rule.py new file mode 100644 index 0000000..bebef53 --- /dev/null +++ b/aniparse/rule/scoring/video_term_score_rule.py @@ -0,0 +1,7 @@ +from aniparse.abstraction.score_base import MetadataScoreRule +from aniparse.core.token_tags import Tag + + +class VideoTermScoreRule(MetadataScoreRule): + descriptorType = Tag.VIDEO_TERM + skip_labels = {Tag.FILE_CHECKSUM} diff --git a/aniparse/token.py b/aniparse/token.py deleted file mode 100644 index 379a751..0000000 --- a/aniparse/token.py +++ /dev/null @@ -1,309 +0,0 @@ -from enum import Enum -from typing import List, Union - -from aniparse.element import ElementCategory -from aniparse.keyword import KeywordManager - - -class TokenCategory(Enum): - # Auto enumerate elements - def __new__(cls): - value = len(cls.__members__) + 1 - obj = object.__new__(cls) - obj._value_ = value - return obj - - UNKNOWN = () - BRACKET = () - DELIMITER = () - IDENTIFIER = () - INVALID = () - - -class TokenFlags(int): - NONE = 0 - # Categories - BRACKET = 1 << 0 - NOT_BRACKET = 1 << 1 - DELIMITER = 1 << 2 - NOT_DELIMITER = 1 << 3 - IDENTIFIER = 1 << 4 - NOT_IDENTIFIER = 1 << 5 - UNKNOWN = 1 << 6 - NOT_UNKNOWN = 1 << 7 - VALID = 1 << 8 - NOT_VALID = 1 << 9 - # Enclosed - ENCLOSED = 1 << 10 - NOT_ENCLOSED = 1 << 11 - # Masks - MASK_CATEGORIES = ( - BRACKET | NOT_BRACKET | - DELIMITER | NOT_DELIMITER | - IDENTIFIER | NOT_IDENTIFIER | - UNKNOWN | NOT_UNKNOWN | - VALID | NOT_VALID - ) - MASK_ENCLOSED = ENCLOSED | NOT_ENCLOSED - - -class Token: - def __init__(self, content: str = None, t_category: TokenCategory = TokenCategory.UNKNOWN, - e_category: ElementCategory = ElementCategory.UNKNOWN, enclosed: bool = False): - self.t_category = t_category - self.content = content - self.enclosed = enclosed - self.e_category = e_category - - def __repr__(self): - return (f"Token(content = \"{self.content}\", category = {self.t_category}, " - f"element = {self.e_category}), enclosed = {self.enclosed}") - - def check_elements(self, elements: ElementCategory = None): - if elements: - return self.e_category in elements - return True - - # check if token is of a certain category - def check_flags(self, flags: int = None): - # Checking for flags is set for this token - # e.g. if we're looking key with flags IDENTIFIER, - # we compare the flags binary with the token's flags binary - # and check if the IDENTIFIER bit is set - if flags is None: - return True - - # local function to check if a flag is equal - def check_flag(flag: int): - return (flags & flag) == flag - - # if MASK_ENCLOSED is set, check if enclosed is equal to the flag - if flags & TokenFlags.MASK_ENCLOSED: - if not check_flag(TokenFlags.ENCLOSED if self.enclosed else TokenFlags.NOT_ENCLOSED): - return False - - # if MASK_CATEGORIES is set, check if category is equal to the flag - if flags & TokenFlags.MASK_CATEGORIES: - if check_flag(TokenFlags.BRACKET - if self.t_category == TokenCategory.BRACKET else TokenFlags.NOT_BRACKET): - return True - if check_flag(TokenFlags.DELIMITER - if self.t_category == TokenCategory.DELIMITER else TokenFlags.NOT_DELIMITER): - return True - if check_flag(TokenFlags.IDENTIFIER - if self.t_category == TokenCategory.IDENTIFIER else TokenFlags.NOT_IDENTIFIER): - return True - if check_flag(TokenFlags.UNKNOWN - if self.t_category == TokenCategory.UNKNOWN else TokenFlags.NOT_UNKNOWN): - return True - if check_flag(TokenFlags.VALID - if self.t_category != TokenCategory.INVALID else TokenFlags.NOT_VALID): - return True - return False - return True - - -class Tokens: - def __init__(self, keyword_manager: KeywordManager): - self.tokens = [] # type: List[Token] - self.counter = {} # type: (ElementCategory, int) - self.keyword_manager = keyword_manager - - def _add_token(self, category, content, enclosed, element=ElementCategory.UNKNOWN) -> Token: - new_token = Token(content, category, element, enclosed) - self.append(new_token) - return new_token - - def _validate_delimiter_tokens(self) -> None: - removed = {} - - def find_previous_valid_token(target_token) -> Token: - return self.find_prev(target_token, TokenFlags.VALID) - - def find_next_valid_token(target_token) -> Token: - return self.find_next(target_token, TokenFlags.VALID) - - def is_delimiter_token(target_token) -> bool: - return target_token is not None and \ - target_token.t_category == TokenCategory.DELIMITER - - def is_unknown_token(target_token) -> bool: - return target_token is not None and \ - target_token.t_category == TokenCategory.UNKNOWN - - def is_single_character_token(target_token) -> bool: - return is_unknown_token(target_token) and len(target_token.content) == 1 and \ - target_token.content != '-' - - def append_token_to(target_token, append_to) -> None: - if target_token in removed: - return - if append_to in removed: - append_to = removed[append_to] - append_to.content += target_token.content - # remove this token - removed[target_token] = append_to - - for token in self.get_list(): - - if token.t_category != TokenCategory.DELIMITER: - continue - - delimiter = token.content - if delimiter == ",": - continue - prev_token = find_previous_valid_token(token) - next_token = find_next_valid_token(token) - - # Check for single-character tokens to prevent splitting group - # names, keywords, episode number, etc. - # come back later - if delimiter != ' ' and delimiter != '_': - if is_single_character_token(prev_token): - if delimiter not in "&+": - # need to be here to make the ["D", ".", "C", ".", "II"] still in this format - # Otherwise it will be ["D.C", ".", "II"], - # Which not a big deal, but I dont know the effect to other part - # In the original code, it makes "A.Certain.Scientific.Railgun.S02." Into a single token. - # Which is not the one I wanted. - continue - if is_single_character_token(next_token): - # e.g. "." in "07.1", "." in "TrueHD5.1" - append_token_to(token, prev_token) - append_token_to(next_token, prev_token) - continue - - # Check for adjacent delimiters - if is_unknown_token(prev_token) and is_delimiter_token(next_token): - next_delimiter = next_token.content - if delimiter != next_delimiter and delimiter != ',': - if next_delimiter == ' ' or next_delimiter == '_': - if not prev_token.content.isdigit(): # prevent "02. " got appended - append_token_to(token, prev_token) # e.g. "." in "Vol. ", "Ep. " - - elif is_delimiter_token(prev_token) and \ - is_delimiter_token(next_token): - prev_delimiter = prev_token.content - next_delimiter = next_token.content - if prev_delimiter == next_delimiter and \ - prev_delimiter != delimiter: - self.set_token_element(token, TokenCategory.UNKNOWN, ElementCategory.UNKNOWN) # e.g. "&" in "_&_" - - # Check for other special cases - if delimiter == '&' or delimiter == '+': - if is_unknown_token(prev_token) and \ - is_unknown_token(next_token): - if prev_token.content.isdigit() and \ - next_token.content.isdigit(): - append_token_to(token, prev_token) - append_token_to(next_token, prev_token) # e.g. "01+02" - - for token in removed: - self.remove_token(token) - - def append(self, token: Token) -> None: - self.tokens.append(token) - self.counter[token.e_category] = self.counter.get(token.e_category, 0) + 1 - - def add_list(self, tokens: List[Token]) -> None: - for token in tokens: - self.append(token) - - def distance(self, token1: Token = None, token2: Token = None) -> int: - begin_index = 0 if token1 is None else self.get_index(token1) - end_index = len(self.tokens) if token2 is None else self.get_index(token2) - return abs(end_index - begin_index) - - def empty(self) -> bool: - return len(self.tokens) == 0 - - def find(self, flags: int) -> Token: - return self.find_in_tokens(self.tokens, flags) - - @staticmethod - def find_in_tokens(tokens: List[Token], - flags: int, - elements: Union[ElementCategory, List[ElementCategory]] = None - ) -> Union[Token, None]: - # return the first token that matches the flags - for token in tokens: - if not token.check_elements(elements): - continue - if token.check_flags(flags): - return token - return None - - def find_next(self, token: Token = None, flags: int = None, element: Union[ElementCategory, List[ElementCategory]] = None) -> Union[Token, None]: - tokens = self.tokens - if token is not None: - tokens = tokens[self.get_index(token) + 1:] - return self.find_in_tokens(tokens, flags, element) - - def find_prev(self, token: Token = None, flags: int = None, element: Union[ElementCategory, List[ElementCategory]] = None) -> Union[Token, None]: - tokens = self.tokens.copy() # make copy of the list, but the tokens are still the same - if token is None: - tokens.reverse() - else: - tokens = tokens[max(self.get_index(token) - 1, 0)::-1] - return self.find_in_tokens(tokens, flags, element) - - def get(self, index: int) -> Token: - return self.tokens[index] - - def get_index(self, token: Token) -> int: - return self.tokens.index(token) - - def get_list(self, - flags: int = None, - begin: Token = None, - end: Token = None, - elements: Union[ElementCategory, List[ElementCategory]] = None) -> List[Token]: - - begin_index = 0 if begin is None else self.get_index(begin) - end_index = len(self.tokens) if end is None else self.get_index(end) - tokens = self.tokens[begin_index:end_index + 1] - - if elements: - tokens = (token for token in tokens if token.check_elements(elements)) - - if flags: - tokens = (t for t in tokens if t.check_flags(flags)) - return tokens - - def insert(self, index: int, token: Token) -> None: - self.tokens.insert(index, token) - self.counter[token.e_category] = self.counter.get(token.e_category, 0) + 1 - - def insert_after(self, token: Token, new_token: Token) -> None: - self.insert(self.get_index(token) + 1, new_token) - - def insert_before(self, token: Token, new_token: Token) -> None: - self.insert(self.get_index(token), new_token) - - def insert_tokens(self, token: Token, word: str, category: TokenCategory, - element: ElementCategory) -> Token: - new_token = Token(word, category, element, token.enclosed) - self.insert_after(token, - new_token - ) - return new_token - - def is_token_isolated(self, token: Token) -> bool: - previous_token = self.find_prev(token, TokenFlags.NOT_DELIMITER) - if previous_token.t_category != TokenCategory.BRACKET: - return False - - next_token = self.find_next(token, TokenFlags.NOT_DELIMITER) - if next_token.t_category != TokenCategory.BRACKET: - return False - return True - - def remove_token(self, token: Token) -> None: - self.counter[token.e_category] -= 1 - self.tokens.remove(token) - - def set_token_element(self, token: Token, t_category: TokenCategory, e_category: ElementCategory) -> None: - self.counter[token.e_category] -= 1 - self.counter[e_category] = self.counter.get(e_category, 0) + 1 - token.t_category = t_category - token.e_category = e_category diff --git a/aniparse/tokenizer.py b/aniparse/tokenizer.py deleted file mode 100644 index 855f676..0000000 --- a/aniparse/tokenizer.py +++ /dev/null @@ -1,249 +0,0 @@ -from __future__ import unicode_literals, absolute_import - -import logging -import re -from typing import Tuple - -from aniparse import parser_helper -from aniparse.element import ElementCategory -from aniparse.keyword import KeywordManager -from aniparse.token import TokenCategory, Tokens - -logger = logging.getLogger(__name__) - -open_brackets = ( - "(", # U+0028 LEFT PARENTHESIS - "[", # U+005B LEFT SQUARE BRACKET - "{", # U+007B LEFT CURLY BRACKET - "\u300C", # Corner bracket - "\u300E", # White corner bracket - "\u3010", # Black lenticular bracket - "\uFF08", # Fullwidth parenthesis -) - -close_brackets = ( - ")", # U+0029 Right parenthesis - "]", # U+005D Right square bracket - "}", # U+007D Right curly bracket - "\u300D", # Corner bracket - "\u300F", # White corner bracket - "\u3011", # Black lenticular bracket - "\uFF09", # Fullwidth right parenthesis -) - -brackets = open_brackets + close_brackets -escaped_open_brackets = [re.escape(bracket) for bracket in open_brackets] -escaped_close_brackets = [re.escape(bracket) for bracket in close_brackets] -escaped_brackets = '|'.join(escaped_open_brackets + escaped_close_brackets) - - -def invert_string_brackets(text) -> Tuple[str, list, list]: - close_brackets_position = [] - open_brackets_position = [] - reverted_name = "" - for idx, char in enumerate(text[::-1]): - if char in open_brackets: - reverted_name += close_brackets[open_brackets.index(char)] - close_brackets_position.append(idx) - elif char in close_brackets: - reverted_name += open_brackets[close_brackets.index(char)] - open_brackets_position.append(idx) - else: - reverted_name += char - return reverted_name, open_brackets_position, close_brackets_position - - -class Tokenizer(Tokens): - def __init__(self, filename: str, options: dict, keyword_manager: KeywordManager): - self.filename = filename - self.options = options - self.batch = [] - allowed_delimiter = self.options['allowed_delimiters'] - escaped_delimiters = [re.escape(d) for d in allowed_delimiter] - if '.' in allowed_delimiter: - dot_with_digit_before = r'(? Tuple[str, list, list]: - # find all brackets positions from self.filename - open_brackets_position = [] - close_brackets_position = [] - for bracket in re.finditer(escaped_brackets, filename, re.IGNORECASE): - if bracket.group() in open_brackets: - open_brackets_position.append(bracket.start()) - else: - close_brackets_position.append(bracket.start()) - - # if the length is not same, then there is a bracket that is not closed or typo - if len(open_brackets_position) != len(close_brackets_position): - # if the different is even, then it is probably a typo - if (len(open_brackets_position) - len(close_brackets_position)) % 2 == 0: - all_bracket = sorted(open_brackets_position + close_brackets_position) - last_seen = [] - expected = [] - reverted = False - if len(close_brackets_position) > len(open_brackets_position): - # reverse the filename and invert open brackets and close brackets - reverted = True - filename, open_brackets_position, close_brackets_position = invert_string_brackets( - filename) - all_bracket = sorted(open_brackets_position + close_brackets_position) - - for index in all_bracket: - char = filename[index] - if char in open_brackets: - try: - abc = char == last_seen[-1] - except IndexError: - abc = False - if abc: - # We expected a close bracket, but we got the same open bracket - # This is probably a typo, replace it with correct bracket - corrected = close_brackets[open_brackets.index(char)] - filename = filename[:index] + corrected + filename[index + 1:] - # remove this index from open_brackets_position and add to close_brackets_position - open_brackets_position.remove(index) - close_brackets_position.append(index) - last_seen.pop() - else: - last_seen.append(char) - expected.append(close_brackets[open_brackets.index(char)]) - - else: - try: - if char == expected[-1]: - last_seen.pop() - expected.pop() - except IndexError: - pass - if reverted: - filename, open_brackets_position, close_brackets_position = invert_string_brackets( - filename) - - # else: I'm not sure what to do here. Let's just ignore it and put on log - else: - logger.debug(f"Invalid brackets in {filename}") - # if the length not same, if there's a same bracket next to each other, remove one of them - if len(open_brackets_position) != len(close_brackets_position): - filename = re.sub(f"({'|'.join(escaped_open_brackets)})\\1+", "\\1", filename) - filename = re.sub(f"({'|'.join(escaped_close_brackets)})\\1+", "\\1", filename) - open_brackets_position = [] - close_brackets_position = [] - for bracket in re.finditer(escaped_brackets, filename, re.IGNORECASE): - if bracket.group() in open_brackets: - open_brackets_position.append(bracket.start()) - else: - close_brackets_position.append(bracket.start()) - - # just to make sure that the brackets are sorted - open_brackets_position.sort() - close_brackets_position.sort() - return filename, open_brackets_position, close_brackets_position - - def _tokenize_by_brackets(self, filename) -> str: - """ - Tokenize the text based on the brackets found in it. - Characters inside brackets are marked as enclosed. - """ - filename, open_brackets_position, close_brackets_position = self._get_brackets_position(filename) - - cursor = 0 - last_pair_index = 0 - for open_bracket_index in open_brackets_position: - if open_bracket_index < cursor: - continue - - if open_bracket_index != cursor: - # Found a token before the bracket - self._tokenize_by_pre_identified( - filename[cursor:open_bracket_index], - enclosed=False - ) - cursor = open_bracket_index - for last_pair_index, close_bracket_index in enumerate(close_brackets_position[last_pair_index:]): - if (close_bracket_index > open_bracket_index - and filename[close_bracket_index] == close_brackets[open_brackets.index( - filename[open_bracket_index])]): - break - else: - close_bracket_index = -1 - if close_bracket_index == -1: - self._tokenize_by_pre_identified( - filename[cursor:], - enclosed=False - ) - cursor = len(filename) - else: - self._add_token( - TokenCategory.BRACKET, filename[open_bracket_index], enclosed=True, - element=ElementCategory.BRACKET) - self._tokenize_by_pre_identified( - filename[cursor + 1:close_bracket_index], - enclosed=True - ) - self._add_token( - TokenCategory.BRACKET, filename[close_bracket_index], enclosed=True, - element=ElementCategory.BRACKET) - cursor = close_bracket_index + 1 - else: - self._tokenize_by_pre_identified( - filename[cursor:], - enclosed=False - ) - return filename - - def _tokenize_by_pre_identified(self, text, enclosed) -> None: - if not text: - return - - pre_identified_tokens = self.keyword_manager.peek(text) - - last_token_end_pos = 0 - for token_begin_pos, token_end_pos, category in pre_identified_tokens: - if last_token_end_pos != token_begin_pos: - # Tokenize the text between the pre-identified tokens - self._tokenize_by_delimiters( - text[last_token_end_pos:token_begin_pos], enclosed) - self._add_token(TokenCategory.IDENTIFIER, text[token_begin_pos:token_end_pos], enclosed, - category) - last_token_end_pos = token_end_pos - - if last_token_end_pos != len(text): - # Tokenize the text after the pre-identified tokens (or all the text - # if there was no pre-identified tokens) - self._tokenize_by_delimiters(text[last_token_end_pos:], enclosed) - - def _tokenize_by_delimiters(self, text, enclosed) -> None: - splited_text = self.pattern.split(text) - new_tokens = Tokens(self.keyword_manager) # Create a new Tokens object to store the new tokens - for sub_text in splited_text: - if sub_text: - if sub_text in self.options['allowed_delimiters']: - new_tokens._add_token(TokenCategory.DELIMITER, sub_text, enclosed, ElementCategory.DELIMITER) - else: - stripped = sub_text.strip(f" {parser_helper.DASHES}") - # e.g S01E01- > S01E01 - match = re.match(f"(.*)({re.escape(stripped)})(.*)", sub_text) - if match: - if match.group(1): - # "-" in "- " or "-Flac" - new_tokens._add_token(TokenCategory.UNKNOWN, match.group(1), enclosed) - if match.group(2): - new_tokens._add_token(TokenCategory.UNKNOWN, match.group(2), enclosed) - if match.group(3): - # "-" in "S01E01-" - new_tokens._add_token(TokenCategory.UNKNOWN, match.group(3), enclosed) - - new_tokens._validate_delimiter_tokens() - - self.add_list(new_tokens.tokens) - - def tokenize(self) -> bool: - self.filename = self._tokenize_by_brackets(self.filename) - return not self.empty() diff --git a/aniparse/wordlist.py b/aniparse/wordlist.py new file mode 100644 index 0000000..a3caef8 --- /dev/null +++ b/aniparse/wordlist.py @@ -0,0 +1,105 @@ +from collections import defaultdict +from difflib import SequenceMatcher +from typing import Optional, Dict, List, Iterator + +from aniparse.abstraction.keyword_base import WordListProvider +from aniparse.keyword import ElementEntry + + +class InMemoryWordListProvider(WordListProvider): + """Provides word lists from an in-memory dictionary.""" + + def __init__(self): + """Initializes the provider with an empty dictionary.""" + self.entries: Dict[str, List[ElementEntry]] = defaultdict(list) + + def find(self, normalized_word: str, fuzzy: bool = False, threshold: float = 0.8) -> Iterator[ElementEntry]: + """Checks if a normalized word exists and return an iterator of it if it does. + + Args: + normalized_word: The normalized word to check. + fuzzy: If True, fall back to fuzzy matching when exact match fails. + threshold: Minimum similarity ratio (0-1) for fuzzy matches. + + Returns: + An iterator of ElementEntry of the matching normalized word. + """ + normalized_word = self.normalize(normalized_word) + if normalized_word in self.entries: + return iter(self.entries[normalized_word]) + if fuzzy: + return self._fuzzy_find(normalized_word, threshold) + return iter([]) + + def _fuzzy_find(self, normalized_word: str, threshold: float) -> Iterator[ElementEntry]: + """Finds entries with keys similar to the given word above the threshold.""" + candidates = [] + for key, entries in self.entries.items(): + ratio = SequenceMatcher(None, normalized_word, key).ratio() + if ratio >= threshold: + candidates.append((ratio, entries)) + candidates.sort(key=lambda x: x[0], reverse=True) + for _, entries in candidates: + yield from entries + + def add_word(self, entry: ElementEntry) -> None: + """Adds a new word entry, handling continuations. + + Args: + entry: The ElementEntry object representing the new word. + """ + normalized = self.normalize(entry.word) + self.entries[normalized].append(entry) + + +class WordListManager: + """Manages multiple WordListProvider instances and provides a unified interface.""" + + def __init__(self): + """Initializes the manager with an empty list of providers.""" + self._providers: Dict[str, WordListProvider] = {} + + def add_provider(self, name: str, provider: WordListProvider) -> None: + """Adds a WordListProvider to the manager. + + Args: + provider: The WordListProvider instance to add. + name: The name to associate with the provider for later reference + """ + self._providers[name] = provider + + def get_providers(self) -> List[str]: + """Returns a list of all registered WordListProviders.""" + return list(self._providers.keys()) + + def add_word(self, entry: ElementEntry, provider_name: str) -> None: + """Adds a new word entry to the specified provider. + + Args: + entry: The ElementEntry object representing the new word. + provider_name: The name of the provider to add the word to. + """ + if provider_name in self._providers: + self._providers[provider_name].add_word(entry) + else: + raise ValueError(f"Provider '{provider_name}' not found.") + + def find(self, normalized_word: str, provider_name: Optional[str] = None, fuzzy: bool = False, threshold: float = 0.8) -> Iterator[ElementEntry]: + """Checks if a word exists in the specified provider or in all providers. + + Args: + normalized_word: The normalized form of the word to check. + provider_name: The name of the provider to check (optional). If None, all providers are checked. + + Yields: + ElementEntry objects from the specified provider (or all providers) that contain the word. + """ + if provider_name: + if provider_name in self._providers: + yield from self._providers[provider_name].find(normalized_word, fuzzy=fuzzy, threshold=threshold) + else: + raise ValueError(f"Provider '{provider_name}' not found.") + else: + for provider in self._providers.values(): + yield from provider.find(normalized_word, fuzzy=fuzzy, threshold=threshold) + diff --git a/pyproject.toml b/pyproject.toml index 4d220bf..386cfca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,37 @@ [build-system] -requires = ["setuptools>=42.0", "wheel"] +requires = ["setuptools>=64", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" +[project] +name = "aniparse" +dynamic = ["version"] +description = "A probability-based anime filename parser" +readme = "README.md" +license = "MPL-2.0" +requires-python = ">=3.7" +authors = [ + { name = "めがねこ", email = "neko@meganeko.dev" }, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +keywords = ["anime", "filename parser"] + +[project.urls] +Homepage = "https://github.com/MeGaNeKoS/aniparse" + +[tool.setuptools-scm] +version_scheme = "guess-next-dev" +local_scheme = "no-local-version" + [tool.pytest.ini_options] -addopts = "--cov=aniparse" -testpaths = [ - "tests", -] \ No newline at end of file +testpaths = ["tests"] diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index edf1abb..0000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,3 +0,0 @@ -tox -pytest -pytest-cov \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 6b80657..0000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -from setuptools import setup, find_packages - -with open("README.md", encoding="utf-8") as f: - README = f.read() - -setup( - name='aniparse', - version='1.2.2', - description="An anime video filename parser", - long_description=README, - long_description_content_type="text/markdown", - url='https://github.com/MeGaNeKoS/aniparse', - license='License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', - author='めがねこ', - author_email='evictory91+pypackages@gmail.com', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - ], - packages=find_packages(), - keywords=['anime', 'filename parser'] -) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ab88d6d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,115 @@ +"""Shared test helpers for fixture comparison.""" +from dataclasses import dataclass, field +from typing import Optional, List + +import pytest +import aniparse +from aniparse.output_schemas import Metadata +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.wordlist import InMemoryWordListProvider, WordListManager +from aniparse.core.constant import DEFAULT_PROVIDER_NAME + + +@dataclass +class Fixture: + """Test fixture: a Metadata expected result + optional custom keywords for DB tests.""" + metadata: Metadata + custom_keywords: Optional[List[ElementEntry]] = field(default=None) + path: Optional[str] = field(default=None) + + @property + def file_name(self): + return self.metadata.file_name + + def to_dict(self): + return self.metadata.to_dict() + + +def _normalize(value): + """Lowercase strings for comparison.""" + if isinstance(value, str): + return value.lower().strip() + if isinstance(value, list): + return [_normalize(v) for v in value] + if isinstance(value, dict): + return {k: _normalize(v) for k, v in value.items()} + return value + + +def compare_dicts(expected, actual, path=""): + """Compare expected fixture dict against actual parse output. + Only checks keys present in expected (fixture is ground truth). + Returns list of (path, expected, actual) mismatches. + """ + mismatches = [] + for key, exp_val in expected.items(): + act_val = actual.get(key) + full_path = f"{path}.{key}" if path else key + + if exp_val is None: + continue + + if act_val is None: + mismatches.append((full_path, exp_val, None)) + continue + + exp_norm = _normalize(exp_val) + act_norm = _normalize(act_val) + + if isinstance(exp_norm, list) and isinstance(act_norm, list): + for i, (e, a) in enumerate(zip(exp_norm, act_norm)): + if isinstance(e, dict) and isinstance(a, dict): + mismatches.extend(compare_dicts(e, a, f"{full_path}[{i}]")) + elif e != a: + mismatches.append((f"{full_path}[{i}]", e, a)) + if len(exp_norm) != len(act_norm): + mismatches.append((f"{full_path}.length", len(exp_norm), len(act_norm))) + elif isinstance(exp_norm, dict) and isinstance(act_norm, dict): + mismatches.extend(compare_dicts(exp_norm, act_norm, full_path)) + elif exp_norm != act_norm: + mismatches.append((full_path, exp_norm, act_norm)) + + return mismatches + + +def run_fixture(fixture): + """Run a single fixture through aniparse.parse() and assert correctness. + + Accepts either a Metadata (no custom keywords) or a Fixture (with custom keywords). + """ + if isinstance(fixture, Fixture): + custom_keywords = fixture.custom_keywords + path = fixture.path + expected = fixture.to_dict() + else: + custom_keywords = None + path = None + expected = fixture.to_dict() + + file_name = expected["file_name"] + + wlm = None + if custom_keywords: + wlm = WordListManager() + provider = InMemoryWordListProvider() + from aniparse import keyword + for word in keyword.DEFAULT_KEYWORD: + provider.add_word(word) + for kw in custom_keywords: + provider.add_word(kw) + wlm.add_provider(DEFAULT_PROVIDER_NAME, provider) + + parse_kwargs = {"wordlist_provider": wlm} + if path: + parse_kwargs["path"] = path + + actual = aniparse.parse(file_name, **parse_kwargs) + + assert actual is not None, f"parse() returned None for: {file_name}" + + mismatches = compare_dicts(expected, actual) + if mismatches: + msg_lines = [f"Mismatches for: {file_name}"] + for path, exp, act in mismatches: + msg_lines.append(f" {path}: expected={exp!r}, got={act!r}") + pytest.fail("\n".join(msg_lines)) diff --git a/tests/fixtures/basic_episodes.py b/tests/fixtures/basic_episodes.py new file mode 100644 index 0000000..b9bc2b1 --- /dev/null +++ b/tests/fixtures/basic_episodes.py @@ -0,0 +1,2239 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata +from aniparse.abstraction.keyword_base import ElementEntry +from aniparse.core.token_tags import Tag +from tests.conftest import Fixture + +basic_episodes = [ + Metadata( + audio_term=[ + "flac" + ], + file_checksum="69eccdcf", + file_name="aim_for_the_top!_gunbuster-ep1.bd(h264.flac.10bit)[kaa][69eccdcf]", + release_group=[ + "kaa" + ], + series=[ + SeriesInfo( + title="aim for the top! gunbuster", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + source=[ + "bd" + ], + video_term=[ + "h264", + "10bit" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_name="bakemonogatari - 01 (bd 1280x720 avc aacx2)", + series=[ + SeriesInfo( + title="bakemonogatari", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ], + source=[ + "bd" + ], + video_term=[ + "avc" + ] + ), + Metadata( + file_name="chrono crusade ep. 1-5", + series=[ + SeriesInfo( + title="chrono crusade" + ) + ] + ), + Metadata( + file_checksum="30c15d62", + file_name="cyborg 009 (1968) [tshs] episode 06 [30c15d62]", + release_group=[ + "tshs" + ], + series=[ + SeriesInfo( + title="cyborg 009", + year=[ + SequenceNumber( + number=1968 + ) + ], + episode=[ + SequenceNumber( + number=6 + ) + ] + ) + ] + ), + Metadata( + file_name="dragon.ball.kai.-.01.-.1080p.bluray.x264.dhd", + release_group=[ + "dhd" + ], + series=[ + SeriesInfo( + title="dragon ball kai", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + source=[ + "bluray" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264", + ] + ), + Metadata( + audio_term=[ + "flac", + "5.1ch" + ], + file_name="evangelion shin gekijouban q (bdrip 1920x1080 x264 flacx2 5.1ch)-ank", + release_group=[ + "ank" + ], + series=[ + SeriesInfo( + title="evangelion shin gekijouban q" + ) + ], + source=[ + "bdrip" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + video_width=1920 + ) + ], + video_term=[ + "x264", + ] + ), + Metadata( + audio_term=[ + "aac", + "5.1", + "2.0" + ], + file_name="evangelion the new movie q (bd 1280x720 avc aacx2 [5.1+2.0])", + series=[ + SeriesInfo( + title="evangelion the new movie q" + ) + ], + source=[ + "bd" + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ], + video_term=[ + "avc" + ] + ), + # Without DB: "2" is ambiguous — parsed as episode (no episode evidence elsewhere) + Metadata( + file_checksum="88f4f7f0", + file_name="eve no jikan 2 [88f4f7f0]", + series=[ + SeriesInfo( + title="eve no jikan", + episode=[ + SequenceNumber(number=2) + ] + ) + ] + ), + # With DB: see basic_episodes_db + Metadata( + file_checksum="69a307a2", + file_name="evobot.[watakushi]_akuma_no_riddle_-_01v2_[720p][69a307a2]", + release_group=[ + "watakushi" + ], + series=[ + SeriesInfo( + title="akuma no riddle", + episode=[ + SequenceNumber( + number=1, + release_version="2" + ) + ] + ) + ], + unknown=[ + "evobot" + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + file_name="evangelion_1.0_you_are_[not]_alone_(1080p)_[@home]", + release_group=[ + "@home" + ], + series=[ + SeriesInfo( + title="evangelion 1.0 you are [not] alone" + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ] + ), + Metadata( + file_name="gekkan shoujo nozaki-kun [horriblesubs] (1080p)", + release_group=[ + "horriblesubs" + ], + series=[ + SeriesInfo( + title="gekkan shoujo nozaki-kun" + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ] + ), + Metadata( + file_name="juuni kokki ep.5", + series=[ + SeriesInfo( + title="juuni kokki", + episode=[ + SequenceNumber( + number=5 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="803da487", + file_name="juuousei_-_01_[black_sheep][hdtv_h264_aac][803da487]", + release_group=[ + "black sheep" + ], + series=[ + SeriesInfo( + title="juuousei", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + source=[ + "hdtv" + ], + video_term=[ + "h264" + ] + ), + Metadata( + audio_term=[ + "aac", + "5.1", + "2.0" + ], + file_name="kotonoha no niwa (bd 1280x720 avc aacx3 [5.1+2.0+2.0] subx3)", + series=[ + SeriesInfo( + title="kotonoha no niwa" + ) + ], + source=[ + "bd" + ], + subs_term=[ + "sub" + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ], + video_term=[ + "avc" + ] + ), + Metadata( + file_checksum="46b35e25", + file_name="macross frontier - sayonara no tsubasa (central anime, 720p) [46b35e25]", + release_group=[ + "central anime" + ], + series=[ + SeriesInfo( + title="macross frontier - sayonara no tsubasa" + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="7a8a7769", + file_name="magical girl lyrical nanoha a's - 01.dvd[h264.aac][dgz][7a8a7769]", + release_group=[ + "dgz" + ], + series=[ + SeriesInfo( + title="magical girl lyrical nanoha a's", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + source=[ + "dvd" + ], + video_term=[ + "h264" + ] + ), + Metadata( + file_name="mary bell (dvd) - 01v2 [h-b]", + release_group=[ + "h-b" + ], + release_version=[ + "2" + ], + series=[ + SeriesInfo( + title="mary bell", + episode=[ + SequenceNumber( + number=1, + release_version="2" + ) + ] + ) + ], + source=[ + "dvd" + ] + ), + Metadata( + audio_term=[ + "dualaudio" + ], + file_checksum="0cdc2145", + file_name="neko no ongaeshi - [hqr.remux-dualaudio][ntv.1280x692.h264](0cdc2145)", + release_information=[ + "remux" + ], + series=[ + SeriesInfo( + title="neko no ongaeshi" + ) + ], + unknown=[ + "ntv" + ], + video_resolution=[ + VideoResolution( + video_height=692, + video_width=1280 + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + file_name="noein_[01_of_24]_[ru_jp]_[bodlerov_&_torrents_ru]", + language=[ + "ru", + "jp" + ], + release_group=[ + "bodlerov", + "torrents_ru" + ], + series=[ + SeriesInfo( + title="noein", + episode=[ + SequenceNumber( + number=1, + total=24 + ) + ] + ) + ] + ), + Metadata( + file_name="the idolm@ster 765 pro to iu monogatari", + series=[ + SeriesInfo( + title="the idolm@ster 765 pro to iu monogatari" + ) + ] + ), + Metadata( + file_name="[46620] re:zero kara hajimeru isekai seikatsu [1080p av1]", + release_group=[ + "46620" + ], + series=[ + SeriesInfo( + title="re:zero kara hajimeru isekai seikatsu" + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "av1" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="7b2c5e8b", + file_name="[akh-swe]_fullmetal_alchemist_(2009)_02v2_[h.264.aac][7b2c5e8b]", + release_group=[ + "akh-swe" + ], + series=[ + SeriesInfo( + title="fullmetal alchemist", + year=[ + SequenceNumber( + number=2009 + ) + ], + episode=[ + SequenceNumber( + number=2, + release_version="2" + ) + ] + ) + ], + video_term=[ + "h.264" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="12f00e89", + file_name="[anbu-menclave]_canaan_-_01_[1024x576_h.264_aac][12f00e89]", + release_group=[ + "anbu-menclave" + ], + series=[ + SeriesInfo( + title="canaan", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=576, + video_width=1024 + ) + ], + video_term=[ + "h.264" + ] + ), + Metadata( + file_checksum="596dd8e6", + file_name="[anbu-umai]_haiyoru!_nyaru-ani_[596dd8e6]", + release_group=[ + "anbu-umai" + ], + series=[ + SeriesInfo( + title="haiyoru! nyaru-ani" + ) + ] + ), + Metadata( + file_checksum="2048a39a", + file_name="[anbu]_princess_lover!_-_01_[2048a39a]", + release_group=[ + "anbu" + ], + series=[ + SeriesInfo( + title="princess lover!", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_name="[aojiaozero][mangaka-san to assistant-san to the animation] ep02 [big][x264_aac][720p]", + release_group=[ + "aojiaozero" + ], + series=[ + SeriesInfo( + title="mangaka-san to assistant-san to the animation", + episode=[ + SequenceNumber( + number=2 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "x264" + ], + unknown=[ + "big" + ] + ), + Metadata( + file_checksum="29675b71", + file_name="[ayako]_infinite_stratos_-_is_-_01v2_[xvid][400p][29675b71]", + release_group=[ + "ayako" + ], + series=[ + SeriesInfo( + title="infinite stratos - is", + episode=[ + SequenceNumber( + number=1, + release_version="2" + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=400, + scan_method="p" + ) + ], + video_term=[ + "XviD" + ] + ), + Metadata( + audio_term=[ + "dd", + "2.0" + ], + file_checksum="cb2b37f1", + file_name="[b-g_&_m.3.3.w]_myself_yourself_12.dvd(h.264_dd2.0)_[cb2b37f1]", + release_group=[ + "b-g", + "m.3.3.w" + ], + series=[ + SeriesInfo( + title="myself yourself", + episode=[ + SequenceNumber( + number=12 + ) + ] + ) + ], + source=[ + "dvd" + ], + video_term=[ + "h.264" + ] + ), + Metadata( + file_checksum="c83164b9", + file_name="[bakawolf-m.3.3.w] special a 01 (h.264) [c83164b9]", + release_group=[ + "bakawolf-m.3.3.w" + ], + series=[ + SeriesInfo( + title="special a", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_term=[ + "h.264" + ] + ), + # Without DB: "no.6" glued — absorbed into title + Metadata( + audio_term=[ + "dual audio" + ], + file_name="[bludragon] blue submarine no.6 (dvd, r2, dual audio) v3", + release_group=[ + "bludragon" + ], + release_version=[ + "3" + ], + series=[ + SeriesInfo( + title="blue submarine no.6", + ) + ] + ), + # With DB: see basic_episodes_db + Metadata( + audio_term=[ + "dual audio", + "vorbis" + ], + # TODO: How we handle this? we should able to recognize them correctly + file_name="[ch] sword art online extra edition dual audio [bd 480p][10bith.264vorbis]", + release_group=[ + "ch" + ], + series=[ + SeriesInfo( + title="sword art online extra edition" + ) + ], + source=[ + "bd" + ], + video_resolution=[ + VideoResolution( + video_height=480, + scan_method="p" + ) + ], + video_term=[ + "10bit", + "h.264" + ] + ), + Metadata( + file_checksum="c1b5ce5d", + file_name="[doremi].ro-kyu-bu!.ss.01.[c1b5ce5d]", + release_group=[ + "doremi" + ], + series=[ + SeriesInfo( + title="ro-kyu-bu! ss", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="888e4991", + file_name="[e-haro raws] kore wa zombie desu ka - 03 (tv 1280x720 h264 aac) [888e4991]", + release_group=[ + "e-haro raws" + ], + series=[ + SeriesInfo( + title="kore wa zombie desu ka", + episode=[ + SequenceNumber( + number=3 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280, + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + file_name="[edomae subs] kore wa zombie desu ka episode 2", + release_group=[ + "edomae subs" + ], + series=[ + SeriesInfo( + title="kore wa zombie desu ka", + episode=[ + SequenceNumber( + number=2 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="c37580f8", + file_name="[elysium]sora.no.woto.ep07.5(bd.720p.aac)[c37580f8]", + release_group=[ + "elysium" + ], + series=[ + SeriesInfo( + title="sora no woto", + episode=[ + SequenceNumber( + number=7.5 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + file_checksum="f2ae5ff6", + file_name="[erai-raws] one piece - 1000 [1080p][multiple subtitle][f2ae5ff6]", + release_group=[ + "erai-raws" + ], + series=[ + SeriesInfo( + title="one piece", + episode=[ + SequenceNumber( + number=1000 + ) + ] + ) + ], + subs_term=[ + "multiple subtitle" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ] + ), + Metadata( + file_checksum="902bb314", + file_name="[erogaki-team]_nurse_witch_komugi-chan_magikarte_02.5_[902bb314]", + release_group=[ + "erogaki-team" + ], + series=[ + SeriesInfo( + title="nurse witch komugi-chan magikarte", + episode=[ + SequenceNumber( + number=2.5 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="457cc066", + file_name="[fbi] baby princess 3d paradise love 01v0 [bd][720p-aac][457cc066]", + release_group=[ + "fbi" + ], + series=[ + SeriesInfo( + title="baby princess 3d paradise love", + episode=[ + SequenceNumber( + number=1, + release_version="0" + ) + ] + ) + ], + source=[ + "bd" + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + file_checksum="bad09c76", + file_name="[fff] futsuu no joshikousei ga [locodol] yatte mita. - 01 [bad09c76]", + release_group=[ + "fff" + ], + series=[ + SeriesInfo( + title="futsuu no joshikousei ga [locodol] yatte mita.", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ] + ), + Metadata( + file_checksum="29ea865b", + file_name="[fff] red data girl - 10v0 [29ea865b]", + release_group=[ + "fff" + ], + series=[ + SeriesInfo( + title="red data girl", + episode=[ + SequenceNumber( + number=10, + release_version="0" + ) + ] + ) + ] + ), + Metadata( + file_name="[faggotryraws] bakuman - 01 (nhk e 848x480)", + release_group=[ + "faggotryraws" + ], + series=[ + SeriesInfo( + title="bakuman", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=480, + video_width=848 + ) + ], + unknown=[ + "nhk e" + ] + ), + Metadata( + file_checksum="73ad0735", + file_name="[frostii]_nodame_cantabile_finale_-_00_[73ad0735]", + release_group=[ + "frostii" + ], + series=[ + SeriesInfo( + title="nodame cantabile finale", + episode=[ + SequenceNumber( + number=0 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "dual audio" + ], + file_checksum="c13cefe0", + file_name="[grimripper] koi kaze [dual audio] ep01 (c13cefe0)", + release_group=[ + "grimripper" + ], + series=[ + SeriesInfo( + title="koi kaze", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ] + ), + Metadata( + file_name="[hakugetsu&mgrt][evangelion 3.0 you can (not) redo][480p][v0]", + release_group=[ + "hakugetsu&mgrt" + ], + release_version=[ + "0" + ], + series=[ + SeriesInfo( + title="evangelion 3.0 you can (not) redo" + ) + ], + video_resolution=[ + VideoResolution( + video_height=480, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[hard-boiled fs]fullmetalalchemist_09", + release_group=[ + "hard-boiled fs" + ], + series=[ + SeriesInfo( + title="fullmetalalchemist", + episode=[ + SequenceNumber( + number=9 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="6b6be015", + file_name="[hien] kotoura-san - special short anime 'haruka's room' - 01 [bd 1080p h.264 10-bit aac][6b6be015]", + release_group=[ + "hien" + ], + series=[ + SeriesInfo( + title="kotoura-san - special short anime 'haruka's room'", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "h.264", + "10-bit" + ] + ), + Metadata( + file_checksum="e83ad672", + file_name="[himatsubushi]_sora_no_woto_-_01_-_h264_-_720p_-_e83ad672", + release_group=[ + "himatsubushi" + ], + series=[ + SeriesInfo( + title="sora no woto", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + file_name="[hiryuu] maji de watashi ni koi shinasai!! - 02 [720]", + release_group=[ + "hiryuu" + ], + series=[ + SeriesInfo( + title="maji de watashi ni koi shinasai!!", + episode=[ + SequenceNumber( + number=2 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[horriblesubs] gintama - 111c [1080p]", + release_group=[ + "horriblesubs" + ], + series=[ + SeriesInfo( + title="gintama", + episode=[ + SequenceNumber( + number=111, + part="c" + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[horriblesubs] heroman - 10_(xvid_animesenshi)", + release_group=[ + "horriblesubs" + ], + series=[ + SeriesInfo( + title="heroman", + episode=[ + SequenceNumber( + number=10 + ) + ] + ) + ], + video_term=[ + "xvid" + ], + unknown=[ + "animesenshi" + ] + ), + Metadata( + file_name="[horriblesubs] the world god only knows 2 - 03 [720p]", + release_group=[ + "horriblesubs" + ], + series=[ + SeriesInfo( + title="the world god only knows 2", + episode=[ + SequenceNumber( + number=3 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[horriblesubs] tower of druaga - sword of uruk - 04 [480p]", + release_group=[ + "horriblesubs" + ], + series=[ + SeriesInfo( + title="tower of druaga - sword of uruk", + episode=[ + SequenceNumber( + number=4 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=480, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[infantjedi] norn9 - norn + nonetto - 12", + release_group=[ + "infantjedi" + ], + series=[ + SeriesInfo( + title="norn9 - norn + nonetto", + episode=[ + SequenceNumber( + number=12 + ) + ] + ) + ] + ), + Metadata( + file_name="[judas] black clover - jump special anime festa 2018", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="black clover - jump special anime festa 2018" + ) + ] + ), + Metadata( + file_name="[judas] boruto - ova - the day naruto became hokage", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="boruto - ova - the day naruto became hokage" + ) + ] + ), + Metadata( + file_name="[judas] card captor sakura - the movie 2 - the sealed card", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="card captor sakura - the movie 2 - the sealed card" + ) + ] + ), + Metadata( + file_name="[judas] initial d - extra stage 2 - sentimental white", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="initial d - extra stage 2 - sentimental white" + ) + ] + ), + Metadata( + file_name="[judas] one piece - 1009v2", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="one piece", + episode=[ + SequenceNumber( + number=1009, + release_version="2" + ) + ] + ) + ] + ), + Metadata( + file_checksum="a1fc58a7", + file_name="[jumonji-giri]_[shinsen-subs][asf]_d.c.ii_da_capo_ii_ep01_(a1fc58a7)", + release_group=[ + "jumonji-giri", + "shinsen-subs", + "asf" + ], + series=[ + SeriesInfo( + title="d.c.ii da capo ii", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ] + ), + Metadata( + file_name="[klf]_d.gray-man_04v2", + release_group=[ + "klf" + ], + series=[ + SeriesInfo( + title="d.gray-man", + episode=[ + SequenceNumber( + number=4, + release_version="2" + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="06ee7355", + file_name="[kira-fansub] uchuu no stellvia ep 14 (bd h264 1280x960 24fps aac) [06ee7355]", + release_group=[ + "kira-fansub" + ], + series=[ + SeriesInfo( + title="uchuu no stellvia", + episode=[ + SequenceNumber( + number=14 + ) + ] + ) + ], + source=[ + "bd" + ], + video_resolution=[ + VideoResolution( + video_height=960, + video_width=1280 + ) + ], + video_term=[ + "h264", + "24fps" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="943106ad", + file_name="[lambda-delta]_umineko_no_naku_koro_ni_-_11_[848x480_h.264_aac][943106ad]", + release_group=[ + "lambda-delta" + ], + series=[ + SeriesInfo( + title="umineko no naku koro ni", + episode=[ + SequenceNumber( + number=11 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=480, + video_width=848 + ) + ], + video_term=[ + "h.264" + ] + ), + Metadata( + file_checksum="d035a39f", + file_name="[mezashite] aikatsu! ‒ 100 [d035a39f]", + release_group=[ + "mezashite" + ], + series=[ + SeriesInfo( + title="aikatsu!", + episode=[ + SequenceNumber( + number=100 + ) + ] + ) + ] + ), + Metadata( + file_name="[n logn fansubs] angel beats (9)", + release_group=[ + "n logn fansubs" + ], + series=[ + SeriesInfo( + title="angel beats", + episode=[ + SequenceNumber( + number=9 + ) + ] + ) + ] + ), + Metadata( + file_checksum="49874745", + file_name="[namaenai] hidamari sketch x365 - 09a (dvd) [49874745]", + release_group=[ + "namaenai" + ], + series=[ + SeriesInfo( + title="hidamari sketch x365", + episode=[ + SequenceNumber( + number=9, + part="a" + ) + ] + ) + ], + source=[ + "dvd" + ] + ), + Metadata( + file_checksum="98965607", + file_name="[nishi-taku] tamayura ~graduation photo~ movie part 1 [bd][720p][98965607]", + release_group=[ + "nishi-taku" + ], + series=[ + SeriesInfo( + title="tamayura ~graduation photo~ movie part 1" + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="ba70ba9c", + file_name="[nubles] space battleship yamato 2199 (2012) episode 18 (720p 8 bit aac)[ba70ba9c]", + release_group=[ + "nubles" + ], + series=[ + SeriesInfo( + title="space battleship yamato 2199", + year=[ + SequenceNumber( + number=2012 + ) + ], + episode=[ + SequenceNumber( + number=18 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "8 bit" + ] + ), + Metadata( + audio_term=[ + "ac3" + ], + file_checksum="82e36a36", + file_name="[r-r] diebuster.ep1 (720p.hi10p.ac3) [82e36a36]", + release_group=[ + "r-r" + ], + series=[ + SeriesInfo( + title="diebuster", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "hi10p" + ] + ), + Metadata( + file_checksum="1590d378", + file_name="[rna]_sakura_taisen_new_york_ny_ep_2_[1590d378]", + release_group=[ + "rna" + ], + series=[ + SeriesInfo( + title="sakura taisen new york ny", + episode=[ + SequenceNumber( + number=2 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "ogg" + ], + file_checksum="585d9971", + file_name="[rax]mezzo(dsa)_-_05_-_[x264_ogg]_[585d9971]", + release_group=[ + "rax" + ], + series=[ + SeriesInfo( + title="mezzo dsa", + episode=[ + SequenceNumber( + number=5 + ) + ] + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + audio_term=[ + "vorbis" + ], + file_name="[rakuda].gift.~eternal.rainbow~.01.dvd.h.264.vorbis", + release_group=[ + "rakuda" + ], + series=[ + SeriesInfo( + title="gift ~eternal rainbow~", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + source=[ + "dvd" + ], + video_term=[ + "h.264" + ] + ), + Metadata( + file_name="[redone] memories off 3.5 - 04 (dvd 10-bit)", + release_group=[ + "redone" + ], + series=[ + SeriesInfo( + title="memories off 3.5", + episode=[ + SequenceNumber( + number=4 + ) + ] + ) + ], + source=[ + "dvd" + ], + video_term=[ + "10-bit" + ] + ), + Metadata( + audio_term=[ + "dual-audio" + ], + file_name="[reaktor] serial experiments lain - e01 [1080p][x265][10-bit][dual-audio]", + release_group=[ + "reaktor" + ], + series=[ + SeriesInfo( + title="serial experiments lain", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x265", + "10-bit" + ] + ), + # Without DB: "no.6" kept as title (empty-title fallback — "no" as ep prefix would leave title empty) + Metadata( + file_checksum="a956075c", + file_name="[ruri]no.6 01 [720p][h264][a956075c]", + release_group=[ + "ruri" + ], + series=[ + SeriesInfo( + title="no.6", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "h264" + ] + ), + # With DB: see basic_episodes_db + Metadata( + file_checksum="0f5f884f", + file_name="[ss]_kemono_no_souja_erin_-_12_(1280x720_h264)_[0f5f884f]", + release_group=[ + "ss" + ], + series=[ + SeriesInfo( + title="kemono no souja erin", + episode=[ + SequenceNumber( + number=12 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + file_checksum="4d5ec315", + file_name="[shinsen-subs]_macross_frontier_-_01b_[4d5ec315]", + release_group=[ + "shinsen-subs" + ], + series=[ + SeriesInfo( + title="macross frontier", + episode=[ + SequenceNumber( + number=1, + part="b" + ) + ] + ) + ] + ), + Metadata( + file_checksum="b6ce8458", + file_name="[spoonsubs]_hidamari_sketch_x365_-_04.1_(dvd)[b6ce8458]", + release_group=[ + "spoonsubs" + ], + series=[ + SeriesInfo( + title="hidamari sketch x365", + episode=[ + SequenceNumber( + number=4.1 + ) + ] + ) + ], + source=[ + "dvd" + ] + ), + Metadata( + audio_term=[ + "ac3" + ], + file_checksum="3abd57e6", + file_name="[subdesu-h] swing out sisters complete version (720p x264 8bit ac3) [3abd57e6]", + release_group=[ + "subdesu-h" + ], + series=[ + SeriesInfo( + title="swing out sisters" + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "x264", + "8bit" + ] + ), + Metadata( + audio_term=[ + "aac", + "5.1ch" + ], + file_name="[tv-j] kidou senshi gundam uc unicorn - episode.02 [bd 1920x1080 h264+aac(5.1ch jp+en) +sub(jp-en-sp-fr-ch) chap]", + language=[ + "jp", + "en", + "sp", + "fr", + "ch" + ], + release_group=[ + "tv-j" + ], + series=[ + SeriesInfo( + title="kidou senshi gundam uc unicorn", + episode=[ + SequenceNumber( + number=2 + ) + ] + ) + ], + source=[ + "bd" + ], + subs_term=[ + "sub" + ], + unknown=[ + "chap" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + video_width=1920 + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + file_checksum="40f2a957", + file_name="[taka]_fullmetal_alchemist_(2009)_04_[720p][40f2a957]", + release_group=[ + "taka" + ], + series=[ + SeriesInfo( + title="fullmetal alchemist", + year=[ + SequenceNumber( + number=2009 + ) + ], + episode=[ + SequenceNumber( + number=4 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[triad]_today_in_class_5-2_-_04", + release_group=[ + "triad" + ], + series=[ + SeriesInfo( + title="today in class 5-2", + episode=[ + SequenceNumber( + number=4 + ) + ] + ) + ] + ), + Metadata( + file_name="[utw-thora] evangelion 3.33 you can (not) redo [bd][1080p,x264,flac][f2060cf5]", + audio_term=[ + "flac" + ], + file_checksum="f2060cf5", + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + release_group=[ + "utw-thora" + ], + series=[ + SeriesInfo( + title="evangelion 3.33 you can (not) redo" + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + audio_term=[ + "truehd", + "5.1" + ], + file_checksum="9f311dab", + file_name="[utw-tmd]_summer_wars_[bd][h264-720p][truehd5.1][9f311dab]", + release_group=[ + "utw-tmd" + ], + series=[ + SeriesInfo( + title="summer wars" + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="3e56ee18", + file_name="[utw]_accel_world_-_ex01_[bd][h264-720p_aac][3e56ee18]", + release_group=[ + "utw" + ], + series=[ + SeriesInfo( + title="accel world - ex01" + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + audio_term=[ + "ac3" + ], + file_checksum="02a0491d", + file_name="[utw]_fate_zero_-_01_[bd][h264-720p_ac3][02a0491d]", + release_group=[ + "utw" + ], + series=[ + SeriesInfo( + title="fate zero", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + audio_term=[ + "ac3" + ], + file_checksum="bfce1627", + file_name="[urusai]_bokura_ga_ita_01_[dvd_h264_ac3]_[bfce1627][fixed]", + release_group=[ + "urusai" + ], + series=[ + SeriesInfo( + title="bokura ga ita", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_term=[ + "h264" + ], + unknown=[ + "fixed" + ] + ), + Metadata( + file_name="[valdikss]_first_squad_the_morment_of_truth_[720x576_h264_dvdscr_eng_hardsub]", + language=[ + "eng" + ], + release_group=[ + "valdikss" + ], + series=[ + SeriesInfo( + title="first squad the morment of truth" + ) + ], + source=[ + "dvdscr" + ], + subs_term=[ + "hardsub" + ], + video_resolution=[ + VideoResolution( + video_height=576, + video_width=720 + ) + ], + video_term=[ + "h264", + ] + ), + Metadata( + file_checksum="36d2444d", + file_name="[yoroshiku]_009-1_-_02_(h264)_[36d2444d]", + release_group=[ + "yoroshiku" + ], + series=[ + SeriesInfo( + title="009-1", + episode=[ + SequenceNumber( + number=2 + ) + ] + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + file_checksum="65274fde", + file_name="[yuurisan-subs]_darker_than_black_-_gemini_of_the_meteor_-_01v2_[65274fde].patch", + release_group=[ + "yuurisan-subs" + ], + series=[ + SeriesInfo( + title="darker than black - gemini of the meteor", + episode=[ + SequenceNumber( + number=1, + release_version="2" + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_name="[zero-raws] shingeki no kyojin - 25 end (mbs 1280x720 x264 aac)", + release_group=[ + "zero-raws" + ], + release_information=[ + "end" + ], + series=[ + SeriesInfo( + title="shingeki no kyojin", + episode=[ + SequenceNumber( + number=25 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + file_name="[a4e]r.o.d_the_tv_01[divx5.2.1]", + release_group=[ + "a4e" + ], + series=[ + SeriesInfo( + title="r.o.d the tv", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_term=[ + "divx5.2.1" + ] + ), + Metadata( + audio_term=[ + "multiaudio" + ], + file_name="[fong] lupin iii - kutabare! nostradamus [bdrip.1080p.10bit.multiaudio]", + release_group=[ + "fong" + ], + series=[ + SeriesInfo( + title="lupin iii - kutabare! nostradamus" + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "10bit" + ] + ), + Metadata( + file_checksum="bf735bc4", + file_name="[gg]_kimi_ni_todoke_2nd_season_-_00_[bf735bc4]", + release_group=[ + "gg" + ], + series=[ + SeriesInfo( + title="kimi ni todoke 2nd season", + episode=[ + SequenceNumber( + number=0 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "he-aac" + ], + file_name="[kito].nazca.episode.01.dvdrip.[x264.he-aac.{jpn}+sub{fr}]", + language=[ + "jpn", + "fr" + ], + release_group=[ + "kito" + ], + series=[ + SeriesInfo( + title="nazca", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + audio_term=[ + "5.1", + "aac" + ], + file_name="]akihitosubs] elfen lied - 12 [bd 1920x1080 x265 10bit 5.1 aac]", + release_group=[ + "akihitosubs" + ], + series=[ + SeriesInfo( + title="elfen lied", + episode=[ + SequenceNumber( + number=12 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + video_width=1920 + ) + ], + video_term=[ + "x265", + "10bit" + ] + ), + Metadata( + audio_term=[ + "2.0ch", + "AAC" + ], + file_name="Byousoku 5 Centimeter [Blu-ray][1920x1080 h.264][2.0ch AAC][softsubs]", + series=[ + SeriesInfo( + title="Byousoku 5 Centimeter" + ) + ], + source=[ + "Blu-ray" + ], + subs_term=[ + "softsubs" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + video_width=1920 + ) + ], + video_term=[ + "h.264" + ] + ) +] + +basic_episodes_db = [ + # With DB: custom_keywords identify "eve no jikan 2" as a known title + Fixture( + metadata=Metadata( + file_checksum="88f4f7f0", + file_name="eve no jikan 2 [88f4f7f0]", + series=[ + SeriesInfo( + title="eve no jikan 2", + ) + ], + ), + custom_keywords=[ + ElementEntry(word='EVE', categories=set(), regex_dict={ + r'EVE[\W_]+NO[\W_]+JIKAN[\W_]+2': {0: {Tag.SERIES_TITLE}} + }) + ] + ), + # With DB: custom_keywords identify "no.6" as part of title + Fixture( + metadata=Metadata( + audio_term=[ + "dual audio" + ], + file_name="[bludragon] blue submarine no.6 (dvd, r2, dual audio) v3", + release_group=[ + "bludragon" + ], + release_version=[ + "3" + ], + series=[ + SeriesInfo( + title="blue submarine no.6", + ) + ], + ), + custom_keywords=[ + ElementEntry(word='NO', categories=set(), regex_dict={ + r'NO\.[\W_]?6': {0: {Tag.SERIES_TITLE}} + }) + ] + ), + # With DB: custom_keywords identify "no.6" as title + Fixture( + metadata=Metadata( + file_checksum="a956075c", + file_name="[ruri]no.6 01 [720p][h264][a956075c]", + release_group=[ + "ruri" + ], + series=[ + SeriesInfo( + title="no.6", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "h264" + ], + ), + custom_keywords=[ + ElementEntry(word='NO', categories=set(), regex_dict={ + r'NO\.[\W_]?6': {0: {Tag.SERIES_TITLE}} + }) + ] + ), +] diff --git a/tests/fixtures/complex.py b/tests/fixtures/complex.py new file mode 100644 index 0000000..af41dcf --- /dev/null +++ b/tests/fixtures/complex.py @@ -0,0 +1,703 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata + +complex_cases = [ + Metadata( + file_name="fairy tail - s06e32 - tartaros arc iron fist of the fire dragon [episode 83]", + series=[ + SeriesInfo( + title="fairy tail", + season=[ + SequenceNumber( + number=6 + ) + ], + episode=[ + SequenceNumber( + number=32, + alternative=[ + SequenceNumber( + number=83 + ) + ], + title="tartaros arc iron fist of the fire dragon" + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "dual audio" + ], + file_name="[animerg] shingeki no kyojin (the final season) - 13 [1080p 10bit dual audio] (attack on titan - 72)", + release_group=[ + "animerg" + ], + series=[ + SeriesInfo( + title="shingeki no kyojin (the final season)", + episode=[ + SequenceNumber( + number=13 + ) + ] + ), + SeriesInfo( + title="attack on titan", + episode=[ + SequenceNumber( + number=72 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "10bit" + ] + ), + Metadata( + file_checksum="5203142b", + file_name="[deep] tegami bachi (reverse) - letter bee - 29 (04) [5203142b]", + release_group=[ + "deep" + ], + series=[ + SeriesInfo( + title="tegami bachi (reverse) - letter bee", + episode=[ + SequenceNumber( + number=29, + alternative=[ + SequenceNumber( + number=4 + ) + ] + ) + ] + ) + ] + ), + Metadata( + file_checksum="9df6b8d5", + file_name="[hatsuyuki-kaitou]_fairy_tail_2_-_52_(227)_[720p][10bit][9df6b8d5]", + release_group=[ + "hatsuyuki-kaitou" + ], + series=[ + SeriesInfo( + title="fairy tail 2", + episode=[ + SequenceNumber( + number=52, + alternative=[ + SequenceNumber( + number=227 + ) + ] + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "10bit" + ] + ), + Metadata( + file_checksum="dd66afb7", + file_name="[hatsuyuki] dragon ball kai (2014) - 002 (100) [1280x720][dd66afb7]", + release_group=[ + "hatsuyuki" + ], + series=[ + SeriesInfo( + title="dragon ball kai", + year=[ + SequenceNumber( + number=2014 + ) + ], + episode=[ + SequenceNumber( + number=2, + alternative=[ + SequenceNumber( + number=100 + ) + ] + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ] + ), + Metadata( + file_checksum="619c57a0", + file_name="[hatsuyuki]_kuroko_no_basuke_s3_-_01_(51)_[720p][10bit][619c57a0]", + release_group=[ + "hatsuyuki" + ], + series=[ + SeriesInfo( + title="kuroko no basuke", + season=[ + SequenceNumber( + number=3 + ) + ], + episode=[ + SequenceNumber( + number=1, + alternative=[ + SequenceNumber( + number=51 + ) + ] + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "10bit" + ] + ), + Metadata( + file_name="[judas] gintama - s02e01-e02 (025-026)", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="gintama", + season=[ + SequenceNumber( + number=2 + ) + ], + episode=[ + SequenceRange( + start=SequenceNumber( + number=1 + ), + end=SequenceNumber( + number=2 + ), + alternative=[ + SequenceRange( + start=SequenceNumber( + number=25 + ), + end=SequenceNumber( + number=26 + ) + ) + ] + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "Dual Audio", + "AAC" + ], + file_name="[Anime Time] Sword Art Online (S01+S02+S03+S04+Alternative+Movies+Specials+OVAs) [BD] [Dual Audio][1080p][HEVC 10bit x265][AAC][Eng Sub] [Batch] (SAO)", + language=[ + "Eng" + ], + release_group=[ + "Anime Time" + ], + series=[ + SeriesInfo( + title="Sword Art Online", + season=[ + SequenceNumber( + number=1 + ) + ] + ), + SeriesInfo( + title="Sword Art Online", + season=[ + SequenceNumber( + number=2 + ) + ] + ), + SeriesInfo( + title="Sword Art Online", + season=[ + SequenceNumber( + number=3 + ) + ] + ), + SeriesInfo( + title="Sword Art Online", + season=[ + SequenceNumber( + number=4 + ) + ] + ), + SeriesInfo( + title="Sword Art Online Alternative" + ), + SeriesInfo( + title="Sword Art Online", + type="Movies" + ), + SeriesInfo( + title="Sword Art Online", + type="Specials" + ), + SeriesInfo( + title="Sword Art Online", + type="OVAs" + ) + ], + source=[ + "BD" + ], + subs_term=[ + "Sub" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "HEVC", + "10bit", + "x265" + ] + ), + Metadata( + file_name="SNK Anime (1993-2018) - Art of Fighting, Fatal Fury, Samurai Shodown, The King of Fighters - 480p-720p x264", + unknown=[ + "Art of Fighting", + "Fatal Fury", + "Samurai Shodown", + "The King of Fighters" + ], + series=[ + SeriesInfo( + title="SNK Anime", + year=[ + SequenceRange( + start=SequenceNumber( + number=1993 + ), + end=SequenceNumber( + number=2018 + ) + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=480, + scan_method="p" + ), + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + file_name="[Judas] Kaguya-Sama Wa Kokurasetai - S03E13v2 (S03E12 Part 2).mkv", + release_group=[ + "Judas" + ], + series=[ + SeriesInfo( + title="Kaguya-Sama Wa Kokurasetai", + season=[ + SequenceNumber( + number=3 + ) + ], + episode=[ + SequenceNumber( + alternative=[ + SequenceNumber( + number=12, + part="2" + ) + ], + number=13, + release_version="2" + ) + ] + ) + ] + ), + # --- Torrent name samples with | alternatives --- + Metadata( + audio_term=[ + "AAC" + ], + file_checksum="CC841FA8", + file_name="[Yameii] TRIGUN STAMPEDE - S02E04 [English Dub] [CR WEB-DL 1080p H264 AAC] [CC841FA8] (TRIGUN STARGAZE | Season 2 | S2)", + language=[ + "English" + ], + release_group=[ + "Yameii" + ], + series=[ + SeriesInfo( + title="TRIGUN STAMPEDE", + season=[ + SequenceNumber( + number=2 + ) + ], + episode=[ + SequenceNumber( + number=4 + ) + ] + ), + SeriesInfo( + title="TRIGUN STARGAZE" + ) + ], + source=[ + "WEB-DL" + ], + subs_term=[ + "Dub" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "H264" + ] + ), + Metadata( + audio_term=[ + "DTS-HD MA", + "Dual Audio" + ], + file_name="[ZQ] Street Fighter II: The Animated Movie (1994) (BD Remux 1080p x264 8-bit DTS-HD MA) [Dual Audio] | Street Fighter II: The Movie", + release_group=[ + "ZQ" + ], + series=[ + SeriesInfo( + title="Street Fighter II: The Animated Movie", + year=[ + SequenceNumber( + number=1994 + ) + ] + ), + SeriesInfo( + title="Street Fighter II: The Movie" + ) + ], + release_information=[ + "Remux" + ], + source=[ + "BD" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264", + "8-bit" + ] + ), + Metadata( + audio_term=[ + "FLAC", + "Dual Audio" + ], + file_name="[UnHoly] Kaiju No. 8 Season 2 (BD Remux 1080p x264 8-bit FLAC) [Dual Audio] | Kaijuu 8-gou 2nd Season", + release_group=[ + "UnHoly" + ], + series=[ + SeriesInfo( + title="Kaiju No. 8", + season=[ + SequenceNumber( + number=2 + ) + ] + ), + SeriesInfo( + title="Kaijuu 8-gou 2nd Season" + ) + ], + release_information=[ + "Remux" + ], + source=[ + "BD" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264", + "8-bit" + ], + ), + Metadata( + audio_term=[ + "AAC" + ], + file_name="[TROLLORANGE] Hell Girl Season 4 (CR WEB-DL 1080p x264 AAC) | Hell Girl: Fourth Twilight | Jigoku Shoujo: Yoi no Togi", + release_group=[ + "TROLLORANGE" + ], + series=[ + SeriesInfo( + title="Hell Girl", + season=[ + SequenceNumber( + number=4 + ) + ] + ), + SeriesInfo( + title="Hell Girl: Fourth Twilight" + ), + SeriesInfo( + title="Jigoku Shoujo: Yoi no Togi" + ) + ], + source=[ + "WEB-DL" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + audio_term=[ + "FLAC", + "Dual Audio" + ], + file_name="[Headpatter] Why Raeliana Ended Up at the Duke's Mansion Season 1 (BD Remux 1080p x264 8-bit FLAC [Dual Audio] | Kanojo ga Koushaku-tei ni Itta Riyuu", + release_group=[ + "Headpatter" + ], + series=[ + SeriesInfo( + title="Why Raeliana Ended Up at the Duke's Mansion", + season=[ + SequenceNumber( + number=1 + ) + ] + ), + SeriesInfo( + title="Kanojo ga Koushaku-tei ni Itta Riyuu" + ) + ], + release_information=[ + "Remux" + ], + source=[ + "BD" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264", + "8-bit" + ] + ), + Metadata( + audio_term=[ + "FLAC" + ], + file_name="[Moxie] We Never Learn: BOKUBEN S00E01-E02 (BD Remux 1080p x264 8-bit FLAC) | We Never Learn OVAs | Bokutachi wa Benkyou ga Dekinai OVAs", + release_group=[ + "Moxie" + ], + series=[ + SeriesInfo( + title="We Never Learn: BOKUBEN", + season=[ + SequenceNumber( + number=0 + ) + ], + episode=[ + SequenceRange( + start=SequenceNumber( + number=1 + ), + end=SequenceNumber( + number=2 + ) + ) + ] + ), + SeriesInfo( + title="We Never Learn OVA" + ), + SeriesInfo( + title="Bokutachi wa Benkyou ga Dekinai OVA" + ) + ], + release_information=[ + "Remux" + ], + source=[ + "BD" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264", + "8-bit" + ] + ), + Metadata( + file_name="[Furretar] 中文配 - 明日方舟 (Arknights) (Season 1-3) (Japanese + Mandarin Chinese Dub) (Matching Soft Subs) (Batch) [國語][国语][中文配音] (Prelude to Dawn) (Perish in Frost) (Rise from Ember) (Reimei Zensou) (Touin Kiro) (Enshin Shomei)", + language=[ + "Japanese", + "Mandarin", + "Chinese" + ], + release_group=[ + "Furretar" + ], + release_information=[ + "Batch" + ], + series=[ + SeriesInfo( + title="\u660e\u65e5\u65b9\u821f", + season=[ + SequenceRange( + start=SequenceNumber( + number=1 + ), + end=SequenceNumber( + number=3 + ) + ) + ] + ) + ], + subs_term=[ + "Dub", + "Soft Subs" + ], + unknown=[ + "Arknights", + "Matching", + "Prelude to Dawn", + "Perish in Frost", + "Rise from Ember", + "Reimei Zensou", + "Touin Kiro", + "Enshin Shomei" + ] + ), + Metadata( + file_name="[Furretar] 台粤配 - 进击的巨人 (Attack on Titan) (Season 1-4 + Kanketsu-hen + OVA + Junior High) (Taiwanese Mandarin + Cantonese Chinese Dub) (Matching Soft Subs) [國語][粵語][国语][中配版] (進擊的巨人) (Shingeki no Kyojin) (The Last Attack) (Final Chapters)", + language=[ + "Mandarin", + "Cantonese", + "Chinese" + ], + release_group=[ + "Furretar" + ], + series=[ + SeriesInfo( + title="进击的巨人", + season=[ + SequenceRange( + start=SequenceNumber( + number=1 + ), + end=SequenceNumber( + number=4 + ) + ) + ] + ) + ], + subs_term=[ + "Dub", + "Soft Subs" + ], + unknown=[ + "Attack on Titan", + "Kanketsu", + "hen", + "High", + "Taiwanese", + "Matching", + "\u9032\u64ca\u7684\u5de8\u4eba", + "Shingeki no Kyojin", + "The Last Attack", + "Chapters" + ] + ) +] diff --git a/tests/fixtures/content_types.py b/tests/fixtures/content_types.py new file mode 100644 index 0000000..e3afe03 --- /dev/null +++ b/tests/fixtures/content_types.py @@ -0,0 +1,252 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata + +content_types = [ + Metadata( + file_name="[Judas] Kimetsu no Yaiba - NCED02 (ep 19)", + release_group=[ + "Judas" + ], + series=[ + SeriesInfo( + title="Kimetsu no Yaiba", + episode=[ + SequenceNumber( + number=19 + ) + ], + content_type=[ + SequenceIdentifierNumber( + identifier="NCED", + number=2 + ) + ] + ) + ] + ), + Metadata( + file_name="[judas] heavy object - nced 01", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="heavy object", + content_type=[ + SequenceIdentifierNumber( + identifier="nced", + number=1 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "flac" + ], + file_checksum="327a2375", + file_name="[Coalgirls]_bakemonogatari_op4a_(1280x720_blu-ray_flac)_[327a2375]", + release_group=[ + "Coalgirls" + ], + series=[ + SeriesInfo( + title="bakemonogatari", + content_type=[ + SequenceIdentifierNumber( + identifier="op", + number=4, + part="a" + ) + ] + ) + ], + source=[ + "blu-ray" + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ] + ), + Metadata( + file_name="[judas] black clover - ncop 10b - black catcher", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="black clover", + content_type=[ + SequenceIdentifierNumber( + number=10, + identifier="ncop", + title="black catcher", + part="b" + ) + ] + ) + ] + ), + Metadata( + file_name="[judas] nogizaka haruka no himitsu - ova ncop", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="nogizaka haruka no himitsu", + type="ova", + content_type=[ + SequenceIdentifierNumber( + identifier="ncop" + ) + ] + ) + ] + ), + Metadata( + file_name="[judas] granblue fantasy - clean ending", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="granblue fantasy", + content_type=[ + SequenceIdentifierNumber( + identifier="clean ending" + ) + ] + ) + ] + ), + Metadata( + file_name="[AnimeRG] Witch Craft Works - EDv2 [1080p] [x265] [pseudo]", + release_group=[ + "AnimeRG" + ], + series=[ + SeriesInfo( + title="Witch Craft Works", + content_type=[ + SequenceIdentifierNumber( + identifier="ED", + release_version="2" + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x265" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="3b65d1e6", + file_name="[coalgirls]_toradora_ed2_(704x480_dvd_aac)_[3b65d1e6]", + release_group=[ + "coalgirls" + ], + series=[ + SeriesInfo( + title="toradora", + content_type=[ + SequenceIdentifierNumber( + identifier="ed", + number=2 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=480, + video_width=704, + ) + ], + source=[ + "dvd" + ] + ), + Metadata( + file_checksum="ca57f300", + file_name="[asenshi] rozen maiden 3 - PV [ca57f300]", + release_group=[ + "asenshi" + ], + series=[ + SeriesInfo( + title="rozen maiden 3", + content_type=[ + SequenceIdentifierNumber( + identifier="PV" + ) + ] + ) + ] + ), + Metadata( + file_name="[fff] love live! the school idol movie - pv [d1a15d2c]", + file_checksum="d1a15d2c", + release_group=[ + "fff" + ], + series=[ + SeriesInfo( + title="love live! the school idol movie", + content_type=[ + SequenceIdentifierNumber( + identifier="pv" + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "flac" + ], + file_name="[ane] yosuga no sora - ep01 preview (yorihime ver) [bdrip 1080p x264 flac]", + release_group=[ + "ane" + ], + series=[ + SeriesInfo( + title="yosuga no sora", + episode=[ + SequenceNumber( + number=1 + ) + ], + content_type=[ + SequenceIdentifierNumber( + release_version="yorihime" + ) + ] + ) + ], + source=[ + "bdrip" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264" + ] + ) +] diff --git a/tests/fixtures/episode_titles.py b/tests/fixtures/episode_titles.py new file mode 100644 index 0000000..194fdec --- /dev/null +++ b/tests/fixtures/episode_titles.py @@ -0,0 +1,461 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata + +episode_titles = [ + Metadata( + file_name="01 - land of visible pain", + series=[ + SeriesInfo( + episode=[ + SequenceNumber( + number=1, + title="land of visible pain" + ) + ] + ) + ] + ), + Metadata( + file_name="attack on titan - episode 3 - a dim light amid despair / humanity's comeback, part 1", + series=[ + SeriesInfo( + title="attack on titan", + episode=[ + SequenceNumber( + number=3, + title="a dim light amid despair / humanity's comeback, part 1" + ) + ] + ) + ] + ), + Metadata( + file_name="ep. 01 - the boy in the iceberg", + series=[ + SeriesInfo( + episode=[ + SequenceNumber( + number=1, + title="the boy in the iceberg" + ) + ] + ) + ] + ), + Metadata( + file_name="fate_stay_night_ep05_the_two_magi_part1_[720p,bluray,x264]_-_thora", + release_group=[ + "thora" + ], + series=[ + SeriesInfo( + title="fate stay night", + episode=[ + SequenceNumber( + number=5, + title="the two magi part 1" + ) + ] + ) + ], + source=[ + "bluray" + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + file_name="dramatical murder episode 1 - data_01_login", + series=[ + SeriesInfo( + title="dramatical murder", + episode=[ + SequenceNumber( + number=1, + title="data 01 login" + ) + ] + ) + ] + ), + Metadata( + file_name="k-on!!_ep08_career_plan!_[1080p,bluray,x264]_-_thora", + release_group=[ + "thora" + ], + series=[ + SeriesInfo( + title="k-on!!", + episode=[ + SequenceNumber( + number=8, + title="career plan!" + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + source=[ + "bluray" + ], + video_term=[ + "x264" + ] + ), + Metadata( + file_name="toradora!-03-your-song", + series=[ + SeriesInfo( + title="toradora!", + episode=[ + SequenceNumber( + number=3, + title="your song" + ) + ] + ) + ] + ), + Metadata( + file_name="toradora-e03-your-song", + series=[ + SeriesInfo( + title="toradora", + episode=[ + SequenceNumber( + number=3, + title="your song" + ) + ] + ) + ] + ), + Metadata( + file_name="tsuredure children episódio 1 – confession", + series=[ + SeriesInfo( + title="tsuredure children", + episode=[ + SequenceNumber( + number=1, + title="confession" + ) + ] + ) + ] + ), + Metadata( + file_name="[5f] rwby - 14 forever fall part 2 pt-br", + language=[ + "pt-br" + ], + release_group=[ + "5f" + ], + series=[ + SeriesInfo( + title="rwby", + episode=[ + SequenceNumber( + number=14, + title="forever fall part 2" + ) + ] + ) + ] + ), + Metadata( + file_checksum="cbdb8577", + file_name="[acx]neon_genesis_evangelion_-_platinum_-_06_-_showdown_in_tokyo_3_[saintdeath]_[cbdb8577]", + release_group=[ + "acx" + ], + series=[ + SeriesInfo( + title="neon genesis evangelion - platinum", + episode=[ + SequenceNumber( + number=6, + title="showdown in tokyo 3" + ) + ] + ) + ], + unknown=[ + "saintdeath" + ] + ), + Metadata( + audio_term=[ + "ac3" + ], + file_checksum="650b731b", + file_name="[ayu]_kiddy_grade_2_-_pilot_[h264_ac3][650b731b]", + release_group=[ + "ayu" + ], + series=[ + SeriesInfo( + title="kiddy grade 2" + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + file_checksum="8f59f2ba", + file_name="[bm&t] toradora! - 07v2 - pool opening [720p hi10 ] [bd] [8f59f2ba]", + release_group=[ + "bm&t" + ], + series=[ + SeriesInfo( + title="toradora!", + episode=[ + SequenceNumber( + number=7, + release_version="2", + title="pool opening" + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + source=[ + "bd" + ], + video_term=[ + "hi10" + ] + ), + Metadata( + file_name="[judas] initial d - extra stage 02 - sentimental white", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="initial d - extra stage", + episode=[ + SequenceNumber( + number=2, + title="sentimental white" + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="4a9ab85f", + file_name="[ninjapanda] tiger & bunny #01 all's well that ends well. (v3, 1080p hi10p, da aac) [4a9ab85f]", + release_group=[ + "ninjapanda" + ], + release_version=[ + "3" + ], + series=[ + SeriesInfo( + title="tiger & bunny", + episode=[ + SequenceNumber( + number=1, + title="all's well that ends well." + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "hi10p" + ], + unknown=[ + "da" + ] + ), + Metadata( + audio_term=[ + "dual audio", + "flac" + ], + file_checksum="8a45634b", + file_name="[raizel] persona 4 the animation episode 13 - a stormy summer vacation part 1 [bd_1080p_dual_audio_flac_hi10p][8a45634b]", + release_group=[ + "raizel" + ], + series=[ + SeriesInfo( + title="persona 4 the animation", + episode=[ + SequenceNumber( + number=13, + title="a stormy summer vacation part 1" + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "hi10p" + ] + ), + Metadata( + audio_term=[ + "flac" + ], + file_checksum="1234abcd", + file_name="[taigasubs]_toradora!_(2008)_-_01v2_-_tiger_and_dragon_[1280x720_h.264_flac][1234abcd]", + release_group=[ + "taigasubs" + ], + series=[ + SeriesInfo( + title="toradora!", + year=[ + SequenceNumber( + number=2008 + ) + ], + episode=[ + SequenceNumber( + number=1, + release_version="2", + title="tiger and dragon" + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ], + video_term=[ + "h.264" + ] + ), + Metadata( + file_checksum="b99c8ded", + file_name="[zom-b] machine-doll wa kizutsukanai - 01 - facing ''cannibal candy'' i (kuroi, fff remux) [b99c8ded]", + release_group=[ + "zom-b" + ], + release_information=[ + "remux" + ], + series=[ + SeriesInfo( + title="machine-doll wa kizutsukanai", + episode=[ + SequenceNumber( + number=1, + title="facing 'cannibal candy' i" + ) + ] + ) + ], + unknown=[ + "kuroi", + "fff" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="f7df16f7", + file_name="[zurako] sora no woto - 07.5 - drinking party - fortress battle (bd 1080p aac) [f7df16f7]", + release_group=[ + "zurako" + ], + series=[ + SeriesInfo( + title="sora no woto", + episode=[ + SequenceNumber( + number=7.5, + title="drinking party - fortress battle" + ) + ] + ) + ], + source=[ + "bd" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[chibi-doki] seikon no qwaser - 13v0 (uncensored director's cut) [988db090]", + file_checksum="988db090", + release_group=[ + "chibi-doki" + ], + series=[ + SeriesInfo( + title="seikon no qwaser", + episode=[ + SequenceNumber( + number=13, + release_version="0", + title="uncensored director's cut" + ) + ] + ) + ] + ), + Metadata( + file_name="The Eminence in Shadow vol 2 06 Pretenders", + series=[ + SeriesInfo( + title="The Eminence in Shadow", + episode=[ + SequenceNumber( + number=6, + title="Pretenders" + ) + ], + volume=[ + SequenceNumber( + number=2 + ) + ] + ) + ] + ) +] diff --git a/tests/fixtures/folder_context.py b/tests/fixtures/folder_context.py new file mode 100644 index 0000000..9c706ee --- /dev/null +++ b/tests/fixtures/folder_context.py @@ -0,0 +1,140 @@ +from aniparse.output_schemas import SequenceNumber, SeriesInfo, Metadata +from tests.conftest import Fixture + +folder_context = [ + # 1. Bare episode filename — folder provides title + Fixture( + path="My Series/01.mkv", + metadata=Metadata( + file_name="01.mkv", + file_extension="mkv", + series=[ + SeriesInfo( + title="My Series", + episode=[SequenceNumber(number=1)], + ) + ], + ), + ), + # 2. Folder with year + Fixture( + path="My Series (2024)/01.mkv", + metadata=Metadata( + file_name="01.mkv", + file_extension="mkv", + series=[ + SeriesInfo( + title="My Series", + year=[SequenceNumber(number=2024)], + episode=[SequenceNumber(number=1)], + ) + ], + ), + ), + # 3. Nested folders: title + season from folders, episode from file + Fixture( + path="My Series/Season 2/01.mkv", + metadata=Metadata( + file_name="01.mkv", + file_extension="mkv", + series=[ + SeriesInfo( + title="My Series", + season=[SequenceNumber(number=2)], + episode=[SequenceNumber(number=1)], + ) + ], + ), + ), + # 4. Multi-season outer narrowed by inner season folder + Fixture( + path="series s1+s2+s3/season1/01 - surge.mkv", + metadata=Metadata( + file_name="01 - surge.mkv", + file_extension="mkv", + series=[ + SeriesInfo( + title="series", + season=[SequenceNumber(number=1)], + episode=[SequenceNumber(number=1, title="surge")], + ) + ], + ), + ), + # 5. Bare number folder as season (not episode) + Fixture( + path="My Series/1/01.mkv", + metadata=Metadata( + file_name="01.mkv", + file_extension="mkv", + series=[ + SeriesInfo( + title="My Series", + season=[SequenceNumber(number=1)], + episode=[SequenceNumber(number=1)], + ) + ], + ), + ), + # 6. Bare number folder narrows multi-season outer + Fixture( + path="series s1+s2+s3/1/01 - surge.mkv", + metadata=Metadata( + file_name="01 - surge.mkv", + file_extension="mkv", + series=[ + SeriesInfo( + title="series", + season=[SequenceNumber(number=1)], + episode=[SequenceNumber(number=1, title="surge")], + ) + ], + ), + ), + # 7. Filename has its own title — folder title does NOT override + Fixture( + path="Other Title/[Group] My Title - 01 [1080p].mkv", + metadata=Metadata( + file_name="[Group] My Title - 01 [1080p].mkv", + file_extension="mkv", + release_group=["Group"], + video_resolution=[], # just checking title isn't overridden + series=[ + SeriesInfo( + title="My Title", + episode=[SequenceNumber(number=1)], + ) + ], + ), + ), + # 8. Folder with season inline (S2), episode + title from file + Fixture( + path="My Series S2/01 - Episode Title.mkv", + metadata=Metadata( + file_name="01 - Episode Title.mkv", + file_extension="mkv", + series=[ + SeriesInfo( + title="My Series", + season=[SequenceNumber(number=2)], + episode=[SequenceNumber(number=1, title="Episode Title")], + ) + ], + ), + ), + # 9. Folder with type (OVA) + Fixture( + path="My Series/OVA/01.mkv", + metadata=Metadata( + file_name="01.mkv", + file_extension="mkv", + series=[ + SeriesInfo( + title="My Series", + type="OVA", + episode=[SequenceNumber(number=1)], + ) + ], + ), + ), +] diff --git a/tests/fixtures/ranges.py b/tests/fixtures/ranges.py new file mode 100644 index 0000000..6b4f986 --- /dev/null +++ b/tests/fixtures/ranges.py @@ -0,0 +1,220 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata + +ranges = [ + Metadata( + file_checksum="2411959b", + file_name="detective conan - 316-317 [dctp][2411959b]", + release_group=[ + "dctp" + ], + series=[ + SeriesInfo( + title="detective conan", + episode=[ + SequenceRange( + start=SequenceNumber( + number=316 + ), + end=SequenceNumber( + number=317 + ) + ) + ] + ), + ] + ), + Metadata( + file_name="dragon_ball_z_movies_8_&_10_[720p,bluray,dts,x264]_-_thora", + release_group=[ + "thora" + ], + series=[ + SeriesInfo( + title="dragon ball z movies 8 & 10" + ) + ], + source=[ + "bluray" + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + audio_term=[ + "dts" + ], + video_term=[ + "x264" + ] + ), + Metadata( + file_name="to_aru_kagaku_no_railgun_13-15_[bd_1080p][atsa]", + release_group=[ + "atsa" + ], + series=[ + SeriesInfo( + title="to aru kagaku no railgun", + episode=[ + SequenceRange( + start=SequenceNumber( + number=13 + ), + end=SequenceNumber( + number=15 + ) + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[horriblesubs] momokuri - 01+02 [720p]", + release_group=[ + "horriblesubs" + ], + series=[ + SeriesInfo( + title="momokuri", + episode=[ + SequenceNumber( + number=1 + ), + SequenceNumber( + number=2 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[horriblesubs] tsukimonogatari - (01-04) [1080p]", + release_group=[ + "horriblesubs" + ], + series=[ + SeriesInfo( + title="tsukimonogatari", + episode=[ + SequenceRange( + start=SequenceNumber( + number=1 + ), + end=SequenceNumber( + number=4 + ) + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[judas] gintama - s01e01-e02 (025-026)", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="gintama", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceRange( + start=SequenceNumber( + number=1 + ), + end=SequenceNumber( + number=2 + ) + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "flac" + ], + file_name="[tsundere] hyouka - 01v2-04 [bdrip h264 1920x1080 10bit flac]", + release_group=[ + "tsundere" + ], + series=[ + SeriesInfo( + title="hyouka", + episode=[ + SequenceRange( + start=SequenceNumber( + number=1, + release_version="2" + ), + end=SequenceNumber( + number=4 + ) + ) + ] + ) + ], + source=[ + "bdrip" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + video_width=1920 + ) + ], + video_term=[ + "h264", + "10bit" + ] + ), + Metadata( + file_name="[horriblesubs] momokuri - 01+07 [720p]", + release_group=[ + "horriblesubs" + ], + series=[ + SeriesInfo( + title="momokuri", + episode=[ + SequenceNumber( + number=1 + ), + SequenceNumber( + number=7 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ) +] diff --git a/tests/fixtures/release_info.py b/tests/fixtures/release_info.py new file mode 100644 index 0000000..d673cb3 --- /dev/null +++ b/tests/fixtures/release_info.py @@ -0,0 +1,140 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata + +release_info = [ + Metadata( + file_name="vol.01", + series=[ + SeriesInfo( + volume=[ + SequenceNumber( + number=1 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "flac" + ], + file_checksum="c09462e2", + file_name="[evetaku] akb0048 vol.03 - making of kibou-ni-tsuite music video (bdrip 1080i h.264-hi10p flac)[c09462e2]", + release_group=[ + "evetaku" + ], + series=[ + SeriesInfo( + title="akb0048", + volume=[ + SequenceNumber( + number=3, + title="making of kibou-ni-tsuite music video" + ) + ] + ) + ], + source=[ + "bdrip" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="i" + ) + ], + video_term=[ + "h.264", + "hi10p" + ] + ), + Metadata( + audio_term=[ + "flac" + ], + file_name="[gs] classroom crisis vol.1&2 (bd 1080p 10bit flac)", + release_group=[ + "gs" + ], + series=[ + SeriesInfo( + title="classroom crisis", + volume=[ + SequenceNumber( + number=1 + ), + SequenceNumber( + number=2 + ) + ] + ) + ], + source=[ + "bd" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "10bit" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_name="[harunatsu] classroom crisis - vol.1 [bd 720p-aac]", + release_group=[ + "harunatsu" + ], + series=[ + SeriesInfo( + title="classroom crisis", + volume=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_name="[tlacatlc6] natsume yuujinchou shi vol. 1v2 & vol. 2 (bd 1280x720 x264 aac)", + release_group=[ + "tlacatlc6" + ], + series=[ + SeriesInfo( + title="natsume yuujinchou shi", + volume=[ + SequenceNumber( + number=1, + release_version="2"), + SequenceNumber( + number=2 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ], + video_term=[ + "x264" + ] + ) +] diff --git a/tests/fixtures/seasons.py b/tests/fixtures/seasons.py new file mode 100644 index 0000000..938c8d1 --- /dev/null +++ b/tests/fixtures/seasons.py @@ -0,0 +1,540 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata + +seasons = [ + Metadata( + file_name="after war gundam x - 1x03 - my mount is fierce!", + series=[ + SeriesInfo( + title="after war gundam x", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceNumber( + number=3, + title="my mount is fierce!" + ) + ] + ) + ] + ), + Metadata( + file_name="attack on titan season 3", + series=[ + SeriesInfo( + title="attack on titan", + season=[ + SequenceNumber( + number=3 + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac", + "5.1" + ], + file_name="ghost_in_the_shell_stand_alone_complex_2nd_gig_ep05v2_excavation_[720p,hdtv,x264,aac_5.1]_-_thora", + release_group=[ + "thora" + ], + release_version=[ + "2" + ], + series=[ + SeriesInfo( + title="ghost in the shell stand alone complex 2nd gig", + episode=[ + SequenceNumber( + number=5, + release_version="2", + title="excavation" + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + file_name="hayate no gotoku 2nd season 24 (blu-ray 1080p) [chihiro]", + release_group=[ + "chihiro" + ], + series=[ + SeriesInfo( + title="hayate no gotoku", + season=[ + SequenceNumber( + number=2 + ) + ], + episode=[ + SequenceNumber( + number=24 + ) + ] + ) + ], + source=[ + "blu-ray" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ] + ), + Metadata( + file_name="mobile_suit_gundam_00_season_2_ep07_a_reunion_and_a_parting_[1080p,bluray,x264]_-_thora", + release_group=[ + "thora" + ], + series=[ + SeriesInfo( + title="mobile suit gundam 00", + season=[ + SequenceNumber( + number=2 + ) + ], + episode=[ + SequenceNumber( + number=7, + title="a reunion and a parting" + ) + ] + ) + ], + source=[ + "bluray" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + file_name="noragami - s02e06 - what must be done [episode 6]", + series=[ + SeriesInfo( + title="noragami", + season=[ + SequenceNumber( + number=2 + ) + ], + episode=[ + SequenceNumber( + number=6, + title="what must be done" + ) + ] + ) + ] + ), + Metadata( + file_checksum="bd841253", + file_name="ookiku furikabutte s2 - 09 (central anime) [bd841253]", + release_group=[ + "central anime" + ], + series=[ + SeriesInfo( + title="ookiku furikabutte", + season=[ + SequenceNumber( + number=2 + ) + ], + episode=[ + SequenceNumber( + number=9 + ) + ] + ) + ] + ), + Metadata( + file_name="the irregular at magic high school - s01e01- enrollment part i", + series=[ + SeriesInfo( + title="the irregular at magic high school", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceNumber( + number=1, + title="enrollment part i" + ) + ] + ) + ] + ), + Metadata( + file_name="toradora! s01e03-your song", + series=[ + SeriesInfo( + title="toradora!", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceNumber( + number=3, + title="your song" + ) + ] + ) + ] + ), + Metadata( + file_name="[(´• ω •`)] nintama rantarou - s23e1821 - buddhist priest-sama is a ninja", + release_group=[ + "(´• ω •`)" + ], + series=[ + SeriesInfo( + title="nintama rantarou", + season=[ + SequenceNumber( + number=23 + ) + ], + episode=[ + SequenceNumber( + number=1821, + title="buddhist priest-sama is a ninja" + ) + ] + ) + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="bb7c6531", + file_name="[0x539] somali and the forest spirit - s01e01 (web 1080p hi10p aac) [bb7c6531]", + release_group=[ + "0x539" + ], + series=[ + SeriesInfo( + title="somali and the forest spirit", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + source=[ + "web" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "hi10p" + ] + ), + Metadata( + audio_term=[ + "multi-audio" + ], + file_checksum="24b349d4", + file_name="[dragsterps] devilman crybaby s01e04 [720p] [multi-audio] [multi-subs] [24b349d4]", + release_group=[ + "dragsterps" + ], + series=[ + SeriesInfo( + title="devilman crybaby", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceNumber( + number=4 + ) + ] + ) + ], + subs_term=[ + "multi-subs" + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + file_checksum="bd00f464", + file_name="[ember] natsu no arashi! - s01e01-playback part 2 [bd00f464]", + release_group=[ + "ember" + ], + series=[ + SeriesInfo( + title="natsu no arashi!", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceNumber( + number=1, + title="playback part 2" + ) + ] + ) + ] + ), + Metadata( + file_name="[horriblesubs] mob psycho 100 s2 - 07 [1080p]", + release_group=[ + "horriblesubs" + ], + series=[ + SeriesInfo( + title="mob psycho 100", + season=[ + SequenceNumber( + number=2 + ) + ], + episode=[ + SequenceNumber( + number=7 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[judas] aharen-san wa hakarenai - s01e06v2", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="aharen-san wa hakarenai", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceNumber( + number=6, + release_version="2" + ) + ] + ) + ] + ), + Metadata( + file_name="[judas] grisaia no kajitsu - s01s02", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="grisaia no kajitsu", + season=[ + SequenceNumber( + number=1 + ), + SequenceNumber( + number=2 + ) + ] + ) + ] + ), + Metadata( + file_name="[judas] one punch man - s01ova02", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="one punch man", + type="ova", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceNumber( + number=2 + ) + ] + ) + ] + ), + Metadata( + file_name="[judas] one-punch man season 1 ova - 05", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="one-punch man", + type="ova", + season=[ + SequenceNumber( + number=1 + ) + ], + episode=[ + SequenceNumber( + number=5 + ) + ] + ) + ] + ), + Metadata( + file_name="[sfw]_queen's_blade_s2", + release_group=[ + "sfw" + ], + series=[ + SeriesInfo( + title="queen's blade", + season=[ + SequenceNumber( + number=2 + ) + ] + ) + ] + ), + Metadata( + file_name="[zza] nanatsu no taizai - s03 - kamigami no gekirin - 21 [1080p.x265]", + release_group=[ + "zza" + ], + series=[ + SeriesInfo( + title="nanatsu no taizai", + season=[ + SequenceNumber( + number=3, + title="kamigami no gekirin" + ) + ], + episode=[ + SequenceNumber( + number=21 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080 + ) + ], + video_term=[ + "x265" + ] + ), + Metadata( + audio_term=[ + "FLAC", + "2.0", + "Dual Audio", + ], + file_name="A.Certain.Scientific.Railgun.S02.1080p-Hi10p.BluRay.FLAC2.0.x264-CTR (English Dubbed Dual Audio)", + language=[ + "English" + ], + series=[ + SeriesInfo( + title="A Certain Scientific Railgun", + season=[ + SequenceNumber( + number=2 + ) + ] + ) + ], + subs_term=[ + "Dubbed" + ], + source=[ + "BluRay" + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "Hi10p", + "x264" + ] + ), + Metadata( + file_name="[judas] amanchu! s1 - ending", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="amanchu!", + season=[ + SequenceNumber( + number=1 + ) + ] + ) + ] + ) +] diff --git a/tests/fixtures/series_types.py b/tests/fixtures/series_types.py new file mode 100644 index 0000000..001fbd2 --- /dev/null +++ b/tests/fixtures/series_types.py @@ -0,0 +1,241 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata + +series_types = [ + Metadata( + file_name="code_geass_r2_tv_[20_of_25]_[ru_jp]_[hdtv]_[varies_&_cuba77_&_animereactor_ru]", + language=[ + "ru", + "jp" + ], + release_group=[ + "varies", + "cuba77", + "animereactor_ru" + ], + series=[ + SeriesInfo( + title="code geass r2", + type="tv", + episode=[ + SequenceNumber( + number=20, + total=25 + ) + ] + ) + ], + source=[ + "hdtv" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_name="queen's blade utsukushiki toushi-tachi - ova_01 (bd 1280x720 avc aac)", + series=[ + SeriesInfo( + title="queen's blade utsukushiki toushi-tachi", + type="ova", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + source=[ + "bd" + ], + video_resolution=[ + VideoResolution( + video_height=720, + video_width=1280 + ) + ], + video_term=[ + "avc" + ] + ), + Metadata( + file_name="[animerg] ushio to tora (tv) - 02 [720p] [xcelent]", + release_group=[ + "animerg" + ], + series=[ + SeriesInfo( + title="ushio to tora", + type="tv", + episode=[ + SequenceNumber( + number=2 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ], + unknown=[ + "xcelent" + ] + ), + Metadata( + file_checksum="e9f43685", + file_name="[cms] magical☆star kanon 100% OVA[DVD][e9f43685]", + release_group=[ + "cms" + ], + series=[ + SeriesInfo( + title="magical☆star kanon 100% ova" + ) + ], + source=[ + "DVD" + ] + ), + Metadata( + audio_term=[ + "aac" + ], + file_checksum="f1ff8588", + file_name="[fff] seirei tsukai no blade dance - sp01 [bd][720p-aac][f1ff8588]", + release_group=[ + "fff" + ], + series=[ + SeriesInfo( + title="seirei tsukai no blade dance", + type="sp", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + source=[ + "bd" + ], + video_resolution=[ + VideoResolution( + video_height=720, + scan_method="p" + ) + ] + ), + Metadata( + file_name="[hakata ramen] cells at work! special", + release_group=[ + "hakata ramen" + ], + series=[ + SeriesInfo( + title="cells at work! special" + ) + ] + ), + Metadata( + file_name="[judas] code geass movie 02", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="code geass", + type="movie", + episode=[ + SequenceNumber( + number=2 + ) + ] + ) + ] + ), + Metadata( + file_name="[judas] code geass movie 02 - lelouch of the rebellion - transgression", + release_group=[ + "judas" + ], + series=[ + SeriesInfo( + title="code geass", + type="movie", + episode=[ + SequenceNumber( + number=2, + title="lelouch of the rebellion - transgression" + ) + ] + ) + ] + ), + Metadata( + file_name="[kaf-team]_one_piece_movie_9_vostfr_hd", + language=[ + "vostfr" + ], + release_group=[ + "kaf-team" + ], + series=[ + SeriesInfo( + title="one piece movie 9" + ) + ], + video_term=[ + "hd" + ] + ), + Metadata( + audio_term=[ + "flac" + ], + file_checksum="6730d40a", + file_name="[seto_otaku]_aika_zero_ova_-_01_[bd][1920x1080_h264-flac][6730d40a]", + release_group=[ + "seto otaku" + ], + series=[ + SeriesInfo( + title="aika zero ova", + episode=[ + SequenceNumber( + number=1 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + video_width=1920 + ) + ], + video_term=[ + "h264" + ] + ), + Metadata( + file_name="Little Witch Academia (TV)", + series=[ + SeriesInfo( + title="Little Witch Academia", + type="TV" + ) + ] + ), + Metadata( + file_name="Arslan Senki (TV) OVA", + series=[ + SeriesInfo( + title="Arslan Senki", + type="TV" + ) + ] + ) +] diff --git a/tests/fixtures/table.py b/tests/fixtures/table.py index 759c34f..9338fd6 100644 --- a/tests/fixtures/table.py +++ b/tests/fixtures/table.py @@ -1,2152 +1,27 @@ -table = [ - { - "episode_number": 1, - "episode_title": "Land of Visible Pain", - "file_extension": "mkv", - "file_name": "01 - Land of Visible Pain.mkv" - }, - { - "anime_title": "Death Note", - "episode_number": 37, - "file_checksum": "6FA7D273", - "file_extension": "avi", - "file_index": 37, - "file_name": "37 [Ruberia]_Death_Note_-_37v2_[FINAL]_[XviD][6FA7D273].avi", - "release_information": "FINAL", - "release_group": "Ruberia", - "release_version": 2, - "video_term": "XviD" - }, - { - "anime_title": "Death Note", - "episode_number": 37, - "file_checksum": "6FA7D273", - "file_extension": "avi", - "file_index": 24, - "file_name": "24._Death_Note_-_37v2_[FINAL]_[XviD][6FA7D273].avi", - "release_information": "FINAL", - "release_version": 2, - "video_term": "XviD" - }, - { - "anime_title": "5 centimeters per second", - "audio_term": "flac", - "file_extension": "mkv", - "file_name": "5_centimeters_per_second[1904x1072.h264.flac][niizk].mkv", - "release_group": "niizk", - "video_resolution": "1904x1072", - "video_term": "h264" - }, - { - "anime_season": 1, - "anime_title": "After War Gundam X", - "episode_number": 3, - "episode_title": "My Mount is Fierce!", - "file_extension": "mkv", - "file_name": "After War Gundam X - 1x03 - My Mount is Fierce!.mkv" - }, - { - "anime_title": "Aim For The Top! Gunbuster", - "audio_term": "FLAC", - "episode_number": 1, - "episode_prefix": "ep", - "file_checksum": "69ECCDCF", - "file_extension": "mkv", - "file_name": "Aim_For_The_Top!_Gunbuster-ep1.BD(H264.FLAC.10bit)[KAA][69ECCDCF].mkv", - "release_group": "KAA", - "source": "BD", - "video_term": [ - "H264", - "10bit" - ] - }, - { - "anime_title": "Attack on Titan", - "anime_season": 3, - "anime_season_prefix": "Season", - "file_name": "Attack on Titan Season 3" - }, - { - "anime_title": "Attack on Titan", - "episode_number": 3, - "episode_prefix": "Episode", - "episode_title": "A Dim Light Amid Despair / Humanity's Comeback, Part 1", - "file_name": "Attack on Titan - Episode 3 - A Dim Light Amid Despair / Humanity's Comeback, Part 1" - }, - { - "anime_title": "Bakemonogatari", - "audio_term": "AACx2", - "episode_number": 1, - "file_extension": "mp4", - "file_name": "Bakemonogatari - 01 (BD 1280x720 AVC AACx2).mp4", - "source": "BD", - "video_resolution": "1280x720", - "video_term": "AVC" - }, - { - "anime_title": "Byousoku 5 Centimeter", - "audio_term": [ - "2.0ch", - "AAC" - ], - "file_name": "Byousoku 5 Centimeter [Blu-Ray][1920x1080 H.264][2.0ch AAC][SOFTSUBS]", - "source": "Blu-Ray", - "subtitles": "SOFTSUBS", - "video_resolution": "1920x1080", - "video_term": "H.264" - }, - { - "anime_title": "Chrono Crusade", - "episode_number": [ - 1, - 5 - ], - "episode_prefix": "ep.", - "file_name": "Chrono Crusade ep. 1-5" - }, - { - "anime_title": "Code Geass R2 TV", - "episode_number": 20, - "episode_total": 25, - "file_extension": "mkv", - "file_name": "Code_Geass_R2_TV_[20_of_25]_[ru_jp]_[HDTV]_[Varies_&_Cuba77_&_AnimeReactor_RU].mkv", - "other": [ - "Varies", - "&", - "Cuba77", - "&", - "AnimeReactor", - "RU" - ], - "release_group": "ru_jp", - "source": "HDTV" - }, - { - "anime_title": "Cyborg 009", - "anime_year": 1968, - "episode_number": 6, - "episode_prefix": "episode", - "file_checksum": "30C15D62", - "file_extension": "mp4", - "file_name": "Cyborg 009 (1968) [TSHS] episode 06 [30C15D62].mp4", - "release_group": "TSHS" - }, - { - "anime_title": "DRAMAtical Murder", - "episode_number": 1, - "episode_prefix": "Episode", - "episode_title": "Data 01 Login", - "file_name": "DRAMAtical Murder Episode 1 - Data_01_Login" - }, - { - "anime_title": "Detective Conan", - "episode_number": [ - 316, - 317 - ], - "file_checksum": "2411959B", - "file_extension": "mkv", - "file_name": "Detective Conan - 316-317 [DCTP][2411959B].mkv", - "release_group": "DCTP" - }, - { - "anime_title": "Dragon Ball KAI", - "episode_number": 1, - "file_extension": "mkv", - "file_name": "Dragon.Ball.KAI.-.01.-.1080p.BluRay.x264.DHD.mkv", - "other": "DHD", - "source": "BluRay", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_title": "Dragon Ball Z Movies", - "audio_term": "DTS", - "episode_number": [ - 8, - 10 - ], - "file_name": "Dragon_Ball_Z_Movies_8_&_10_[720p,BluRay,DTS,x264]_-_THORA", - "release_group": "THORA", - "source": "BluRay", - "video_resolution": "720p", - "video_term": "x264" - }, - { - "episode_number": 1, - "episode_prefix": "Ep.", - "episode_title": "The Boy in the Iceberg", - "file_name": "Ep. 01 - The Boy in the Iceberg" - }, - { - "anime_title": "Evangelion Shin Gekijouban Q", - "audio_term": [ - "FLACx2", - "5.1ch" - ], - "file_extension": "mkv", - "file_name": "Evangelion Shin Gekijouban Q (BDrip 1920x1080 x264 FLACx2 5.1ch)-ank.mkv", - "other": "ank", - "source": "BDrip", - "video_resolution": "1920x1080", - "video_term": "x264" - }, - { - "anime_title": "Evangelion The New Movie Q", - "audio_term": [ - "AACx2", - "5.1", - "2.0" - ], - "file_extension": "mp4", - "file_name": "Evangelion The New Movie Q (BD 1280x720 AVC AACx2 [5.1+2.0]).mp4", - "source": "BD", - "video_resolution": "1280x720", - "video_term": "AVC" - }, - { - "anime_title": "Evangelion 1.0 You Are [Not] Alone", - "file_name": "Evangelion_1.0_You_Are_[Not]_Alone_(1080p)_[@Home]", - "release_group": "@Home", - "video_resolution": "1080p" - }, - { - "anime_title": "Evangelion 1.11 You Are (Not) Alone", - "anime_year": 2009, - "audio_term": "DTS-ES", - "file_extension": "mkv", - "file_name": "Evangelion_1.11_You_Are_(Not)_Alone_(2009)_[1080p,BluRay,x264,DTS-ES]_-_THORA.mkv", - "release_group": "THORA", - "source": "BluRay", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_title": "Evangelion 1.11 You Are (Not) Alone", - "audio_term": "DTS-ES", - "file_extension": "mkv", - "file_name": "Evangelion_1.11_You_Are_(Not)_Alone_[1080p,BluRay,x264,DTS-ES]_-_THORA.mkv", - "release_group": "THORA", - "source": "BluRay", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_title": "Eve no Jikan 2", - "file_checksum": "88F4F7F0", - "file_extension": "mkv", - "file_name": "Eve no Jikan 2 [88F4F7F0].mkv" - }, - { - "anime_title": "EvoBot [Watakushi] Akuma no Riddle", - "episode_number": 1, - "file_checksum": "69A307A2", - "file_extension": "mkv", - "file_name": "EvoBot.[Watakushi]_Akuma_no_Riddle_-_01v2_[720p][69A307A2].mkv", - "release_version": 2, - "video_resolution": "720p" - }, - { - "anime_season": 6, - "anime_season_prefix": "S", - "anime_title": "Fairy Tail", - "episode_number": 32, - "episode_number_alt": 83, - "episode_prefix": [ - "E", - "Episode" - ], - "episode_title": "Tartaros Arc Iron Fist of the Fire Dragon", - "file_name": "Fairy Tail - S06E32 - Tartaros Arc Iron Fist of the Fire Dragon [Episode 83]" - }, - { - "anime_title": "Fate Stay Night", - "episode_number": 5, - "episode_prefix": "Ep", - "episode_title": "The Two Magi Part1", - "file_extension": "mkv", - "file_name": "Fate_Stay_Night_Ep05_The_Two_Magi_Part1_[720p,BluRay,x264]_-_THORA.mkv", - "release_group": "THORA", - "source": "BluRay", - "video_resolution": "720p", - "video_term": "x264" - }, - { - "anime_title": "Gekkan Shoujo Nozaki-kun", - "file_name": "Gekkan Shoujo Nozaki-kun [HorribleSubs] (1080p)", - "release_group": "HorribleSubs", - "video_resolution": "1080p" - }, - { - "anime_title": "Ghost in the Shell Stand Alone Complex 2nd GIG", - "audio_term": [ - "AAC", - "5.1" - ], - "episode_number": 5, - "episode_prefix": "Ep", - "episode_title": "EXCAVATION", - "file_extension": "mkv", - "file_name": "Ghost_in_the_Shell_Stand_Alone_Complex_2nd_GIG_Ep05v2_EXCAVATION_[720p,HDTV,x264,AAC_5.1]_-_THORA.mkv", - "release_group": "THORA", - "release_version": 2, - "source": "HDTV", - "video_resolution": "720p", - "video_term": "x264" - }, - { - "anime_title": "Ghost in the Shell Stand Alone Complex 2nd GIG", - "audio_term": [ - "AAC", - "5.1" - ], - "episode_number": 6, - "episode_prefix": "Ep", - "episode_title": "Pu239", - "file_extension": "mkv", - "file_name": "Ghost_in_the_Shell_Stand_Alone_Complex_2nd_GIG_Ep06_Pu239_[720p,HDTV,x264,AAC_5.1]_-_THORA.mkv", - "release_group": "THORA", - "source": "HDTV", - "video_resolution": "720p", - "video_term": "x264" - }, - { - "anime_title": "Gin'iro no Kami no Agito", - "anime_year": 2006, - "audio_term": "DTS", - "file_extension": "mkv", - "file_name": "Gin'iro_no_Kami_no_Agito_(2006)_[1080p,BluRay,x264,DTS]_-_THORA.mkv", - "release_group": "THORA", - "source": "BluRay", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_season": 2, - "anime_season_prefix": "Season", - "anime_title": "Hayate no Gotoku", - "episode_number": 24, - "file_name": "Hayate no Gotoku 2nd Season 24 (Blu-Ray 1080p) [Chihiro]", - "release_group": "Chihiro", - "source": "Blu-Ray", - "video_resolution": "1080p" - }, - { - "anime_title": "Howl's Moving Castle", - "anime_year": 2004, - "audio_term": [ - "flac", - "dts" - ], - "file_extension": "mkv", - "file_name": "Howl's_Moving_Castle_(2004)_[1080p,BluRay,flac,dts,x264]_-_THORA v2.mkv", - "release_group": "THORA", - "release_version": 2, - "source": "BluRay", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_title": "Juuni Kokki", - "episode_number": 5, - "episode_prefix": "Ep.", - "file_extension": "avi", - "file_name": "Juuni Kokki Ep.5.avi" - }, - { - "anime_title": "Juuni Kokki", - "episode_number": 5, - "episode_prefix": "Ep.", - "file_extension": "avi", - "file_name": "Juuni.Kokki.Ep.5.avi" - }, - { - "anime_title": "Juuousei", - "audio_term": "AAC", - "episode_number": 1, - "file_checksum": "803DA487", - "file_extension": "mkv", - "file_name": "Juuousei_-_01_[Black_Sheep][HDTV_H264_AAC][803DA487].mkv", - "release_group": "Black_Sheep", - "source": "HDTV", - "video_term": "H264" - }, - { - "anime_title": "K-ON!!", - "episode_number": 8, - "episode_prefix": "Ep", - "episode_title": "Career Plan!", - "file_extension": "mkv", - "file_name": "K-ON!!_Ep08_Career_Plan!_[1080p,BluRay,x264]_-_THORA.mkv", - "release_group": "THORA", - "source": "BluRay", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_title": "K-ON!", - "episode_number": 3, - "episode_prefix": "Ep", - "episode_title": "Training!", - "file_extension": "mkv", - "file_name": "K-ON!_Ep03_Training!_[1080p,BluRay,x264]_-_THORA.mkv", - "release_group": "THORA", - "source": "BluRay", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_title": "Kotonoha no Niwa", - "audio_term": [ - "AACx3", - '5.1', - '2.0', - '2.0' - ], - "file_extension": "mp4", - "file_name": "Kotonoha no Niwa (BD 1280x720 AVC AACx3 [5.1+2.0+2.0] Subx3).mp4", - "other": 'Subx3', - "source": "BD", - "video_resolution": "1280x720", - "video_term": "AVC" - }, - { - "anime_title": "Macross Frontier Sayonara no Tsubasa", - "file_checksum": "46B35E25", - "file_extension": "mkv", - "file_name": "Macross Frontier - Sayonara no Tsubasa (Central Anime, 720p) [46B35E25].mkv", - "other": [ - "Central", - "Anime" - ], - "video_resolution": "720p" - }, - { - "anime_title": "Magical Girl Lyrical Nanoha A's", - "audio_term": "AAC", - "episode_number": 1, - "file_checksum": "7A8A7769", - "file_extension": "mkv", - "file_name": "Magical Girl Lyrical Nanoha A's - 01.DVD[H264.AAC][DGz][7A8A7769].mkv", - "release_group": "DGz", - "source": "DVD", - "video_term": "H264" - }, - { - "anime_title": "Mary Bell", - "episode_number": 1, - "file_extension": "mkv", - "file_name": "Mary Bell (DVD) - 01v2 [h-b].mkv", - "release_group": "h-b", - "release_version": 2, - "source": "DVD" - }, - { - "anime_title": "Mary Bell", - "episode_number": 2, - "file_extension": "mkv", - "file_name": "Mary Bell (DVD) - 02 [h-b].mkv", - "release_group": "h-b", - "source": "DVD" - }, - { - "anime_season": 2, - "anime_season_prefix": "Season", - "anime_title": "Mobile Suit Gundam 00", - "episode_number": 7, - "episode_prefix": "Ep", - "episode_title": "A Reunion and a Parting", - "file_extension": "mkv", - "file_name": "Mobile_Suit_Gundam_00_Season_2_Ep07_A_Reunion_and_a_Parting_[1080p,BluRay,x264]_-_THORA.mkv", - "release_group": "THORA", - "source": "BluRay", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_title": "Neko no Ongaeshi", - "file_checksum": "0CDC2145", - "file_extension": "mkv", - "file_name": "Neko no Ongaeshi - [HQR.remux-DualAudio][NTV.1280x692.h264](0CDC2145).mkv", - "other": "NTV", - "release_group": "HQR.remux-DualAudio", - "video_resolution": "1280x692", - "video_term": "h264" - }, - { - "anime_title": "Noein", - "episode_number": 1, - "episode_total": 24, - "file_extension": "mkv", - "file_name": "Noein_[01_of_24]_[ru_jp]_[bodlerov_&_torrents_ru].mkv", - "other": [ - "bodlerov", - "&", - "torrents", - "ru" - ], - "release_group": "ru_jp" - }, - { - "anime_season": 2, - "anime_season_prefix": "S", - "anime_title": "Noragami", - "episode_number": 6, - "episode_prefix": [ - "E", - "Episode" - ], - "episode_title": "What Must Be Done", - "file_name": "Noragami - S02E06 - What Must Be Done [Episode 6]" - }, - { - "anime_season": 2, - "anime_season_prefix": "S", - "anime_title": "Ookiku Furikabutte", - "episode_number": 9, - "file_checksum": "BD841253", - "file_extension": "mkv", - "file_name": "Ookiku Furikabutte S2 - 09 (Central Anime) [BD841253].mkv", - "release_group": "Central Anime" - }, - { - "anime_title": "Queen's Blade Utsukushiki Toushi-tachi", - "anime_type": "OVA", - "audio_term": "AAC", - "episode_number": 1, - "file_extension": "mp4", - "file_name": "Queen's Blade Utsukushiki Toushi-tachi - OVA_01 (BD 1280x720 AVC AAC).mp4", - "source": "BD", - "video_resolution": "1280x720", - "video_term": "AVC" - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "The Irregular at Magic High School", - "episode_number": 1, - "episode_prefix": "E", - "episode_title": "Enrollment Part I", - "file_extension": "mkv", - "file_name": "The Irregular at Magic High School - S01E01- Enrollment Part I.mkv" - }, - { - "anime_title": "The iDOLM@STER 765 Pro to Iu Monogatari", - "file_extension": "mkv", - "file_name": "The iDOLM@STER 765 Pro to Iu Monogatari.mkv" - }, - { - "anime_title": "To Aru Kagaku no Railgun", - "episode_number": [ - 13, - 15 - ], - "file_name": "To_Aru_Kagaku_no_Railgun_13-15_[BD_1080p][AtsA]", - "release_group": "AtsA", - "source": "BD", - "video_resolution": "1080p" - }, - { - "anime_title": "Toradora! asdwd", - "episode_number": 3, - "episode_title": "Your-Song", - "file_extension": "mkv", - "file_name": "Toradora! asdwd-03-Your-Song.mkv" - }, - { - "anime_title": "Toradora", - "episode_number": 3, - "episode_prefix": "E", - "episode_title": "Your-Song", - "file_extension": "mkv", - "file_name": "Toradora-E03-Your-Song.mkv" - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "Toradora!", - "episode_number": 3, - "episode_prefix": "E", - "episode_title": "Your Song", - "file_extension": "mkv", - "file_name": "Toradora! S01E03-Your Song.mkv" - }, - { - "anime_title": "Tsuredure Children", - "episode_number": 1, - "episode_prefix": "Episódio", - "episode_title": "Confession", - "file_name": "Tsuredure Children Episódio 1 – Confession" - }, - { - "file_name": "Vol.01", - "volume_number": 1, - "volume_prefix": "Vol" - }, - { - "anime_season": 23, - "anime_season_prefix": "S", - "anime_title": "Nintama Rantarou", - "episode_number": 1821, - "episode_prefix": "E", - "episode_title": "Buddhist Priest-sama is a Ninja", - "file_extension": "mkv", - "file_name": "[(´• ω •`)] Nintama Rantarou - S23E1821 - Buddhist Priest-sama is a Ninja.mkv", - "release_group": "(´• ω •`)" - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "Somali and the Forest Spirit", - "audio_term": "AAC", - "episode_number": 1, - "episode_prefix": "E", - "file_checksum": "BB7C6531", - "file_extension": "mkv", - "file_name": "[0x539] Somali and the Forest Spirit - S01E01 (WEB 1080p Hi10P AAC) [BB7C6531].mkv", - "other": "WEB", - "release_group": "0x539", - "video_resolution": "1080p", - "video_term": "Hi10P" - }, - { - "anime_title": "Re:Zero kara Hajimeru Isekai Seikatsu", - "file_name": "[46620] Re:Zero kara Hajimeru Isekai Seikatsu [1080p AV1]", - "other": "AV1", - "release_group": "46620", - "video_resolution": "1080p" - }, - { - "anime_title": "RWBY", - "episode_number": 14, - "episode_title": "Forever Fall Part 2", - "file_extension": "mp4", - "file_name": "[5F] RWBY - 14 Forever Fall Part 2 pt-BR.mp4", - "language": "pt-BR", - "release_group": "5F" - }, - { - "anime_title": "Neon Genesis Evangelion Platinum", - "episode_number": 6, - "episode_title": "Showdown in Tokyo 3", - "file_checksum": "CBDB8577", - "file_extension": "mkv", - "file_name": "[ACX]Neon_Genesis_Evangelion_-_Platinum_-_06_-_Showdown_in_Tokyo_3_[SaintDeath]_[CBDB8577].mkv", - "other": "SaintDeath", - "release_group": "ACX" - }, - { - "anime_title": "Fullmetal Alchemist", - "anime_year": 2009, - "audio_term": "AAC", - "episode_number": 2, - "file_checksum": "7B2C5E8B", - "file_extension": "mkv", - "file_name": "[AKH-SWE]_Fullmetal_Alchemist_(2009)_02v2_[H.264.AAC][7B2C5E8B].mkv", - "release_group": "AKH-SWE", - "release_version": 2, - "video_term": "H.264" - }, - { - "anime_title": "Canaan", - "audio_term": "AAC", - "episode_number": 1, - "file_checksum": "12F00E89", - "file_extension": "mkv", - "file_name": "[ANBU-Menclave]_Canaan_-_01_[1024x576_H.264_AAC][12F00E89].mkv", - "release_group": "ANBU-Menclave", - "video_resolution": "1024x576", - "video_term": "H.264" - }, - { - "anime_title": "Haiyoru! Nyaru-Ani", - "file_checksum": "596DD8E6", - "file_extension": "mkv", - "file_name": "[ANBU-umai]_Haiyoru!_Nyaru-Ani_[596DD8E6].mkv", - "release_group": "ANBU-umai" - }, - { - "anime_title": "Princess Lover!", - "episode_number": 1, - "file_checksum": "2048A39A", - "file_extension": "mkv", - "file_name": "[ANBU]_Princess_Lover!_-_01_[2048A39A].mkv", - "release_group": "ANBU" - }, - { - "anime_title": "Yosuga no Sora", - "anime_type": "Preview", - "audio_term": "FLAC", - "episode_number": 1, - "episode_prefix": "Ep", - "file_extension": "mkv", - "file_name": "[ANE] Yosuga no Sora - Ep01 Preview (Yorihime ver) [BDRip 1080p x264 FLAC].mkv", - "other": [ - "Yorihime", - "ver" - ], - "release_group": "ANE", - "source": "BDRip", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_title": "Shingeki no Kyojin (The Final Season)", - "audio_term": "Dual Audio", - "episode_number": 13, - "episode_number_alt": 72, - "file_extension": "mkv", - "file_name": "[AnimeRG] Shingeki no Kyojin (The Final Season) - 13 " - "[1080p 10bit Dual Audio] (Attack on Titan - 72).mkv", - "other": ["Attack", "on", "Titan"], - "release_group": "AnimeRG", - "video_resolution": "1080p", - "video_term": "10bit" - }, - { - "anime_title": "Ushio to Tora (TV)", - "episode_number": 2, - "file_extension": "mkv", - "file_name": "[AnimeRG] Ushio to Tora (TV) - 02 [720p] [Xcelent].mkv", - "other": "Xcelent", - "release_group": "AnimeRG", - "video_resolution": "720p" - }, - { - "anime_title": "Witch Craft Works", - "anime_type": "ED", - "file_extension": "mkv", - "file_name": "[AnimeRG] Witch Craft Works - EDv2 [1080p] [x265] [pseudo].mkv", - "other": "pseudo", - "release_group": "AnimeRG", - "release_version": 2, - "video_resolution": "1080p", - "video_term": "x265" - }, - { - "anime_title": "Mangaka-san to Assistant-san to the Animation", - "audio_term": "AAC", - "episode_number": 2, - "episode_prefix": "ep", - "file_extension": "mp4", - "file_name": "[AoJiaoZero][Mangaka-san to Assistant-san to the Animation] ep02 [BIG][X264_AAC][720P].mp4", - "other": "BIG", - "release_group": "AoJiaoZero", - "video_resolution": "720P", - "video_term": "X264" - }, - { - "anime_title": "Rozen Maiden 3", - "anime_type": "PV", - "file_checksum": "CA57F300", - "file_extension": "mkv", - "file_name": "[Asenshi] Rozen Maiden 3 - PV [CA57F300].mkv", - "release_group": "Asenshi" - }, - { - "anime_title": "Infinite Stratos IS", - "episode_number": 1, - "file_checksum": "29675B71", - "file_extension": "avi", - "file_name": "[Ayako]_Infinite_Stratos_-_IS_-_01v2_[XVID][400p][29675B71].avi", - "release_group": "Ayako", - "release_version": 2, - "video_resolution": "400p", - "video_term": "XVID" - }, - { - "anime_title": "Kiddy Grade 2 Pilot", - "audio_term": "AC3", - "file_checksum": "650B731B", - "file_extension": "mkv", - "file_name": "[Ayu]_Kiddy_Grade_2_-_Pilot_[H264_AC3][650B731B].mkv", - "release_group": "Ayu", - "video_term": "H264" - }, - { - "anime_title": "Myself Yourself", - "episode_number": 12, - "file_checksum": "CB2B37F1", - "file_extension": "mkv", - "file_name": "[B-G_&_m.3.3.w]_Myself_Yourself_12.DVD(H.264_DD2.0)_[CB2B37F1].mkv", - "other": "DD2.0", - "release_group": "B-G_&_m.3.3.w", - "source": "DVD", - "video_term": "H.264" - }, - { - "anime_title": "Toradora!", - "episode_number": 7, - "episode_title": "Pool Opening", - "file_checksum": "8F59F2BA", - "file_name": "[BM&T] Toradora! - 07v2 - Pool Opening [720p Hi10 ] [BD] [8F59F2BA]", - "release_group": "BM&T", - "release_version": 2, - "source": "BD", - "video_resolution": "720p", - "video_term": "Hi10" - }, - { - "anime_title": "Special A", - "episode_number": 1, - "file_checksum": "C83164B9", - "file_extension": "mkv", - "file_name": "[BakaWolf-m.3.3.w] Special A 01 (H.264) [C83164B9].mkv", - "release_group": "BakaWolf-m.3.3.w", - "video_term": "H.264" - }, - { - "anime_title": "Blue Submarine No.6", - "audio_term": "Dual Audio", - "file_name": "[BluDragon] Blue Submarine No.6 (DVD, R2, Dual Audio) V3", - "other": "R2", - "release_group": "BluDragon", - "release_version": 3, - "source": "DVD" - }, - { - "anime_title": "Sword Art Online Extra Edition", - "audio_term": [ - "Dual Audio", - "Vorbis" - ], - "file_name": "[CH] Sword Art Online Extra Edition Dual Audio [BD 480p][10bitH.264Vorbis]", - "release_group": "CH", - "source": "BD", - "video_resolution": "480p", - "video_term": [ - "10bit", - "H.264" - ] - }, - { - "anime_title": "Magical☆Star Kanon 100% OVA", - "file_checksum": "E9F43685", - "file_extension": "mkv", - "file_name": "[CMS] Magical☆Star Kanon 100% OVA[DVD][E9F43685].mkv", - "release_group": "CMS", - "source": "DVD" - }, - { - "anime_title": "Kono Aozora ni Yakusoku Wo", - "episode_number": 10, - "file_checksum": "C83D206B", - "file_extension": "mkv", - "file_name": "[Chihiro]_Kono_Aozora_ni_Yakusoku_Wo_10_v2_[DVD][h264][C83D206B].mkv", - "release_group": "Chihiro", - "release_version": 2, - "source": "DVD", - "video_term": "h264" - }, - { - "anime_title": "Bakemonogatari", - "anime_type": "OP", - "audio_term": "FLAC", - "episode_number": 4, - "episode_part": "a", - "file_checksum": "327A2375", - "file_extension": "mkv", - "file_name": "[Coalgirls]_Bakemonogatari_OP4a_(1280x720_Blu-Ray_FLAC)_[327A2375].mkv", - "release_group": "Coalgirls", - "source": "Blu-Ray", - "video_resolution": "1280x720" - }, - { - "anime_title": "Fate Zero", - "anime_type": "OVA", - "audio_term": "FLAC", - "episode_number": 3.5, - "file_checksum": "5F5AD026", - "file_extension": "mkv", - "file_name": "[Coalgirls]_Fate_Zero_OVA3.5_(1280x720_Blu-ray_FLAC)_[5F5AD026].mkv", - "release_group": "Coalgirls", - "source": "Blu-ray", - "video_resolution": "1280x720" - }, - { - "anime_title": "Toradora", - "anime_type": "ED", - "audio_term": "AAC", - "episode_number": 2, - "file_checksum": "3B65D1E6", - "file_extension": "mkv", - "file_name": "[Coalgirls]_Toradora_ED2_(704x480_DVD_AAC)_[3B65D1E6].mkv", - "release_group": "Coalgirls", - "source": "DVD", - "video_resolution": "704x480" - }, - { - "anime_title": "White Album", - "audio_term": "FLAC", - "episode_number": [ - 1, - 13 - ], - "file_name": "[Coalgirls]_White_Album_1-13_(1280×720_Blu-Ray_FLAC)", - "release_group": "Coalgirls", - "source": "Blu-Ray", - "video_resolution": "1280×720" - }, - { - "anime_title": "Last Exile ~Fam, The Silver Wing~", - "episode_number": 13, - "file_checksum": "AFF9E530", - "file_extension": "mkv", - "file_name": "[Commie] Last Exile ~Fam, The Silver Wing~ - 13 [AFF9E530].mkv", - "release_group": "Commie" - }, - { - "anime_season": 2, - "anime_season_prefix": "S", - "anime_title": "Mobile Suit Gundam 00", - "audio_term": "AAC", - "episode_number": 1, - "file_checksum": "4863FBE8", - "file_extension": "mkv", - "file_name": "[Conclave-Mendoi]_Mobile_Suit_Gundam_00_S2_-_01v2_[1280x720_H.264_AAC][4863FBE8].mkv", - "release_group": "Conclave-Mendoi", - "release_version": 2, - "video_resolution": "1280x720", - "video_term": "H.264" - }, - { - "anime_title": "Bleach 225", - "file_checksum": "C63D149C", - "file_extension": "avi", - "file_name": "[DB]_Bleach_225_[C63D149C].avi", - "release_group": "DB" - }, - { - "anime_title": "Tatakau Shisho The Book of Bantorra", - "audio_term": "MP3", - "file_extension": "mkv", - "file_name": "[Darksoul-Subs] Tatakau Shisho - The Book of Bantorra [848x480 XVID_MP3].mkv", - "release_group": "Darksoul-Subs", - "video_resolution": "848x480", - "video_term": "XVID" - }, - { - "anime_title": "Tegami Bachi (REVERSE) Letter Bee", - "episode_number": 4, - "episode_number_alt": 29, - "file_checksum": "5203142B", - "file_extension": "mkv", - "file_name": "[Deep] Tegami Bachi (REVERSE) - Letter Bee - 29 (04) [5203142B].mkv", - "release_group": "Deep" - }, - { - "anime_title": "Magi The Labyrinth Of Magic", - "file_name": "[DmonHiro] Magi - The Labyrinth Of Magic - Vol.1v2 (BD, 720p)", - "release_group": "DmonHiro", - "release_version": 2, - "source": "BD", - "video_resolution": "720p", - "volume_number": 1, - "volume_prefix": "Vol" - }, - { - "anime_title": "Oreshura", - "episode_number": 1, - "episode_prefix": "#", - "episode_title": "The Start Of High School Life Is A War Zone", - "file_checksum": "211375E6", - "file_extension": "mkv", - "file_name": "[DmonHiro] Oreshura #01v2 - The Start Of High School Life Is A War Zone [BD, 720p] [211375E6].mkv", - "release_group": "DmonHiro", - "release_version": 2, - "source": "BD", - "video_resolution": "720p" - }, - { - "anime_title": "Nogizaka Haruka no Himitsu Purezza", - "audio_term": "AAC", - "episode_number": [ - 1, - 3 - ], - "file_name": "[Doki] Nogizaka Haruka no Himitsu - Purezza - 01v2-03v2 (1280x720 h264 AAC)", - "release_group": "Doki", - "release_version": [ - 2, - 2 - ], - "video_resolution": "1280x720", - "video_term": "h264" - }, - { - "anime_title": "Ro-Kyu-Bu! SS 01", - "file_checksum": "C1B5CE5D", - "file_extension": "mkv", - "file_name": "[Doremi].Ro-Kyu-Bu!.SS.01.[C1B5CE5D].mkv", - "release_group": "Doremi" - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "Devilman Crybaby", - "audio_term": "Multi-Audio", - "episode_number": 4, - "episode_prefix": "E", - "file_checksum": "24B349D4", - "file_extension": "mkv", - "file_name": "[DragsterPS] Devilman Crybaby S01E04 [720p] [Multi-Audio] [Multi-Subs] [24B349D4].mkv", - "release_group": "DragsterPS", - "subtitles": "Multi-Subs", - "video_resolution": "720p" - }, - { - "anime_title": "Kore wa Zombie desu ka", - "anime_type": "TV", - "audio_term": "AAC", - "episode_number": 3, - "file_checksum": "888E4991", - "file_extension": "mkv", - "file_name": "[E-HARO Raws] Kore wa Zombie desu ka - 03 (TV 1280x720 h264 AAC) [888E4991].mkv", - "release_group": "E-HARO Raws", - "video_resolution": "1280x720", - "video_term": "h264" - }, - { - "anime_title": "Kore wa Zombie desu ka", - "episode_number": 2, - "episode_prefix": "Episode", - "file_extension": "mkv", - "file_name": "[Edomae Subs] Kore wa Zombie desu ka Episode 2.mkv", - "release_group": "Edomae Subs" - }, - { - "anime_title": "Sora no Woto", - "audio_term": "AAC", - "episode_number": 7.5, - "episode_prefix": "EP", - "file_checksum": "C37580F8", - "file_extension": "mkv", - "file_name": "[Elysium]Sora.no.Woto.EP07.5(BD.720p.AAC)[C37580F8].mkv", - "release_group": "Elysium", - "source": "BD", - "video_resolution": "720p" - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "Natsu no Arashi!", - "episode_number": 1, - "episode_prefix": "E", - "episode_title": "Playback Part 2", - "file_checksum": "BD00F464", - "file_extension": "mkv", - "file_name": "[EMBER] Natsu no Arashi! - S01E01-Playback Part 2 [BD00F464].mkv", - "release_group": "EMBER" - }, - { - "anime_title": "One Piece", - "episode_number": 1000, - "file_checksum": "F2AE5FF6", - "file_extension": "mkv", - "file_name": "[Erai-raws] One Piece - 1000 [1080p][Multiple Subtitle][F2AE5FF6].mkv", - "release_group": "Erai-raws", - "subtitles": "Multiple Subtitle", - "video_resolution": "1080p" - }, - { - "anime_title": "Nurse Witch Komugi-chan Magikarte", - "episode_number": 2.5, - "file_checksum": "902BB314", - "file_extension": "mkv", - "file_name": "[EroGaKi-Team]_Nurse_Witch_Komugi-chan_Magikarte_02.5_[902BB314].mkv", - "release_group": "EroGaKi-Team" - }, - { - "anime_title": "AKB0048", - "audio_term": "FLAC", - "episode_title": "Making of Kibou-ni-Tsuite Music Video", - "file_checksum": "C09462E2", - "file_name": "[EveTaku] AKB0048 Vol.03 - Making of Kibou-ni-Tsuite Music Video (BDRip 1080i H.264-Hi10P FLAC)[C09462E2]", - "release_group": "EveTaku", - "source": "BDRip", - "video_resolution": "1080i", - "video_term": [ - "H.264", - "Hi10P" - ], - "volume_number": 3, - "volume_prefix": "Vol" - }, - { - "anime_title": "Baby Princess 3D Paradise Love", - "audio_term": "AAC", - "episode_number": 1, - "file_checksum": "457CC066", - "file_extension": "mkv", - "file_name": "[FBI] Baby Princess 3D Paradise Love 01v0 [BD][720p-AAC][457CC066].mkv", - "release_group": "FBI", - "release_version": 0, - "source": "BD", - "video_resolution": "720p" - }, - { - "anime_title": "Crayon Shin-Chan", - "anime_type": "Movie", - "anime_year": 1994, - "audio_term": "AC3", - "episode_number": 2, - "episode_title": "The Secret of Buri Buri Kingdom", - "file_extension": "avi", - "file_name": "[FB] Crayon Shin-Chan Movie 2 The Secret of Buri Buri Kingdom [DivX5 AC3] 1994 [852X480] V2.avi", - "release_group": "FB", - "release_version": 2, - "video_resolution": "852X480", - "video_term": "DivX5" - }, - { - "anime_title": "Futsuu no Joshikousei ga [Locodol] Yatte Mita.", - "episode_number": 1, - "file_checksum": "BAD09C76", - "file_extension": "mkv", - "file_name": "[FFF] Futsuu no Joshikousei ga [Locodol] Yatte Mita. - 01 [BAD09C76].mkv", - "release_group": "FFF" - }, - { - "anime_title": "Love Live! The School Idol", - "anime_type": ["Movie", "PV"], - "file_checksum": "D1A15D2C", - "file_extension": "mkv", - "file_name": "[FFF] Love Live! The School Idol Movie - PV [D1A15D2C].mkv", - "release_group": "FFF" - }, - { - "anime_title": "Red Data Girl", - "episode_number": 10, - "file_checksum": "29EA865B", - "file_extension": "mkv", - "file_name": "[FFF] Red Data Girl - 10v0 [29EA865B].mkv", - "release_group": "FFF", - "release_version": 0 - }, - { - "anime_title": "Seirei Tsukai no Blade Dance", - "anime_type": "SP", - "audio_term": "AAC", - "episode_number": 1, - "file_checksum": "F1FF8588", - "file_extension": "mkv", - "file_name": "[FFF] Seirei Tsukai no Blade Dance - SP01 [BD][720p-AAC][F1FF8588].mkv", - "release_group": "FFF", - "source": "BD", - "video_resolution": "720p" - }, - { - "anime_title": "Bakuman", - "episode_number": 1, - "episode_prefix": "E", - "file_extension": "mkv", - "file_name": "[FaggotryRaws] Bakuman - 01 (NHK E 848x480).mkv", - "other": "NHK", - "release_group": "FaggotryRaws", - "video_resolution": "848x480" - }, - { - "anime_title": "Nodame Cantabile Finale", - "episode_number": 0, - "file_checksum": "73AD0735", - "file_extension": "mkv", - "file_name": "[Frostii]_Nodame_Cantabile_Finale_-_00_[73AD0735].mkv", - "release_group": "Frostii" - }, - { - "anime_title": "Classroom Crisis", - "audio_term": "FLAC", - "file_name": "[GS] Classroom Crisis Vol.1&2 (BD 1080p 10bit FLAC)", - "release_group": "GS", - "source": "BD", - "video_resolution": "1080p", - "video_term": "10bit", - "volume_number": [ - 1, - 2 - ], - "volume_prefix": "Vol." - }, - { - "anime_title": "Koi Kaze", - "audio_term": "Dual Audio", - "episode_number": 1, - "episode_prefix": "Ep", - "file_checksum": "c13cefe0", - "file_extension": "mkv", - "file_name": "[GrimRipper] Koi Kaze [Dual Audio] Ep01 (c13cefe0).mkv", - "release_group": "GrimRipper" - }, - { - "file_name": "[Hakata Ramen] Cells At Work! Special.mkv", - "file_extension": "mkv", - "release_group": "Hakata Ramen", - "anime_title": "Cells At Work!", - "anime_type": "Special" - }, - { - "anime_title": "Evangelion 3.0 You Can (Not) Redo", - "file_extension": "mp4", - "file_name": "[Hakugetsu&MGRT][Evangelion 3.0 You Can (Not) Redo][480P][V0].mp4", - "release_group": "Hakugetsu&MGRT", - "release_version": 0, - "video_resolution": "480P" - }, - { - "anime_title": "FullMetalAlchemist", - "episode_number": 9, - "file_extension": "rmvb", - "file_name": "[Hard-Boiled FS]FullMetalAlchemist_09.rmvb", - "release_group": "Hard-Boiled FS" - }, - { - "anime_title": "Classroom Crisis", - "audio_term": "AAC", - "file_name": "[Harunatsu] Classroom Crisis - Vol.1 [BD 720p-AAC]", - "release_group": "Harunatsu", - "source": "BD", - "video_resolution": "720p", - "volume_number": 1, - "volume_prefix": "Vol." - }, - { - "anime_title": "Fairy Tail 2", - "episode_number": 52, - "episode_number_alt": 227, - "file_checksum": "9DF6B8D5", - "file_extension": "mkv", - "file_name": "[Hatsuyuki-Kaitou]_Fairy_Tail_2_-_52_(227)_[720p][10bit][9DF6B8D5].mkv", - "release_group": "Hatsuyuki-Kaitou", - "video_resolution": "720p", - "video_term": "10bit" - }, - { - "anime_title": "Dragon Ball Kai", - "anime_year": 2014, - "episode_number": 2, - "episode_number_alt": 100, - "file_checksum": "DD66AFB7", - "file_extension": "mkv", - "file_name": "[Hatsuyuki] Dragon Ball Kai (2014) - 002 (100) [1280x720][DD66AFB7].mkv", - "release_group": "Hatsuyuki", - "video_resolution": "1280x720" - }, - { - "anime_season": 3, - "anime_season_prefix": "S", - "anime_title": "Kuroko no Basuke", - "episode_number": 1, - "episode_number_alt": 51, - "file_checksum": "619C57A0", - "file_extension": "mkv", - "file_name": "[Hatsuyuki]_Kuroko_no_Basuke_S3_-_01_(51)_[720p][10bit][619C57A0].mkv", - "release_group": "Hatsuyuki", - "video_resolution": "720p", - "video_term": "10bit" - }, - { - "anime_title": "Kotoura-san Special Short Anime 'Haruka's Room'", - "audio_term": "AAC", - "episode_number": 1, - "file_checksum": "6B6BE015", - "file_extension": "mkv", - "file_name": "[Hien] Kotoura-san - Special Short Anime 'Haruka's Room' - 01 [BD 1080p H.264 10-bit AAC][6B6BE015].mkv", - "release_group": "Hien", - "source": "BD", - "video_resolution": "1080p", - "video_term": [ - "H.264", - "10-bit" - ] - }, - { - "anime_title": "Sora no Woto", - "episode_number": 1, - "file_checksum": "E83AD672", - "file_extension": "mkv", - "file_name": "[Himatsubushi]_Sora_no_Woto_-_01_-_H264_-_720p_-_E83AD672.mkv", - "release_group": "Himatsubushi", - "video_resolution": "720p", - "video_term": "H264" - }, - { - "anime_title": "Maji de Watashi ni Koi Shinasai!!", - "episode_number": 2, - "file_extension": "mkv", - "file_name": "[Hiryuu] Maji de Watashi ni Koi Shinasai!! - 02 [720].mkv", - "release_group": "Hiryuu", - "video_resolution": "720" - }, - { - "anime_title": "Gintama", - "episode_number": 111, - "episode_part": "C", - "file_extension": "mkv", - "file_name": "[HorribleSubs] Gintama - 111C [1080p].mkv", - "release_group": "HorribleSubs", - "video_resolution": "1080p" - }, - { - "anime_title": "HEROMAN", - "episode_number": 10, - "file_extension": "mkv", - "file_name": "[HorribleSubs] HEROMAN - 10_(XviD_AnimeSenshi).mkv", - "other": "AnimeSenshi", - "release_group": "HorribleSubs", - "video_term": "XviD" - }, - { - "anime_season": 2, - "anime_season_prefix": "S", - "anime_title": "Mob Psycho 100", - "episode_number": 7, - "file_extension": "mkv", - "file_name": "[HorribleSubs] Mob Psycho 100 S2 - 07 [1080p].mkv", - "release_group": "HorribleSubs", - "video_resolution": "1080p" - }, - { - "anime_title": "Momokuri", - "episode_number": [ - 1, - 2 - ], - "file_name": "[HorribleSubs] Momokuri - 01+02 [720p]", - "release_group": "HorribleSubs", - "video_resolution": "720p" - }, - { - "anime_title": "The World God Only Knows 2", - "episode_number": 3, - "file_extension": "mkv", - "file_name": "[HorribleSubs] The World God Only Knows 2 - 03 [720p].mkv", - "release_group": "HorribleSubs", - "video_resolution": "720p" - }, - { - "anime_title": "Tower of Druaga Sword of Uruk", - "episode_number": 4, - "file_extension": "mkv", - "file_name": "[HorribleSubs] Tower of Druaga - Sword of Uruk - 04 [480p].mkv", - "release_group": "HorribleSubs", - "video_resolution": "480p" - }, - { - "anime_title": "Tsukimonogatari", - "episode_number": [ - 1, - 4 - ], - "file_extension": "mkv", - "file_name": "[HorribleSubs] Tsukimonogatari - (01-04) [1080p].mkv", - "release_group": "HorribleSubs", - "video_resolution": "1080p" - }, - { - "anime_title": "Norn9 Norn + Nonetto", - "episode_number": 12, - "file_name": "[Infantjedi] Norn9 - Norn + Nonetto - 12", - "release_group": "Infantjedi" - }, - { - "anime_title": "Heavy Object", - "anime_type": "NCED", - "episode_number": 1, - "file_extension": "mkv", - "file_name": "[Judas[ Heavy Object - NCED 01.mkv", - "release_group": "Judas" - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "Aharen-san wa Hakarenai", - "episode_number": 6, - "episode_prefix": "E", - "file_extension": "mkv", - "file_name": "[Judas] Aharen-san wa Hakarenai - S01E06v2.mkv", - "release_group": "Judas", - "release_version": 2 - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "Amanchu!", - "anime_type": "Ending", - "file_extension": "mkv", - "file_name": "[Judas] Amanchu! S1 - Ending.mkv", - "release_group": "Judas" - }, - { - "anime_title": "Black Clover Jump Special Anime Festa 2018", - "file_name": "[Judas] Black Clover - Jump Special Anime Festa 2018.mkv", - "file_extension": "mkv", - "release_group": "Judas" - }, - { - "anime_title": "Black Clover", - "anime_type": "NCOP", - "episode_number": 10, - "episode_part": "b", - "episode_title": "Black Catcher", - "file_name": "[Judas] Black Clover - NCOP 10b - Black Catcher.mkv", - "file_extension": "mkv", - "release_group": "Judas", - }, - { - "anime_title": "Boruto", - "anime_type": "OVA", - "episode_title": "The Day Naruto Became Hokage", - "file_name": "[Judas] Boruto - OVA - The Day Naruto Became Hokage.mkv", - "file_extension": "mkv", - "release_group": "Judas" - }, - { - "anime_title": "Card Captor Sakura The Movie", - "episode_number": 2, - "episode_title": "The Sealed Card", - "file_extension": "mkv", - "file_name": "[Judas] Card Captor Sakura - The Movie 2 - The Sealed Card.mkv", - "release_group": "Judas" - }, - { - "anime_title": "Code Geass", - "anime_type": "Movie", - "episode_number": 2, - "file_extension": "mkv", - "file_name": "[Judas] Code Geass Movie 02.mkv", - "release_group": "Judas" - }, - { - "anime_title": "Code Geass", - "anime_type": "Movie", - "episode_number": 2, - "episode_title": "Lelouch of the Rebellion Transgression", - "file_extension": "mkv", - "file_name": "[Judas] Code Geass Movie 02 - Lelouch of the Rebellion - Transgression.mkv", - "release_group": "Judas" - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "Gintama", - "episode_number": [1, 2], - "episode_number_alt": [1, 2], - "episode_prefix": ["E", "E"], - "file_extension": "mkv", - "file_name": "[Judas] Gintama - S01E01-E02 (001-002).mkv", - "release_group": "Judas" - }, - { - "anime_season": 2, - "anime_season_prefix": "S", - "anime_title": "Gintama", - "episode_number": [1, 2], - "episode_number_alt": [25, 26], - "episode_prefix": ["E", "E"], - "file_extension": "mkv", - "file_name": "[Judas] Gintama - S02E01-E02 (025-026).mkv", - "release_group": "Judas" - }, - { - "anime_title": "Granblue Fantasy", - "anime_type": "Clean Ending", - "file_extension": "mkv", - "file_name": "[Judas] Granblue Fantasy - Clean Ending.mkv", - "release_group": "Judas" - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "Grisaia no Kajitsu", - "anime_type": "S", - "episode_number": 2, - "file_extension": "mkv", - "file_name": "[Judas] Grisaia no Kajitsu - S01S02.mkv", - "release_group": "Judas" - }, - { - "anime_title": "Initial D Extra Stage", - "episode_number": 2, - "episode_title": "Sentimental White", - "file_extension": "mkv", - "file_name": "[Judas] Initial D - Extra Stage 02 - Sentimental White.mkv", - "release_group": "Judas" - }, - { - "anime_title": "Initial D Extra Stage 2 Sentimental White", - "file_extension": "mkv", - "file_name": "[Judas] Initial D - Extra Stage 2 - Sentimental White.mkv", - "release_group": "Judas" - }, - { - "anime_title": "Nogizaka Haruka no Himitsu", - "anime_type": ["OVA", "NCOP"], - "file_extension": "mkv", - "file_name": "[Judas] Nogizaka Haruka no Himitsu - OVA NCOP.mkv", - "release_group": "Judas" - }, - { - "anime_title": "One Piece", - "episode_number": 1009, - "file_extension": "mkv", - "file_name": "[Judas] One Piece - 1009v2.mkv", - "release_group": "Judas", - "release_version": 2 - }, - { - "anime_season": 1, - "anime_season_prefix": "S", - "anime_title": "One Punch Man", - "anime_type": "OVA", - "episode_number": 2, - "file_extension": "mkv", - "file_name": "[Judas] One Punch Man - S01OVA02.mkv", - "release_group": "Judas" - }, - { - "anime_season": 1, - "anime_season_prefix": "Season", - "anime_title": "One-Punch Man", - "anime_type": "OVA", - "episode_number": 5, - "file_extension": "mkv", - "file_name": "[Judas] One-Punch Man Season 1 OVA - 05.mkv", - "release_group": "Judas" - }, - { - "anime_title": "D C II Da Capo II", - "episode_number": 1, - "episode_prefix": "Ep", - "file_checksum": "a1fc58a7", - "file_extension": "mkv", - "file_name": "[Jumonji-Giri]_[Shinsen-Subs][ASF]_D.C.II_Da_Capo_II_Ep01_(a1fc58a7).mkv", - "other": [ - "Shinsen-Subs", - "ASF" - ], - "release_group": "Jumonji-Giri" - }, - { - "anime_title": "One Piece", - "anime_type": "Movie", - "episode_number": 9, - "file_extension": "avi", - "file_name": "[KAF-TEAM]_One_Piece_Movie_9_vostfr_HD.avi", - "language": "vostfr", - "release_group": "KAF-TEAM", - "video_term": "HD" - }, - { - "anime_title": "D Gray-man", - "episode_number": 4, - "file_extension": "avi", - "file_name": "[KLF]_D.Gray-man_04V2.avi", - "release_group": "KLF", - "release_version": 2 - }, - { - "anime_title": "Uchuu no Stellvia", - "audio_term": "AAC", - "episode_number": 14, - "episode_prefix": "ep", - "file_checksum": "06EE7355", - "file_extension": "mkv", - "file_name": "[Kira-Fansub] Uchuu no Stellvia ep 14 (BD H264 1280x960 24fps AAC) [06EE7355].mkv", - "release_group": "Kira-Fansub", - "source": "BD", - "video_resolution": "1280x960", - "video_term": [ - "H264", - "24fps" - ] - }, - { - "anime_title": "1001 Nights", - "anime_year": 1998, - "file_name": "[LRL] 1001 Nights (1998) [DVD]", - "release_group": "LRL", - "source": "DVD" - }, - { - "anime_title": "Umineko no Naku Koro ni", - "audio_term": "AAC", - "episode_number": 11, - "file_checksum": "943106AD", - "file_extension": "mkv", - "file_name": "[Lambda-Delta]_Umineko_no_Naku_Koro_ni_-_11_[848x480_H.264_AAC][943106AD].mkv", - "release_group": "Lambda-Delta", - "video_resolution": "848x480", - "video_term": "H.264" - }, - { - "anime_title": "Aikatsu!", - "episode_number": 100, - "file_checksum": "D035A39F", - "file_extension": "mkv", - "file_name": "[Mezashite] Aikatsu! ‒ 100 [D035A39F].mkv", - "release_group": "Mezashite" - }, - { - "anime_title": "Angel Beats", - "episode_number": 9, - "file_extension": "mkv", - "file_name": "[N LogN Fansubs] Angel Beats (9).mkv", - "release_group": "N LogN Fansubs" - }, - { - "anime_title": "Hidamari Sketch x365", - "episode_number": 9, - "episode_part": "a", - "file_checksum": "49874745", - "file_extension": "mkv", - "file_name": "[NamaeNai] Hidamari Sketch x365 - 09a (DVD) [49874745].mkv", - "release_group": "NamaeNai", - "source": "DVD" - }, - { - "anime_title": "Tiger & Bunny", - "audio_term": "AAC", - "episode_number": 1, - "episode_prefix": "#", - "episode_title": "All's well that ends well.", - "file_checksum": "4A9AB85F", - "file_extension": "mkv", - "file_name": "[NinjaPanda] Tiger & Bunny #01 All's well that ends well. (v3, 1080p Hi10P, DA AAC) [4A9AB85F].mkv", - "other": "DA", - "release_group": "NinjaPanda", - "release_version": 3, - "video_resolution": "1080p", - "video_term": "Hi10P" - }, - { - "anime_title": "Tamayura ~graduation photo~ Movie Part 1", - "file_checksum": "98965607", - "file_extension": "mkv", - "file_name": "[Nishi-Taku] Tamayura ~graduation photo~ Movie Part 1 [BD][720p][98965607].mkv", - "release_group": "Nishi-Taku", - "source": "BD", - "video_resolution": "720p" - }, - { - "anime_title": "Space Battleship Yamato 2199", - "anime_year": 2012, - "audio_term": "AAC", - "episode_number": 18, - "episode_prefix": "episode", - "file_checksum": "1F56D642", - "file_name": "[Nubles] Space Battleship Yamato 2199 (2012) episode 18 (720p 10 bit AAC)[1F56D642]", - "release_group": "Nubles", - "video_resolution": "720p", - "video_term": "10 bit" - }, - { - "anime_title": "Space Battleship Yamato 2199", - "anime_year": 2012, - "audio_term": "AAC", - "episode_number": 18, - "episode_prefix": "episode", - "file_checksum": "BA70BA9C", - "file_name": "[Nubles] Space Battleship Yamato 2199 (2012) episode 18 (720p 8 bit AAC)[BA70BA9C]", - "release_group": "Nubles", - "video_resolution": "720p", - "video_term": "8 bit" - }, - { - "anime_title": "Diebuster", - "audio_term": "AC3", - "episode_number": 1, - "episode_prefix": "EP", - "file_checksum": "82E36A36", - "file_extension": "mkv", - "file_name": "[R-R] Diebuster.EP1 (720p.Hi10p.AC3) [82E36A36].mkv", - "release_group": "R-R", - "video_resolution": "720p", - "video_term": "Hi10p" - }, - { - "anime_title": "Sakura Taisen New York NY", - "episode_number": 2, - "episode_prefix": "Ep", - "file_checksum": "1590D378", - "file_extension": "avi", - "file_name": "[RNA]_Sakura_Taisen_New_York_NY_Ep_2_[1590D378].avi", - "release_group": "RNA" - }, - { - "anime_title": "Mezzo(DSA)", - "audio_term": "ogg", - "episode_number": 5, - "file_checksum": "585d9971", - "file_extension": "mkv", - "file_name": "[RaX]Mezzo(DSA)_-_05_-_[x264_ogg]_[585d9971].mkv", - "release_group": "RaX", - "video_term": "x264" - }, - { - "anime_title": "Persona 4 The Animation", - "audio_term": "FLAC", - "episode_number": 13, - "episode_prefix": "Episode", - "episode_title": "A Stormy Summer Vacation Part 1", - "file_checksum": "8A45634B", - "file_extension": "mkv", - "file_name": "[Raizel] Persona 4 The Animation Episode 13 - A Stormy Summer Vacation Part 1 [BD_1080p_Dual_Audio_FLAC_Hi10p][8A45634B].mkv", - "other": [ - "Dual", - "Audio" - ], - "release_group": "Raizel", - "source": "BD", - "video_resolution": "1080p", - "video_term": "Hi10p" - }, - { - "anime_title": "Gift ~eternal rainbow~", - "audio_term": "vorbis", - "episode_number": 1, - "file_extension": "mkv", - "file_name": "[Rakuda].Gift.~eternal.rainbow~.01.dvd.h.264.vorbis.mkv", - "release_group": "Rakuda", - "source": "dvd", - "video_term": "h.264" - }, - { - "anime_title": "Memories Off 3.5", - "episode_number": 4, - "file_extension": "mkv", - "file_name": "[ReDone] Memories Off 3.5 - 04 (DVD 10-bit).mkv", - "release_group": "ReDone", - "source": "DVD", - "video_term": "10-bit" - }, - { - "anime_title": "Serial Experiments Lain", - "audio_term": "Dual-Audio", - "episode_number": 1, - "episode_prefix": "E", - "file_extension": "mkv", - "file_name": "[Reaktor] Serial Experiments Lain - E01 [1080p][x265][10-bit][Dual-Audio].mkv", - "release_group": "Reaktor", - "video_resolution": "1080p", - "video_term": [ - "x265", - "10-bit" - ] - }, - { - "anime_title": "No.6", - "episode_number": 1, - "file_checksum": "A956075C", - "file_extension": "mkv", - "file_name": "[Ruri]No.6 01 [720p][H264][A956075C].mkv", - "release_group": "Ruri", - "video_resolution": "720p", - "video_term": "H264" - }, - { - "anime_season": 2, - "anime_season_prefix": "S", - "anime_title": "Queen's Blade", - "file_name": "[SFW]_Queen's_Blade_S2", - "release_group": "SFW" - }, - { - "anime_title": "Kemono no Souja Erin", - "episode_number": 12, - "file_checksum": "0F5F884F", - "file_extension": "mkv", - "file_name": "[SS]_Kemono_no_Souja_Erin_-_12_(1280x720_h264)_[0F5F884F].mkv", - "release_group": "SS", - "video_resolution": "1280x720", - "video_term": "h264" - }, - { - "anime_title": "AIKa ZERO", - "anime_type": "OVA", - "audio_term": "Flac", - "episode_number": 1, - "file_checksum": "6730D40A", - "file_extension": "mkv", - "file_name": "[Seto_Otaku]_AIKa_ZERO_OVA_-_01_[BD][1920x1080_H264-Flac][6730D40A].mkv", - "release_group": "Seto_Otaku", - "source": "BD", - "video_resolution": "1920x1080", - "video_term": "H264" - }, - { - "anime_title": "Macross Frontier", - "episode_number": 1, - "episode_part": "b", - "file_checksum": "4D5EC315", - "file_extension": "avi", - "file_name": "[Shinsen-Subs]_Macross_Frontier_-_01b_[4D5EC315].avi", - "release_group": "Shinsen-Subs" - }, - { - "anime_title": "Hidamari Sketch x365", - "episode_number": 4.1, - "file_checksum": "B6CE8458", - "file_extension": "mkv", - "file_name": "[SpoonSubs]_Hidamari_Sketch_x365_-_04.1_(DVD)[B6CE8458].mkv", - "release_group": "SpoonSubs", - "source": "DVD" - }, - { - "anime_title": "Swing out Sisters", - "audio_term": "AC3", - "file_checksum": "3ABD57E6", - "file_extension": "mp4", - "file_name": "[SubDESU-H] Swing out Sisters Complete Version (720p x264 8bit AC3) [3ABD57E6].mp4", - "other": "Version", - "release_group": "SubDESU-H", - "release_information": "Complete", - "video_resolution": "720p", - "video_term": [ - "x264", - "8bit" - ] - }, - { - "anime_title": "Kidou Senshi Gundam UC Unicorn", - "audio_term": [ - "AAC", - "5.1ch", - "JP", - "EN" - ], - "episode_number": 2, - "episode_prefix": "episode", - "file_extension": "mp4", - "file_name": "[TV-J] Kidou Senshi Gundam UC Unicorn - episode.02 [BD 1920x1080 h264+AAC(5.1ch JP+EN) +Sub(JP-EN-SP-FR-CH) Chap].mp4", - 'other': 'Chap', - 'release_group': 'TV-J', - 'source': 'BD', - 'subtitles': ['Sub', 'JP-EN-SP-FR-CH'], - "video_resolution": "1920x1080", - "video_term": "h264" - }, - { - "anime_title": "Toradora!", - "anime_year": 2008, - "audio_term": "FLAC", - "episode_number": 1, - "episode_title": "Tiger and Dragon", - "file_checksum": "1234ABCD", - "file_extension": "mkv", - "file_name": "[TaigaSubs]_Toradora!_(2008)_-_01v2_-_Tiger_and_Dragon_[1280x720_H.264_FLAC][1234ABCD].mkv", - "release_group": "TaigaSubs", - "release_version": 2, - "video_resolution": "1280x720", - "video_term": "H.264" - }, - { - "anime_title": "Fullmetal Alchemist", - "anime_year": 2009, - "episode_number": 4, - "file_checksum": "40F2A957", - "file_extension": "mp4", - "file_name": "[Taka]_Fullmetal_Alchemist_(2009)_04_[720p][40F2A957].mp4", - "release_group": "Taka", - "video_resolution": "720p" - }, - { - "anime_title": "Today in Class 5-2", - "episode_number": 4, - "file_extension": "avi", - "file_name": "[Triad]_Today_in_Class_5-2_-_04.avi", - "release_group": "Triad" - }, - { - "anime_title": "Hyouka", - "audio_term": "FLAC", - "episode_number": [ - 1, - 4 - ], - "file_name": "[Tsundere] Hyouka - 01v2-04 [BDRip h264 1920x1080 10bit FLAC]", - "release_group": "Tsundere", - "release_version": 2, - "source": "BDRip", - "video_resolution": "1920x1080", - "video_term": [ - "h264", - "10bit" - ] - }, - { - "anime_title": "Evangelion 3.33 You Can (Not) Redo", - "audio_term": "flac", - "file_checksum": "F2060CF5", - "file_extension": "mkv", - "file_name": "[UTW-THORA] Evangelion 3.33 You Can (Not) Redo [BD][1080p,x264,flac][F2060CF5].mkv", - "release_group": "UTW-THORA", - "source": "BD", - "video_resolution": "1080p", - "video_term": "x264" - }, - { - "anime_title": "Summer Wars", - "audio_term": "TrueHD5.1", - "file_checksum": "9F311DAB", - "file_extension": "mkv", - "file_name": "[UTW-TMD]_Summer_Wars_[BD][h264-720p][TrueHD5.1][9F311DAB].mkv", - "release_group": "UTW-TMD", - "source": "BD", - "video_resolution": "720p", - "video_term": "h264" - }, - { - "anime_title": "Accel World EX01", - "audio_term": "AAC", - "file_checksum": "3E56EE18", - "file_extension": "mkv", - "file_name": "[UTW]_Accel_World_-_EX01_[BD][h264-720p_AAC][3E56EE18].mkv", - "release_group": "UTW", - "source": "BD", - "video_resolution": "720p", - "video_term": "h264" - }, - { - "anime_title": "Fate Zero", - "audio_term": "AC3", - "episode_number": 1, - "file_checksum": "02A0491D", - "file_extension": "mkv", - "file_name": "[UTW]_Fate_Zero_-_01_[BD][h264-720p_AC3][02A0491D].mkv", - "release_group": "UTW", - "source": "BD", - "video_resolution": "720p", - "video_term": "h264" - }, - { - "anime_title": "Bokura Ga Ita", - "audio_term": "AC3", - "episode_number": 1, - "file_checksum": "BFCE1627", - "file_extension": "mkv", - "file_name": "[Urusai]_Bokura_Ga_Ita_01_[DVD_h264_AC3]_[BFCE1627][Fixed].mkv", - "other": "Fixed", - "release_group": "Urusai", - "source": "DVD", - "video_term": "h264" - }, - { - "anime_title": "First Squad The Morment Of Truth", - "file_extension": "mkv", - "file_name": "[ValdikSS]_First_Squad_The_Morment_Of_Truth_[720x576_h264_dvdscr_eng_hardsub].mkv", - "language": "eng", - "other": "dvdscr", - "release_group": "ValdikSS", - "subtitles": "hardsub", - "video_resolution": "720x576", - "video_term": "h264" - }, - { - "anime_title": "009-1", - "episode_number": 2, - "file_checksum": "36D2444D", - "file_extension": "mkv", - "file_name": "[Yoroshiku]_009-1_-_02_(H264)_[36D2444D].mkv", - "release_group": "Yoroshiku", - "video_term": "H264" - }, - { - "anime_title": "Darker than Black Gemini of the Meteor", - "episode_number": 1, - "file_checksum": "65274FDE", - "file_extension": "7z", - "file_name": "[Yuurisan-Subs]_Darker_than_Black_-_Gemini_of_the_Meteor_-_01v2_[65274FDE].patch.7z", - "release_group": "Yuurisan-Subs", - "release_information": "patch", - "release_version": 2 - }, - { - "anime_title": "Shingeki no Kyojin", - "audio_term": "AAC", - "episode_number": 25, - "file_extension": "mp4", - "file_name": "[Zero-Raws] Shingeki no Kyojin - 25 END (MBS 1280x720 x264 AAC).mp4", - "other": "MBS", - "release_group": "Zero-Raws", - "release_information": "END", - "video_resolution": "1280x720", - "video_term": "x264" - }, - { - "anime_title": "Machine-Doll wa Kizutsukanai", - "episode_number": 1, - "episode_title": "Facing ''Cannibal Candy'' I", - "file_checksum": "B99C8DED", - "file_extension": "mkv", - "file_name": "[Zom-B] Machine-Doll wa Kizutsukanai - 01 - Facing ''Cannibal Candy'' I (kuroi, FFF remux) [B99C8DED].mkv", - "other": [ - "kuroi", - "FFF" - ], - "release_group": "Zom-B", - "release_information": "remux" - }, - { - "anime_title": "Sora no Woto", - "audio_term": "AAC", - "episode_number": 7.5, - "episode_title": "Drinking Party Fortress Battle", - "file_checksum": "F7DF16F7", - "file_extension": "mkv", - "file_name": "[Zurako] Sora no Woto - 07.5 - Drinking Party - Fortress Battle (BD 1080p AAC) [F7DF16F7].mkv", - "release_group": "Zurako", - "source": "BD", - "video_resolution": "1080p" - }, - { - "anime_title": "Nanatsu no Taizai S3 Kamigami no Gekirin", - "episode_number": 21, - "file_extension": "mkv", - "file_name": "[zza] Nanatsu no Taizai - S03 - Kamigami no Gekirin - 21 [1080p.x265].mkv", - "release_group": "zza", - "video_resolution": "1080p", - "video_term": "x265" - }, - { - "anime_season": 3, - "anime_season_prefix": "S", - "anime_title": "Nanatsu no Taizai", - "anime_type": "OVA", - "episode_number": 21, - "file_extension": "mkv", - "file_name": "[zza] Nanatsu no Taizai - S03 - OVA - 21 [1080p.x265].mkv", - "release_group": "zza", - "video_resolution": "1080p", - "video_term": "x265" - }, - { - "anime_title": "Shingeki no Kyojin", - "audio_term": "AAC", - "episode_number": 5, - "file_extension": "mp4", - "file_name": "[[Zero-Raws] Shingeki no Kyojin - 05 (MBS 1280x720 x264 AAC).mp4", - "other": "MBS", - "release_group": "Zero-Raws", - "video_resolution": "1280x720", - "video_term": "x264" - }, - { - "anime_title": "R O D the TV", - "episode_number": 1, - "file_extension": "mkv", - "file_name": "[a4e]R.O.D_the_TV_01[divx5.2.1].mkv", - "other": "divx5.2.1", - "release_group": "a4e" - }, - { - "anime_title": "Seikon no Qwaser", - "episode_number": 13, - "file_checksum": "988DB090", - "file_extension": "mkv", - "file_name": "[chibi-Doki] Seikon no Qwaser - 13v0 (Uncensored Director's Cut) [988DB090].mkv", - "other": [ - "Uncensored", - "Director's", - "Cut" - ], - "release_group": "chibi-Doki", - "release_version": 0 - }, - { - "anime_title": "Lupin III Kutabare! Nostradamus", - "audio_term": "MultiAudio", - "file_extension": "mkv", - "file_name": "[fong] Lupin III - Kutabare! Nostradamus [BDrip.1080p.10bit.MultiAudio].mkv", - "release_group": "fong", - "source": "BDrip", - "video_resolution": "1080p", - "video_term": "10bit" - }, - { - "anime_season": 2, - "anime_season_prefix": "Season", - "anime_title": "Kimi ni Todoke", - "episode_number": 0, - "file_checksum": "BF735BC4", - "file_extension": "mkv", - "file_name": "[gg]_Kimi_ni_Todoke_2nd_Season_-_00_[BF735BC4].mkv", - "release_group": "gg" - }, - { - "anime_title": "Nazca", - "episode_number": 1, - "episode_prefix": "episode", - "file_extension": "mkv", - "file_name": "[kito].Nazca.episode.01.DVDRip.[x264.He-aac.{Jpn}+Sub{Fr}].mkv", - 'other': 'He-aac.{Jpn}', - 'release_group': 'kito', - 'source': 'DVDRip', - 'subtitles': ['Sub', 'Fr'], - "video_term": "x264" - }, - { - "anime_title": "Natsume Yuujinchou Shi", - "audio_term": "AAC", - "file_name": "[tlacatlc6] Natsume Yuujinchou Shi Vol. 1v2 & Vol. 2 (BD 1280x720 x264 AAC)", - "release_group": "tlacatlc6", - "release_version": 2, - "source": "BD", - "video_resolution": "1280x720", - "video_term": "x264", - "volume_number": [ - 1, - 2 - ], - "volume_prefix": [ - "Vol.", - "Vol." - ] - }, - { - "anime_title": "Elfen Lied", - "audio_term": [ - "5.1", - "AAC" - ], - "episode_number": 12, - "file_extension": "mkv", - "file_name": "]AkihitoSubs] Elfen Lied - 12 [BD 1920x1080 x265 10Bit 5.1 AAC].mkv", - "release_group": "AkihitoSubs", - "source": "BD", - "video_resolution": "1920x1080", - "video_term": [ - "x265", - "10Bit" - ] - }, - { - 'anime_title': 'A Certain Scientific Railgun', - 'anime_season_prefix': 'S', - 'anime_season': 2, - 'file_name': 'A.Certain.Scientific.Railgun.S02.1080p-Hi10p.BluRay.FLAC2.0.x264-CTR (English Dubbed Dual Audio)', - 'video_resolution': '1080p', - 'video_term': 'Hi10p', - 'source': 'BluRay', - 'other': ['FLAC2.0', 'x264-CTR'], - 'language': 'English', - 'subtitles': 'Dubbed', - 'audio_term': 'Dual Audio' - }, - { - "anime_season": [1, 2, 3, 4], - "anime_season_prefix": ["S", "S", "S", "S"], - "anime_title": "Sword Art Online", - "audio_term": ["Dual Audio", "AAC"], - "batch": [1, 2, 3, 4, "Alternative", "Movies", "Specials", "OVAs"], - "file_name": "[Anime Time] Sword Art Online (S01+S02+S03+S04+Alternative+Movies+Specials+OVAs) " - "[BD] [Dual Audio][1080p][HEVC 10bit x265][AAC][Eng Sub] [Batch] (SAO)", - "language": "Eng", - "other": ["Alternative", "Movies", "Specials", "OVAs", "SAO"], - "release_group": "Anime Time", - "release_information": "Batch", - "source": "BD", - "subtitles": "Sub", - "video_resolution": "1080p", - "video_term": ["HEVC", "10bit", "x265"] - } -] +from tests.fixtures.basic_episodes import basic_episodes, basic_episodes_db +from tests.fixtures.seasons import seasons +from tests.fixtures.ranges import ranges +from tests.fixtures.episode_titles import episode_titles +from tests.fixtures.years import years +from tests.fixtures.series_types import series_types +from tests.fixtures.content_types import content_types +from tests.fixtures.release_info import release_info +from tests.fixtures.video_audio import video_audio +from tests.fixtures.complex import complex_cases as complex_fixtures + +heuristic = ( + basic_episodes + + seasons + + ranges + + episode_titles + + years + + series_types + + content_types + + release_info + + video_audio + + complex_fixtures +) + +with_db = basic_episodes_db + +table = heuristic + with_db diff --git a/tests/fixtures/video_audio.py b/tests/fixtures/video_audio.py new file mode 100644 index 0000000..25dda21 --- /dev/null +++ b/tests/fixtures/video_audio.py @@ -0,0 +1,22 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata + +video_audio = [ + Metadata( + audio_term=[ + "flac" + ], + file_name="5_centimeters_per_second[1904x1072.h264.flac][niizk]", + release_group=[ + "niizk" + ], + video_resolution=[ + VideoResolution( + video_height=1072, + video_width=1904 + ) + ], + video_term=[ + "h264" + ] + ) +] diff --git a/tests/fixtures/years.py b/tests/fixtures/years.py new file mode 100644 index 0000000..75dc9f6 --- /dev/null +++ b/tests/fixtures/years.py @@ -0,0 +1,172 @@ +from aniparse.output_schemas import VideoResolution, SequenceNumber, SequenceIdentifierNumber, SequenceRange, SeriesInfo, Metadata + +years = [ + Metadata( + audio_term=[ + "dts-es" + ], + file_name="evangelion_1.11_you_are_(not)_alone_(2009)_[1080p,bluray,x264,dts-es]_-_thora", + release_group=[ + "thora" + ], + series=[ + SeriesInfo( + title="evangelion 1.11 you are (not) alone", + year=[ + SequenceNumber( + number=2009 + ) + ] + ) + ], + source=[ + "bluray", + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264", + ] + ), + Metadata( + audio_term=[ + "dts" + ], + file_name="gin'iro_no_kami_no_agito_(2006)_[1080p,bluray,x264,dts]_-_thora", + release_group=[ + "thora" + ], + series=[ + SeriesInfo( + title="gin'iro no kami no agito", + year=[ + SequenceNumber( + number=2006 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + audio_term=[ + "flac", + "dts" + ], + file_name="howl's_moving_castle_(2004)_[1080p,bluray,flac,dts,x264]_-_thora v2", + release_group=[ + "thora" + ], + release_version=[ + "2" + ], + series=[ + SeriesInfo( + title="howl's moving castle", + year=[ + SequenceNumber( + number=2004 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=1080, + scan_method="p" + ) + ], + video_term=[ + "x264" + ] + ), + Metadata( + audio_term=[ + "ac3" + ], + file_name="[fb] crayon shin-chan movie 2 the secret of buri buri kingdom [divx5 ac3] 1994 [852x480] v2", + release_group=[ + "fb" + ], + release_version=[ + "2" + ], + series=[ + SeriesInfo( + title="crayon shin-chan movie 2 the secret of buri buri kingdom", + year=[ + SequenceNumber( + number=1994 + ) + ] + ) + ], + video_resolution=[ + VideoResolution( + video_height=480, + video_width=852 + ) + ], + video_term=[ + "divx5" + ] + ), + Metadata( + file_name="[lrl] 1001 nights (1998) [dvd]", + release_group=[ + "lrl" + ], + series=[ + SeriesInfo( + title="1001 nights", + year=[ + SequenceNumber( + number=1998 + ) + ] + ) + ], + source=[ + "dvd" + ] + ), + Metadata( + file_name="FAIRY TAIL (2014)", + series=[ + SeriesInfo( + title="FAIRY TAIL", + year=[ + SequenceNumber( + number=2014 + ) + ] + ) + ] + ), + Metadata( + file_name="Example Anime (TV) (2020)", + series=[ + SeriesInfo( + title="Example Anime", + type="TV", + year=[ + SequenceNumber( + number=2020 + ) + ] + ) + ] + ) +] diff --git a/tests/test_aniparse.py b/tests/test_aniparse.py deleted file mode 100644 index 1eb0bf4..0000000 --- a/tests/test_aniparse.py +++ /dev/null @@ -1,50 +0,0 @@ -from unittest import TestCase - -import aniparse -from aniparse.element import ElementCategory -from aniparse.keyword import KeywordManager, option -from tests.fixtures.table import table - - -class TestAniparse(TestCase): - - def test_table(self): - keyword = KeywordManager() - keyword.add(ElementCategory.RELEASE_GROUP, option["default"], [ - 'THORA' - ]) - for index, expected in enumerate(table): - filename = expected["file_name"] - anime = aniparse.parse(filename, keyword_manager=keyword) - self.assertEqual(expected, anime, 'on entry number %d' % index) - - def test_season_part_as_unique(self): - titles = { - 'Attack On Titan Season 3 Part 2.mkv': { - True: {'file_name': 'Attack On Titan Season 3 Part 2.mkv', - 'file_extension': 'mkv', - 'anime_title': 'Attack On Titan Season 3 Part 2'}, - False: {'file_name': 'Attack On Titan Season 3 Part 2.mkv', - 'file_extension': 'mkv', - 'anime_title': 'Attack On Titan', - 'anime_season_prefix': 'Season', - 'anime_season': 3}, - }, - 'Attack On Titan Season 3 Part 2 - 1.mkv': { - True: {'file_name': 'Attack On Titan Season 3 Part 2 - 1.mkv', - 'file_extension': 'mkv', - 'anime_title': 'Attack On Titan Season 3 Part 2', - 'episode_number': 1}, - False: {'file_name': 'Attack On Titan Season 3 Part 2 - 1.mkv', - 'file_extension': 'mkv', - 'anime_title': 'Attack On Titan', - 'anime_season_prefix': 'Season', - 'anime_season': 3, - 'episode_number': 1}, - } - } - - for title, options in titles.items(): - for condition, expected in options.items(): - anime = aniparse.parse(title, options={'season_part_as_unique': condition}) - self.assertEqual(expected, anime) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..d5c8258 --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,69 @@ +"""Heuristic-only test fixtures — no DB/custom_keywords needed.""" +import pytest +from tests.fixtures.basic_episodes import basic_episodes +from tests.fixtures.seasons import seasons +from tests.fixtures.ranges import ranges +from tests.fixtures.episode_titles import episode_titles +from tests.fixtures.years import years +from tests.fixtures.series_types import series_types +from tests.fixtures.content_types import content_types +from tests.fixtures.release_info import release_info +from tests.fixtures.video_audio import video_audio +from tests.fixtures.complex import complex_cases as complex_fixtures +from tests.fixtures.folder_context import folder_context +from tests.conftest import run_fixture + + +@pytest.mark.parametrize("fixture", basic_episodes, ids=lambda f: f.file_name[:60]) +def test_basic_episodes(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", seasons, ids=lambda f: f.file_name[:60]) +def test_seasons(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", ranges, ids=lambda f: f.file_name[:60]) +def test_ranges(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", episode_titles, ids=lambda f: f.file_name[:60]) +def test_episode_titles(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", years, ids=lambda f: f.file_name[:60]) +def test_years(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", series_types, ids=lambda f: f.file_name[:60]) +def test_series_types(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", content_types, ids=lambda f: f.file_name[:60]) +def test_content_types(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", release_info, ids=lambda f: f.file_name[:60]) +def test_release_info(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", video_audio, ids=lambda f: f.file_name[:60]) +def test_video_audio(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", complex_fixtures, ids=lambda f: f.file_name[:60]) +def test_complex(fixture): + run_fixture(fixture) + + +@pytest.mark.parametrize("fixture", folder_context, ids=lambda f: f.path[:60]) +def test_folder_context(fixture): + run_fixture(fixture) diff --git a/tests/test_fixtures_db.py b/tests/test_fixtures_db.py new file mode 100644 index 0000000..70a40b1 --- /dev/null +++ b/tests/test_fixtures_db.py @@ -0,0 +1,9 @@ +"""DB-dependent test fixtures — require custom_keywords (simulated DB lookups).""" +import pytest +from tests.fixtures.basic_episodes import basic_episodes_db +from tests.conftest import run_fixture + + +@pytest.mark.parametrize("fixture", basic_episodes_db, ids=lambda f: f.file_name[:60]) +def test_basic_episodes_db(fixture): + run_fixture(fixture) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index cab7247..0000000 --- a/tox.ini +++ /dev/null @@ -1,22 +0,0 @@ -[tox] -minversion = 3.8.0 -envlist = py35, py36, py37, py38, py39, py310, py311 -isolated_build = true - -[gh-actions] -python = - 3.5: py35 - 3.6: py36 - 3.7: py37 - 3.8: py38 - 3.9: py39 - 3.10: py310 - 3.11: py311 - -[testenv] -setenv = - PYTHONPATH = {toxinidir} -deps = - -r {toxinidir}/requirements_dev.txt -commands = - pytest --basetemp={envtmpdir}