├── .vscode └── settings.json ├── ytdl_nfo ├── __main__.py ├── configs │ ├── vimeo.yaml │ ├── zype.yaml │ ├── youtube.yaml │ ├── bilibili.yaml │ ├── twitch_clips.yaml │ ├── twitch_vod.yaml │ ├── youtube_tab.yaml │ ├── test.yaml │ ├── nebula_channel.yaml │ └── nebula_video.yaml ├── __init__.py ├── Ytdl_nfo.py └── nfo.py ├── flake.nix ├── flake.lock ├── pyproject.toml ├── LICENSE ├── .gitignore ├── README.md └── poetry.lock /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.autopep8Args": [ 3 | "--max-line-length=79" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /ytdl_nfo/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import ytdl_nfo 4 | 5 | if __name__ == '__main__': 6 | ytdl_nfo.main() 7 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/vimeo.yaml: -------------------------------------------------------------------------------- 1 | episodedetails: 2 | - title: '{title}' 3 | - showtitle: '{uploader}' 4 | - uniqueid: 5 | attr: 6 | type: 'vimeo' 7 | default: "true" 8 | value: '{id}' 9 | - plot: '{description}' 10 | - premiered: 11 | convert: 'date' 12 | input_f: '%Y%m%d' 13 | output_f: '%Y-%m-%d' 14 | value: '{upload_date}' 15 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/zype.yaml: -------------------------------------------------------------------------------- 1 | episodedetails: 2 | - title: '{title}' 3 | - showtitle: '{uploader}' 4 | - uniqueid: 5 | attr: 6 | type: 'zype' 7 | default: "true" 8 | value: '{id}' 9 | - plot: '{description}' 10 | - premiered: 11 | convert: 'date' 12 | input_f: '%Y%m%d' 13 | output_f: '%Y-%m-%d' 14 | value: '{upload_date}' 15 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/youtube.yaml: -------------------------------------------------------------------------------- 1 | episodedetails: 2 | - title: '{title}' 3 | - showtitle: '{uploader}' 4 | - uniqueid: 5 | attr: 6 | type: 'youtube' 7 | default: "true" 8 | value: '{id}' 9 | - plot: '{description}' 10 | - premiered: 11 | convert: 'date' 12 | input_f: '%Y%m%d' 13 | output_f: '%Y-%m-%d' 14 | value: '{upload_date}' 15 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/bilibili.yaml: -------------------------------------------------------------------------------- 1 | episodedetails: 2 | - title: '{title}' 3 | - showtitle: '{uploader}' 4 | - uniqueid: 5 | attr: 6 | type: 'bilibili' 7 | default: "true" 8 | value: '{id}' 9 | - plot: '{description}' 10 | - premiered: 11 | convert: 'date' 12 | input_f: '%Y%m%d' 13 | output_f: '%Y-%m-%d' 14 | value: '{upload_date}' 15 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/twitch_clips.yaml: -------------------------------------------------------------------------------- 1 | episodedetails: 2 | - title: '{title}' 3 | - showtitle: '{creator}' 4 | - uniqueid: 5 | attr: 6 | type: 'twitchclips' 7 | default: "true" 8 | value: '{id}' 9 | - plot: '{fulltitle}' 10 | - premiered: 11 | convert: 'date' 12 | input_f: '%Y%m%d' 13 | output_f: '%Y-%m-%d' 14 | value: '{upload_date}' 15 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/twitch_vod.yaml: -------------------------------------------------------------------------------- 1 | episodedetails: 2 | - title: '{title}' 3 | - showtitle: '{uploader}' 4 | - uniqueid: 5 | attr: 6 | type: 'twitchvod' 7 | default: "true" 8 | value: '{id}' 9 | - plot: '{fulltitle}' 10 | - premiered: 11 | convert: 'date' 12 | input_f: '%Y%m%d' 13 | output_f: '%Y-%m-%d' 14 | value: '{upload_date}' 15 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/youtube_tab.yaml: -------------------------------------------------------------------------------- 1 | episodedetails: 2 | - title: '{title}' 3 | - showtitle: '{uploader}' 4 | - uniqueid: 5 | attr: 6 | type: 'youtubetab' 7 | default: "true" 8 | value: '{id}' 9 | - plot: '{description}' 10 | - premiered: 11 | convert: 'date' 12 | input_f: '%Y%m%d' 13 | output_f: '%Y-%m-%d' 14 | value: '{modified_date}' 15 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/test.yaml: -------------------------------------------------------------------------------- 1 | episodedetails: 2 | - genre!: "{categories}" 3 | - actor>name!: "{cast}" 4 | - really>nested>actor>name>list!: "{cast}" 5 | - this>should>be>invalid: "{upload_date}" 6 | - normal: "{upload_date}" 7 | - converted>date: 8 | convert: "date" 9 | input_f: "%Y%m%d" 10 | output_f: "%Y-%m-%d" 11 | value: "{upload_date}" 12 | - nested>date!: 13 | convert: "date" 14 | input_f: "%Y%m%d" 15 | output_f: "%Y-%m-%d" 16 | value: "{nested_dates}" 17 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Nix Dev Shell"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | }; 7 | 8 | outputs = 9 | { 10 | nixpkgs, 11 | ... 12 | }: 13 | let 14 | system = "x86_64-linux"; 15 | pkgs = nixpkgs.legacyPackages.${system}; 16 | in 17 | { 18 | devShells.${system} = { 19 | default = pkgs.mkShell { 20 | buildInputs = [ 21 | pkgs.poetry 22 | ]; 23 | }; 24 | }; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/nebula_channel.yaml: -------------------------------------------------------------------------------- 1 | tvshow: 2 | - title: '{title}' 3 | - sorttitle: '{title}' 4 | - season: '-1' 5 | - episode: '-1' 6 | - year: 7 | convert: 'date' 8 | value: '{upload_date}' 9 | input_f: '%Y%m%d' 10 | output_f: '%Y' 11 | - displayorder: 'aired' 12 | - outline: '{description}' 13 | - plot: '{description}' 14 | - genre: 'Nebula' 15 | - uniqueid: 16 | attr: 17 | type: 'nebula' 18 | default: 'true' 19 | value: '{id}' 20 | - nebulaid: '{id}' 21 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1738824222, 6 | "narHash": "sha256-U3SNq+waitGIotmgg/Et3J7o4NvUtP2gb2VhME5QXiw=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "550e11f27ba790351d390d9eca3b80ad0f0254e7", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ytdl-nfo" 3 | version = "0.3.0" 4 | description = "Utility to convert youtube-dl/yt-dlp json metadata to .nfo" 5 | license = { text = "Unlicense" } 6 | readme = "README.md" 7 | requires-python = ">=3.8" 8 | authors = [{ name = "Owen", email = "owdevel@gmail.com" }] 9 | 10 | dependencies = ["PyYAML>=6.0.1", "setuptools>=70.2.0"] 11 | 12 | [project.urls] 13 | repository = "https://github.com/owdevel/ytdl-nfo" 14 | 15 | [project.scripts] 16 | ytdl-nfo = "ytdl_nfo:main" 17 | 18 | [tool.poetry.group.dev] 19 | optional = true 20 | 21 | [tool.poetry.group.dev.dependencies] 22 | flake8 = "^5.0.4" 23 | autopep8 = "^1.6.0" 24 | 25 | [build-system] 26 | requires = ["poetry-core>=2.0.0,<3.0.0"] 27 | build-backend = "poetry.core.masonry.api" 28 | -------------------------------------------------------------------------------- /ytdl_nfo/configs/nebula_video.yaml: -------------------------------------------------------------------------------- 1 | episodedetails: 2 | - title: '{title}' 3 | - showtitle: '{uploader}' 4 | - season: 5 | convert: 'date' 6 | value: '{upload_date}' 7 | input_f: '%Y%m%d' 8 | output_f: '%Y' 9 | - episode: 10 | convert: 'date' 11 | value: '{upload_date}' 12 | input_f: '%Y%m%d' 13 | output_f: '%m%d' 14 | - year: 15 | convert: 'date' 16 | value: '{upload_date}' 17 | input_f: '%Y%m%d' 18 | output_f: '%Y' 19 | - aired: 20 | convert: 'date' 21 | value: '{upload_date}' 22 | input_f: '%Y%m%d' 23 | output_f: '%Y-%m-%d' 24 | - plot: '{description}' 25 | - genre: '{extractor_key}' 26 | - uniqueid: 27 | attr: 28 | type: 'nebula' 29 | default: 'true' 30 | value: '{channel_id}' 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /ytdl_nfo/__init__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import re 4 | 5 | from .Ytdl_nfo import Ytdl_nfo 6 | 7 | 8 | def main(): 9 | parser = argparse.ArgumentParser( 10 | description="ytdl_nfo, a youtube-dl utility to convert the output of 'youtube-dl --write-info-json' to an NFO for use with Kodi, Plex, Emby, Jellyfin, etc." 11 | ) 12 | parser.add_argument( 13 | "--config", 14 | help="Show the path to the config directory", 15 | action="version", 16 | version=f"{get_config_path()}", 17 | ) 18 | parser.add_argument("-e", "--extractor", help="Specify specific extractor") 19 | parser.add_argument( 20 | "-r", 21 | "--regex", 22 | type=str, 23 | default=r".json$", 24 | help="A regular expression used to search for JSON source files", 25 | ) 26 | parser.add_argument( 27 | "-w", "--overwrite", action="store_true", help="Overwrite existing NFO files" 28 | ) 29 | parser.add_argument( 30 | "input", 31 | metavar="JSON_FILE", 32 | type=str, 33 | help="JSON file to convert or directory to process recursively", 34 | ) 35 | args = parser.parse_args() 36 | 37 | extractor_str = args.extractor if args.extractor is not None else "file specific" 38 | 39 | if os.path.isfile(args.input): 40 | print(f"Processing {args.input} with {extractor_str} extractor") 41 | file = Ytdl_nfo(args.input, args.extractor) 42 | file.process() 43 | else: 44 | for root, dirs, files in os.walk(args.input): 45 | for file_name in files: 46 | file_path = os.path.join(root, file_name) 47 | if file_name.endswith(".live_chat.json"): 48 | continue 49 | if re.search(args.regex, file_name): 50 | file = Ytdl_nfo(file_path, args.extractor) 51 | if args.overwrite or not os.path.exists(file.get_nfo_path()): 52 | print(f"Processing {file_path} with {extractor_str} extractor") 53 | file.process() 54 | 55 | 56 | def get_config_path(): 57 | return os.path.join(os.path.dirname(__file__), "configs") 58 | 59 | 60 | __all__ = ["main", "Ytdl_nfo", "nfo"] 61 | -------------------------------------------------------------------------------- /ytdl_nfo/Ytdl_nfo.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | from .nfo import get_config 5 | 6 | 7 | class Ytdl_nfo: 8 | def __init__(self, file_path, extractor=None): 9 | self.path = file_path 10 | self.dir = os.path.dirname(file_path) 11 | self.data = None 12 | self.filename = None 13 | self.input_ok = True 14 | self.extractor = extractor 15 | 16 | # Read json data 17 | if self.input_ok: 18 | try: 19 | with open(self.path, "rt", encoding="utf-8") as f: 20 | self.data = json.load(f) 21 | except json.JSONDecodeError: 22 | print(f'Error: Failed to parse JSON in file {self.path}') 23 | self.input_ok = False 24 | 25 | if self.extractor is None and self.data is not None: 26 | data_extractor = self.data.get('extractor') 27 | if isinstance(data_extractor, str): 28 | self.extractor = re.sub(r'[:?*/\\]', '_', data_extractor.lower()) 29 | 30 | if self.path.endswith(".info.json"): 31 | self.filename = self.path[:-10] 32 | elif self.data is not None: 33 | data_filename = self.data.get('_filename') 34 | if isinstance(data_filename, str): 35 | self.filename = os.path.splitext(data_filename)[0] 36 | if self.filename is None: 37 | self.filename = self.path 38 | 39 | if isinstance(self.extractor, str): 40 | self.nfo = get_config(self.extractor, self.path) 41 | else: 42 | self.nfo = None 43 | 44 | def process(self): 45 | if not self.input_ok or self.nfo is None or not self.nfo.config_ok(): 46 | return False 47 | generated = self.nfo.generate(self.data) 48 | if generated: 49 | self.write_nfo() 50 | return generated 51 | 52 | def get_nfo_path(self): 53 | return f'{self.filename}.nfo' 54 | 55 | def write_nfo(self): 56 | if self.nfo is not None and self.nfo.generated_ok(): 57 | nfo_path = self.get_nfo_path() 58 | self.nfo.write_nfo(nfo_path) 59 | 60 | def print_data(self): 61 | print(json.dumps(self.data, indent=4, sort_keys=True)) 62 | 63 | def get_nfo(self): 64 | if self.nfo is not None: 65 | return self.nfo.get_nfo() 66 | else: 67 | return None 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ytdl-nfo : youtube-dl NFO generator 2 | 3 | [youtube-dl](https://github.com/ytdl-org/youtube-dl) is an incredibly useful tool for downloading and archiving footage from across the web; however, viewing and organizing these files can be a hassle. 4 | 5 | **ytdl-nfo** automates metadata processing so that media files can be easily imported into media centers such as [Plex](https://www.plex.tv/), [Emby](https://emby.media/), [Jellyfin](https://jellyfin.org/), etc. It does this by parsing each `.info.json` file created by youtube-dl (using the `--write-info-json` flag) and generating a Kodi-compatible `.nfo` file. 6 | 7 | While this package was originally built for youtube-dl, the goal is to maintain compatibility with related forks, such as [yt-dlp](https://github.com/yt-dlp/yt-dlp). 8 | 9 | > :warning: **Warning**: This package is still in early stages and breaking changes may be introduced. 10 | 11 | ## Installation 12 | 13 | ### Python 3 pipx (recommended) 14 | 15 | [pipx](https://github.com/pipxproject/pipx) is a tool that installs a package and its dependencies in an isolated environment. 16 | 17 | 1. Install [Python 3.8](https://www.python.org/downloads/) (or later) 18 | 2. Install [pipx](https://github.com/pipxproject/pipx) 19 | 3. Run `pipx install ytdl-nfo` 20 | 21 | ### Python 3 pip 22 | 23 | 1. Install [Python 3.8](https://www.python.org/downloads/) (or later) 24 | 2. Installed [pip](https://pip.pypa.io/en/stable/installation/) 25 | 3. Run `pip install ytdl-nfo` 26 | 27 | ### Package from Source 28 | 29 | 1. Install [Python 3.8](https://www.python.org/downloads/) (or later) 30 | 2. Install [Python Poetry](https://python-poetry.org/) 31 | 3. Clone the repo using `git clone https://github.com/owdevel/ytdl_nfo.git` 32 | 4. Create a dev environment with `poetry install` 33 | 5. Build with `poetry build` 34 | 6. Install from the `dist` directory with `pip install ./dist/ytdl_nfo-x.x.x.tar.gz` 35 | 36 | ## Usage 37 | 38 | youtube-dl uses site-specific extractors to collect technical data about a media file. This metadata, along with the extractor ID, are written to a `.info.json` file when the `--write-info-json` flag is used. ytdl-nfo uses a set of YAML configs, located in `ytdl_nfo/configs` to control how metadata from the JSON file is mapped to NFO tags. 39 | 40 | If extractor auto-detection fails or you want to override the default, use the `--extractor` option to specify a particular template. The template must be located at `ytdl_nfo/configs/.yaml`. 41 | 42 | ```text 43 | python3 -m ytdl_nfo [-h] [--config] [-e EXTRACTOR] [--regex REGEX] [-w] JSON_FILE 44 | 45 | positional arguments: 46 | JSON_FILE JSON file to convert or directory to process recursively 47 | 48 | options: 49 | -h, --help show this help message and exit 50 | --config Show the path to the config directory 51 | -e EXTRACTOR, --extractor EXTRACTOR 52 | Specify specific extractor 53 | -r, --regex REGEX A regular expression used to search for JSON source files 54 | -w, --overwrite Overwrite existing NFO files 55 | ``` 56 | 57 | ### Examples 58 | 59 | ```bash 60 | # Display the configuration location 61 | ytdl-nfo --config 62 | 63 | # Create a single NFO file using metadata from `great_video.info.json` 64 | ytdl-nfo great_video.info.json 65 | 66 | # Create an NFO file for each `.info.json` file located in the `video_folder` directory 67 | # (provided a matching extractor template exists in the `ytdl_nfo/configs` directory) 68 | ytdl-nfo video_folder 69 | 70 | # Create a single NFO file using metadata from `great_video.info.json` and the `custom_extractor_name` template 71 | ytdl-nfo --extractor custom_extractor_name great_video.info.json 72 | ``` 73 | 74 | ## Contributing 75 | 76 | This is a small project I started to learn how to use the Python packaging system whilst providing some useful functionality for my home server setup. Issues/Pull Requests and constructive criticism are welcome. 77 | 78 | ### Development Environment 79 | 80 | 1. Install [Python 3.8](https://www.python.org/downloads/) (or later) 81 | 2. Install [Python Poetry](https://python-poetry.org/) 82 | 3. Create a fork of this repo 83 | 4. Clone your fork using `git clone git@github.com:/ytdl-nfo.git` 84 | 5. Change to the project directory and initialize the environment using poetry 85 | 86 | ```bash 87 | cd ytdl-nfo 88 | poetry install 89 | ``` 90 | 91 | 6. Run the application using `poetry run ytdl-nfo`, or use `poetry shell` to enter the virtual env 92 | 93 | ## Todo 94 | 95 | - [ ] Add try catches to pretty print errors 96 | - [ ] Documentation and templates for creating custom extractors 97 | - [x] Documentation of CLI arguments 98 | - [x] Recursive folder searching 99 | - [x] Add package to pypi 100 | -------------------------------------------------------------------------------- /ytdl_nfo/nfo.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import ast 3 | import datetime as dt 4 | import xml.etree.ElementTree as ET 5 | import pkg_resources 6 | from collections import defaultdict 7 | from xml.dom import minidom 8 | 9 | 10 | class Nfo: 11 | def __init__(self, extractor, file_path): 12 | self.data = None 13 | self.top = None 14 | try: 15 | extractor_path = f"configs/{extractor}.yaml" 16 | with pkg_resources.resource_stream("ytdl_nfo", extractor_path) as f: 17 | self.data = yaml.load(f, Loader=yaml.FullLoader) 18 | except FileNotFoundError: 19 | print(f"Error: No config available for extractor {extractor} in file {file_path}") 20 | 21 | def config_ok(self): 22 | return self.data is not None 23 | 24 | def generated_ok(self): 25 | return self.top is not None 26 | 27 | def generate(self, raw_data): 28 | 29 | # There should only be one top level node 30 | top_name = list(self.data.keys())[0] 31 | self.top = ET.Element(top_name) 32 | 33 | # Recursively generate the rest of the NFO 34 | try: 35 | self.__create_child(self.top, self.data[top_name], raw_data) 36 | except ValueError as e: 37 | print(e) 38 | return False 39 | 40 | return True 41 | 42 | def __create_child(self, parent, subtree, raw_data): 43 | # Some .info.json files may not include an upload_date. 44 | if raw_data.get("upload_date") is None: 45 | date = dt.datetime.fromtimestamp(raw_data["epoch"]) 46 | raw_data["upload_date"] = date.strftime("%Y%m%d") 47 | 48 | # Allow missing keys to give an empty string instead of 49 | # a KeyError when formatting values 50 | # https://stackoverflow.com/a/21754294 51 | format_dict = defaultdict(lambda: "") 52 | format_dict.update(raw_data) 53 | 54 | # Check if current node is a list 55 | if isinstance(subtree, list): 56 | 57 | # Process individual nodes 58 | for child in subtree: 59 | self.__create_child(parent, child, raw_data) 60 | return 61 | 62 | # Process data in child node 63 | child_name = list(subtree.keys())[0] 64 | 65 | table = child_name[-1] == '!' 66 | 67 | attributes = {} 68 | children = [] 69 | 70 | # Check if attributes are present 71 | if isinstance(subtree[child_name], dict): 72 | attributes = subtree[child_name] 73 | value = subtree[child_name]['value'] 74 | 75 | # Set children if value flag 76 | if table: 77 | children = ast.literal_eval(value.format_map(format_dict)) 78 | else: 79 | children = [value.format_map(format_dict)] 80 | 81 | if 'convert' in attributes.keys(): 82 | target_type = attributes['convert'] 83 | input_f = attributes['input_f'] 84 | output_f = attributes['output_f'] 85 | 86 | for i in range(len(children)): 87 | if target_type == 'date': 88 | date = dt.datetime.strptime(children[i], input_f) 89 | children[i] = date.strftime(output_f) 90 | 91 | # Value only 92 | else: 93 | if table: 94 | children = ast.literal_eval( 95 | subtree[child_name].format_map(format_dict)) 96 | else: 97 | children = [subtree[child_name].format_map(format_dict)] 98 | 99 | # Add the child node(s) 100 | child_name = child_name.rstrip('!') 101 | 102 | for value in children: 103 | sub_parent = parent 104 | sub_name = child_name 105 | sub_index = sub_name.find('>') 106 | while sub_index > -1: 107 | if not table: 108 | raise ValueError(f'Error with key {sub_name}: > deliminator can only be used for lists') 109 | sub_parent = ET.SubElement(sub_parent, sub_name[:sub_index]) 110 | sub_name = sub_name[sub_index + 1:] 111 | sub_index = sub_name.find('>') 112 | 113 | child = ET.SubElement(sub_parent, sub_name) 114 | child.text = value 115 | 116 | # Add attributes 117 | if 'attr' in attributes.keys(): 118 | for attribute, attr_value in attributes['attr'].items(): 119 | child.set(attribute, attr_value.format_map(format_dict)) 120 | 121 | def print_nfo(self): 122 | xmlstr = minidom.parseString(ET.tostring( 123 | self.top, 'utf-8')).toprettyxml(indent=" ") 124 | print(xmlstr) 125 | 126 | def write_nfo(self, filename): 127 | xmlstr = minidom.parseString(ET.tostring( 128 | self.top, 'utf-8')).toprettyxml(indent=" ") 129 | with open(filename, 'wt', encoding="utf-8") as f: 130 | f.write(xmlstr) 131 | 132 | def get_nfo(self): 133 | xmlstr = minidom.parseString(ET.tostring( 134 | self.top, 'utf-8')).toprettyxml(indent=" ") 135 | return xmlstr 136 | 137 | 138 | def get_config(extractor, file_path): 139 | return Nfo(extractor, file_path) 140 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "autopep8" 5 | version = "1.7.0" 6 | description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" 7 | optional = false 8 | python-versions = "*" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "autopep8-1.7.0-py2.py3-none-any.whl", hash = "sha256:6f09e90a2be784317e84dc1add17ebfc7abe3924239957a37e5040e27d812087"}, 12 | {file = "autopep8-1.7.0.tar.gz", hash = "sha256:ca9b1a83e53a7fad65d731dc7a2a2d50aa48f43850407c59f6a1a306c4201142"}, 13 | ] 14 | 15 | [package.dependencies] 16 | pycodestyle = ">=2.9.1" 17 | toml = "*" 18 | 19 | [[package]] 20 | name = "flake8" 21 | version = "5.0.4" 22 | description = "the modular source code checker: pep8 pyflakes and co" 23 | optional = false 24 | python-versions = ">=3.6.1" 25 | groups = ["dev"] 26 | files = [ 27 | {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"}, 28 | {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"}, 29 | ] 30 | 31 | [package.dependencies] 32 | mccabe = ">=0.7.0,<0.8.0" 33 | pycodestyle = ">=2.9.0,<2.10.0" 34 | pyflakes = ">=2.5.0,<2.6.0" 35 | 36 | [[package]] 37 | name = "mccabe" 38 | version = "0.7.0" 39 | description = "McCabe checker, plugin for flake8" 40 | optional = false 41 | python-versions = ">=3.6" 42 | groups = ["dev"] 43 | files = [ 44 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 45 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 46 | ] 47 | 48 | [[package]] 49 | name = "pycodestyle" 50 | version = "2.9.1" 51 | description = "Python style guide checker" 52 | optional = false 53 | python-versions = ">=3.6" 54 | groups = ["dev"] 55 | files = [ 56 | {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"}, 57 | {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"}, 58 | ] 59 | 60 | [[package]] 61 | name = "pyflakes" 62 | version = "2.5.0" 63 | description = "passive checker of Python programs" 64 | optional = false 65 | python-versions = ">=3.6" 66 | groups = ["dev"] 67 | files = [ 68 | {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"}, 69 | {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"}, 70 | ] 71 | 72 | [[package]] 73 | name = "pyyaml" 74 | version = "6.0.2" 75 | description = "YAML parser and emitter for Python" 76 | optional = false 77 | python-versions = ">=3.8" 78 | groups = ["main"] 79 | files = [ 80 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, 81 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, 82 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, 83 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, 84 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, 85 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, 86 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, 87 | {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, 88 | {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, 89 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, 90 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, 91 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, 92 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, 93 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, 94 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, 95 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, 96 | {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, 97 | {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, 98 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, 99 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, 100 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, 101 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, 102 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, 103 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, 104 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, 105 | {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, 106 | {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, 107 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, 108 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, 109 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, 110 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, 111 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, 112 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, 113 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, 114 | {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, 115 | {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, 116 | {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, 117 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, 118 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, 119 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, 120 | {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, 121 | {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, 122 | {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, 123 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, 124 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, 125 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, 126 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, 127 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, 128 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, 129 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, 130 | {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, 131 | {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, 132 | {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, 133 | ] 134 | 135 | [[package]] 136 | name = "setuptools" 137 | version = "75.3.0" 138 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 139 | optional = false 140 | python-versions = ">=3.8" 141 | groups = ["main"] 142 | files = [ 143 | {file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"}, 144 | {file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"}, 145 | ] 146 | 147 | [package.extras] 148 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] 149 | core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] 150 | cover = ["pytest-cov"] 151 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 152 | enabler = ["pytest-enabler (>=2.2)"] 153 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 154 | type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] 155 | 156 | [[package]] 157 | name = "toml" 158 | version = "0.10.2" 159 | description = "Python Library for Tom's Obvious, Minimal Language" 160 | optional = false 161 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 162 | groups = ["dev"] 163 | files = [ 164 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 165 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 166 | ] 167 | 168 | [metadata] 169 | lock-version = "2.1" 170 | python-versions = ">=3.8" 171 | content-hash = "3035dd4540dbea597d8c559440cdce13e16f2655536cd727c753c9d3bb1e44f6" 172 | --------------------------------------------------------------------------------