├── .gitignore ├── .pytest_cache └── v │ └── cache │ ├── lastfailed │ ├── nodeids │ └── stepwise ├── LICENSE ├── MANIFEST.in ├── README.rst ├── anidbcli ├── __init__.py ├── __main__.py ├── anidbconnector.py ├── cli.py ├── encryptors.py ├── libed2k.py ├── operations.py └── output.py ├── docs ├── Makefile ├── api.rst ├── basics.rst ├── conf.py ├── ed2k.rst ├── index.rst ├── installation.rst └── make.bat ├── requirements.txt ├── run_tests.py ├── setup.py └── tests ├── test_connection.py └── test_operations.py /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | __venv__ 104 | .pytest_cache 105 | .vscode -------------------------------------------------------------------------------- /.pytest_cache/v/cache/lastfailed: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.pytest_cache/v/cache/nodeids: -------------------------------------------------------------------------------- 1 | [ 2 | "tests/test_connection.py::test_encryption", 3 | "tests/test_connection.py::test_unecrypted_initialization", 4 | "tests/test_connection.py::test_encrypted_initialization", 5 | "tests/test_connection.py::test_udp_send_retry", 6 | "tests/test_operations.py::test_add_ok", 7 | "tests/test_operations.py::test_add_already_in_mylist", 8 | "tests/test_operations.py::test_add_send_exception", 9 | "tests/test_operations.py::test_parse_file_info", 10 | "tests/test_operations.py::test_parse_file_info_err", 11 | "tests/test_operations.py::test_hash_operation", 12 | "tests/test_operations.py::test_hash_error", 13 | "tests/test_operations.py::test_rename_works_with_subtitles" 14 | ] -------------------------------------------------------------------------------- /.pytest_cache/v/cache/stepwise: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 adameste 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include requirements.txt -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | anidbcli 2 | =========================== 3 | Anidbcli is a simple command line interface for managing your anime collection on your local computer or NAS (using only ssh). 4 | 5 | Requirements 6 | --------------------------- 7 | * `Python 3.6 `_ or newer (version 3.5 seems to work as well) 8 | 9 | Key features 10 | --------------------------- 11 | * ed2k hashing library utilizing multiple cores 12 | * adding anime to mylist 13 | * utilize data from anidb to move/rename the files 14 | * moves/renames the subtitle and other files with same extension 15 | * encryption 16 | 17 | Installation 18 | --------------------------- 19 | 20 | The package can be installed automatically using pip. 21 | 22 | .. code-block:: bash 23 | 24 | pip install anidbcli 25 | pip install --upgrade anidbcli #update 26 | 27 | Package can be also installed from source like this. 28 | 29 | .. code-block:: bash 30 | 31 | python setup.py install 32 | 33 | After installation anidbcli can be invoked like a python module 34 | 35 | .. code-block:: bash 36 | 37 | python -m anidbcli 38 | 39 | or directly by typing following in the command line 40 | 41 | .. code-block:: bash 42 | 43 | anidbcli 44 | 45 | Quickstart 46 | --------------------------- 47 | The basic syntax is 48 | 49 | .. code-block:: bash 50 | 51 | anidbcli [OPTIONS] ed2k/api [OPTIONS] ARGS 52 | 53 | If you want to just generate ed2k links for mkv and mp4 files recursively for given folders and copy them to clipboard, use: 54 | 55 | .. code-block:: bash 56 | 57 | anidbcli -r -e mkv,mp4 ed2k -c "path/to/directory" "path/to/directory2" 58 | 59 | Where 60 | * **-r** is recursive 61 | * **-e** comma separated list of extensions, that are treated as anime files 62 | 63 | 64 | To add all mkv files from directory resursively to mylist use: 65 | 66 | .. code-block:: bash 67 | 68 | anidbcli -r -e mkv api -u "username" -p "password" -k "apikey" -a "path/to/directory" 69 | 70 | Where 71 | * **"password"** is your anidb password 72 | * **"username"** is your anidb username 73 | * **"apikey"** is anidb upd api key, that you can set at https://anidb.net/user/setting. If no key is provided, unencrypted connection will be used. 74 | 75 | Optionally, if you don't provide password or username, you will be prompted to input them. 76 | 77 | .. code-block:: bash 78 | 79 | anidbcli -r -e mkv api -k "apikey" -a "path/to/directory" 80 | Enter your username: "username" 81 | Enter your password: "password" 82 | 83 | To set files to a specified state use: 84 | 85 | .. code-block:: bash 86 | 87 | anidbcli -r -e mkv api -u "username" -p "password" -k "apikey" --state 0 --show-ed2k -a "path/to/directory" 88 | 89 | Where 90 | * **"show-ed2k"** is an optional parameter to show/print out ed2k links, while adding/renaming files. 91 | * **"state"** is your desired MyList file state (see https://wiki.anidb.net/Filestates). 92 | 93 | The number 0 can be substituted for different states: 94 | * 0 is unknown (default) 95 | * 1 is internal storage 96 | * 2 is external storage 97 | * 3 is deleted 98 | * 4 is remote storage 99 | 100 | To rename all mkv and mp4 files in directory recursively using data from api you can call 101 | 102 | .. code-block:: bash 103 | 104 | anidbcli -r -e mkv,mp4 api -u "username" -p "password" -k "apikey" -sr "%ep_no% - %ep_english% [%g_name%]" "path/to/directory" 105 | 106 | Where 107 | * **"-r"** rename using provided format string 108 | * **"-s"** prepend original file path to each renamed file. Without this flag the files would me moved to current directory. 109 | * **"-U"** use with -a (-aU). Adds the files to mylist as unwatched. 110 | 111 | Also along with the parameter "-r" you can use one of the following parameters: 112 | * **"-h"** Create hardlinks instead of renaming. 113 | * **"-l"** Create softlinks instead of renaming. 114 | * **"-t"** Save session info instead of logging out (session lifetime is 35 minutes after last command). Use for subsequent calls of anidbcli to avoid api bans. 115 | 116 | Anidbcli should be called with all the parameters as usual. If the session was saved before more than 35 minutes, a new session is created instead. 117 | 118 | You can also move watched anime from unwatched directory to watched directory and add it to mylist at the same time using following command. 119 | 120 | .. code-block:: bash 121 | 122 | anidbcli -r -e mkv,mp4 api -u "username" -p "password" -k "apikey" -xr "watched/%a_english%/%ep_no% - %ep_english% [%g_name%]" "unwatched/anime1" "unwatched/anime2" 123 | 124 | Where 125 | * **"-x"** Delete empty folders after moving all files away. 126 | 127 | **NOTE: All files with same name and different extension (fx. subtitle files) will be renamed/moved as well.** 128 | 129 | Selected usable tags: 130 | * **%md5%** - md5 hash of file. 131 | * **%sha1%** - sha1 hash of file. 132 | * **%crc32%** - crc32 hash of file. 133 | * **%resolution%** - file resolution, for example "1920x1080" 134 | * **%aired%** - Episode aired date. Only option that needs "--date-format" option. You can find list of available tags at https://docs.python.org/3.6/library/time.html#time.strftime. 135 | * **%year%** - Year, the anime was aired. Can be a timespan, if the anime was aired several years "1990-2005" etc. 136 | * **%a_romaji%** - Anime title in romaji. 137 | * **%a_kanji%** - Anime title in kanji. 138 | * **%a_english%** - English anime title. 139 | * **%ep_no%** - Episode number. Prepends the necessary zeros, fx. 001, 01 140 | * **%ep_english%** - English episode name. 141 | * **%ep_romaji%** - Episode name in romaji. 142 | * **%ep_kanji%** - Episode name in kanji. 143 | * **%g_name%** - Group that released the anime. fx. HorribleSubs. 144 | * **%g_sname%** - Short group name. 145 | 146 | Complete list of usable tags in format string: 147 | 148 | .. code-block:: bash 149 | 150 | %fid%, %aid%, %eid%, %gid%, %lid%, %status%, %size%, %ed2k%, %md5%, %sha1%, %crc32%, %color_depth%, 151 | %quality%, %source%, %audio_codec%, %audio_bitrate%, %video_codec%, %video_bitrate%, %resolution%, 152 | %filetype%, %dub_language%, %sub_language%, %length%, %aired%, %filename%, %ep_total%, %ep_last%, %year%, 153 | %a_type%, %a_categories%, %a_romaji%, %a_kanji%, %a_english%, %a_other%, %a_short%, %a_synonyms%, %ep_no%, 154 | %ep_english%, %ep_romaji%, %ep_kanji%, %g_name%, %g_sname%, %version%, %censored% 155 | -------------------------------------------------------------------------------- /anidbcli/__init__.py: -------------------------------------------------------------------------------- 1 | from .anidbconnector import AnidbConnector 2 | from .libed2k import get_ed2k_link,hash_file 3 | from .cli import main 4 | 5 | __all__ = ['AnidbConnector', "main", "hash_file", "get_ed2k_link"] -------------------------------------------------------------------------------- /anidbcli/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | main() -------------------------------------------------------------------------------- /anidbcli/anidbconnector.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import hashlib 3 | import time 4 | import os 5 | import json 6 | import anidbcli.encryptors as encryptors 7 | 8 | API_ADDRESS = "api.anidb.net" 9 | API_PORT = 9000 10 | SOCKET_TIMEOUT = 5 11 | MAX_RECEIVE_SIZE = 65507 # Max size of an UDP packet is about 1400B anyway 12 | RETRY_COUNT = 3 13 | 14 | API_ENDPOINT_ENCRYPT = "ENCRYPT user=%s&type=1" 15 | API_ENDPOINT_LOGIN = "AUTH user=%s&pass=%s&protover=3&client=anidbcli&clientver=1&enc=UTF8" 16 | API_ENDPOINT_LOGOUT = "LOGOUT s=%s" 17 | 18 | ENCRYPTION_ENABLED = 209 19 | LOGIN_ACCEPTED = 200 20 | LOGIN_ACCEPTED_NEW_VERSION_AVAILABLE = 201 21 | 22 | 23 | class AnidbConnector: 24 | def __init__(self, bind_addr = None): 25 | """For class initialization use class methods create_plain or create_secure.""" 26 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 27 | if bind_addr: 28 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 29 | self.socket.bind(tuple(bind_addr)) 30 | self.socket.connect((socket.gethostbyname_ex(API_ADDRESS)[2][0], API_PORT)) 31 | self.socket.settimeout(SOCKET_TIMEOUT) 32 | self.crypto = encryptors.PlainTextCrypto() 33 | self.salt = None 34 | 35 | def _set_crypto(self, crypto): 36 | self.crypto = crypto 37 | 38 | @classmethod 39 | def create_plain(cls, username, password): 40 | """Creates unencrypted UDP API connection using the provided credenitals.""" 41 | instance = cls() 42 | instance._login(username, password) 43 | return instance 44 | 45 | @classmethod 46 | def create_secure(cls, username, password, api_key): 47 | """Creates AES128 encrypted UDP API connection using the provided credenitals and users api key.""" 48 | instance = cls() 49 | enc_res = instance.send_request(API_ENDPOINT_ENCRYPT % username, False) 50 | if enc_res["code"] != ENCRYPTION_ENABLED: 51 | raise Exception(enc_res["data"]) 52 | instance.salt = enc_res["data"].split(" ")[0] 53 | md5 = hashlib.md5(bytes(api_key + instance.salt, "ascii")) 54 | instance._set_crypto(encryptors.Aes128TextEncryptor(md5.digest())) 55 | instance._login(username, password) 56 | return instance 57 | @classmethod 58 | def create_from_session(cls, session_key, sock_addr, api_key, salt): 59 | """Crates instance from an existing session. If salt is not None, encrypted instance is created.""" 60 | instance = cls(sock_addr) 61 | instance.session = session_key 62 | if (salt != None): 63 | instance.salt = salt 64 | md5 = hashlib.md5(bytes(api_key + instance.salt, "ascii")) 65 | instance._set_crypto(encryptors.Aes128TextEncryptor(md5.digest())) 66 | return instance 67 | 68 | 69 | def _login(self, username, password): 70 | response = self.send_request(API_ENDPOINT_LOGIN % (username, password), False) 71 | if response["code"] == LOGIN_ACCEPTED or response["code"] == LOGIN_ACCEPTED_NEW_VERSION_AVAILABLE: 72 | self.session = response["data"].split(" ")[0] 73 | else: 74 | raise Exception(response["data"]) 75 | 76 | def close(self, persistent, persist_file): 77 | """Logs out the user from current session and closes the connection.""" 78 | if not self.session: 79 | raise Exception("Cannot logout: No active session.") 80 | if persistent: 81 | try: 82 | os.makedirs(os.path.dirname(persist_file)) 83 | except: pass # Exists 84 | d = dict() 85 | d["session_key"] = self.session 86 | d["timestamp"] = time.time() 87 | d["salt"] = None 88 | d["sockaddr"] = self.socket.getsockname() 89 | if (self.salt): d["salt"] = self.salt 90 | with open(persist_file, "w") as file: 91 | file.writelines(json.dumps(d)) 92 | else: 93 | try: 94 | os.remove(persist_file) 95 | except: pass # does not exist 96 | self.send_request(API_ENDPOINT_LOGOUT % self.session, False) 97 | self.socket.close() 98 | 99 | 100 | def send_request(self, content, appendSession=True): 101 | """Sends request to the API and returns a dictionary containing response code and data.""" 102 | if appendSession: 103 | if not self.session: 104 | raise Exception("No session was set") 105 | content += "&s=%s" % self.session 106 | res = None 107 | for _ in range(RETRY_COUNT): 108 | try: 109 | self.socket.send(self.crypto.Encrypt(content)) 110 | res = self.socket.recv(MAX_RECEIVE_SIZE) 111 | break 112 | except: # Socket timeout / upd packet not sent 113 | time.sleep(1) 114 | pass 115 | if not res: 116 | raise Exception("Cound not connect to anidb UDP API: Socket timeout.") 117 | res = self.crypto.Decrypt(res) 118 | res = res.rstrip("\n") 119 | response = dict() 120 | response["code"] = int(res[:3]) 121 | response["data"] = res[4:] 122 | return response -------------------------------------------------------------------------------- /anidbcli/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | import os 3 | import time 4 | import json 5 | import pyperclip 6 | import anidbcli.libed2k as libed2k 7 | import anidbcli.anidbconnector as anidbconnector 8 | import anidbcli.output as output 9 | import anidbcli.operations as operations 10 | 11 | @click.group(name="anidbcli") 12 | @click.version_option(version="1.66", prog_name="anidbcli") 13 | @click.option("--recursive", "-r", is_flag=True, default=False, help="Scan folders for files recursively.") 14 | @click.option("--extensions", "-e", help="List of file extensions separated by , character.") 15 | @click.option("--quiet", "-q", is_flag=True, default=False, help="Display only warnings and errors.") 16 | @click.pass_context 17 | def cli(ctx, recursive, extensions, quiet): 18 | ctx.obj["recursive"] = recursive 19 | ctx.obj["extensions"] = None 20 | ctx.obj["output"] = output.CliOutput(quiet) 21 | if extensions: 22 | ext = [] 23 | for i in extensions.split(","): 24 | i = i.strip() 25 | i = i.replace(".","") 26 | ext.append(i) 27 | ctx.obj["extensions"] = ext 28 | 29 | 30 | @cli.command(help="Outputs file hashes that can be added manually to anidb.") 31 | @click.option("--clipboard", "-c", is_flag=True, default=False, help="Copy the results to clipboard when finished.") 32 | @click.argument("files", nargs=-1, type=click.Path(exists=True)) 33 | @click.pass_context 34 | def ed2k(ctx , files, clipboard): 35 | to_process = get_files_to_process(files, ctx) 36 | links = [] 37 | for file in to_process: 38 | link = libed2k.get_ed2k_link(file) 39 | print(link) 40 | links.append(link) 41 | if clipboard: 42 | pyperclip.copy("\n".join(links)) 43 | ctx.obj["output"].success("All links were copied to clipboard.") 44 | 45 | @cli.command(help="Utilize the anidb API. You can add files to mylist and/or organize them to directories using " 46 | + "information obtained from AniDB.") 47 | @click.option('--username', "-u", prompt=True) 48 | @click.option('--password', "-p", prompt=True, hide_input=True) 49 | @click.option('--apikey', "-k") 50 | @click.option("--add", "-a", is_flag=True, default=False, help="Add files to mylist.") 51 | @click.option("--unwatched", "-U", is_flag=True, default=False, help="Add files to mylist as unwatched. Use with -a flag.") 52 | @click.option("--rename", "-r", default=None, help="Rename the files according to provided format. See documentation for more info.") 53 | @click.option("--link", "-h", is_flag=True, default=False, help="Create a hardlink instead of renaming. Should be used with rename parameter.") 54 | @click.option("--softlink", "-l", is_flag=True, default=False, help="Create a symbolic link instead of renaming. Should be used with rename parameter.") 55 | @click.option("--keep-structure", "-s", default=False, is_flag=True, help="Prepends file original directory path to the new path. See documentation for info.") 56 | @click.option("--date-format", "-d", default="%Y-%m-%d", help="Date format. See documentation for details.") 57 | @click.option("--delete-empty", "-x", default=False, is_flag=True, help="Delete empty folders after moving files.") 58 | @click.option("--persistent", "-t", default=False, is_flag=True, help="Save session info for next invocations with this parameter. (35 minutes session lifetime)") 59 | @click.option("--abort", default=False, is_flag=True, help="Abort if an usable tag is empty.") 60 | @click.option("--state", default=0, help="Specify the file state. (0-4)") 61 | @click.option("--show-ed2k", default=False, is_flag=True, help="Show ed2k link of processed file (while adding or renaming files).") 62 | @click.argument("files", nargs=-1, type=click.Path(exists=True)) 63 | @click.pass_context 64 | def api(ctx, username, password, apikey, add, unwatched, rename, files, keep_structure, date_format, delete_empty, link, softlink, persistent, abort, state, show_ed2k): 65 | if (not add and not rename): 66 | ctx.obj["output"].info("Nothing to do.") 67 | return 68 | try: 69 | conn = get_connector(apikey, username, password, persistent) 70 | except Exception as e: 71 | raise e 72 | ctx.obj["output"].error(e) 73 | exit(1) 74 | pipeline = [] 75 | pipeline.append(operations.HashOperation(ctx.obj["output"], show_ed2k)) 76 | if add: 77 | pipeline.append(operations.MylistAddOperation(conn, ctx.obj["output"], state, unwatched)) 78 | if rename: 79 | pipeline.append(operations.GetFileInfoOperation(conn, ctx.obj["output"])) 80 | pipeline.append(operations.RenameOperation(ctx.obj["output"], rename, date_format, delete_empty, keep_structure, softlink, link, abort)) 81 | to_process = get_files_to_process(files, ctx) 82 | for file in to_process: 83 | file_obj = {} 84 | file_obj["path"] = file 85 | ctx.obj["output"].info("Processing file \"" + file +"\"") 86 | 87 | for operation in pipeline: 88 | res = operation.Process(file_obj) 89 | if not res: # Critical error, cannot proceed with pipeline 90 | break 91 | conn.close(persistent, get_persistent_file_path()) 92 | 93 | def get_connector(apikey, username, password, persistent): 94 | conn = None 95 | if persistent: 96 | path = get_persistent_file_path() 97 | if (os.path.exists(path)): 98 | with open(path, "r") as file: 99 | lines = file.read() 100 | data = json.loads(lines) 101 | if ((time.time() - data["timestamp"]) < 60 * 10): 102 | conn = anidbconnector.AnidbConnector.create_from_session(data["session_key"], data["sockaddr"], apikey, data["salt"]) 103 | if (conn != None): return conn 104 | if apikey: 105 | conn = anidbconnector.AnidbConnector.create_secure(username, password, apikey) 106 | else: 107 | conn = anidbconnector.AnidbConnector.create_plain(username, password) 108 | return conn 109 | 110 | def get_persistent_file_path(): 111 | path = os.getenv("APPDATA") 112 | if (path == None): # Unix 113 | path = os.getenv("HOME") 114 | path = os.path.join(path, ".anidbcli", "session.json") 115 | else: 116 | path = os.path.join(path, "anidbcli", "session.json") 117 | return path 118 | 119 | def get_files_to_process(files, ctx): 120 | to_process = [] 121 | for file in files: 122 | if os.path.isfile(file): 123 | to_process.append(file) 124 | elif ctx.obj["recursive"]: 125 | for folder, _, files in os.walk(file): 126 | for filename in files: 127 | to_process.append(os.path.join(folder,filename)) 128 | ret = [] 129 | for f in to_process: 130 | if (check_extension(f, ctx.obj["extensions"])): 131 | ret.append(f) 132 | return ret 133 | 134 | def check_extension(path, extensions): 135 | if not extensions: 136 | return True 137 | else: 138 | _, file_extension = os.path.splitext(path) 139 | return file_extension.replace(".", "") in extensions 140 | 141 | 142 | def main(): 143 | cli(obj={}) 144 | 145 | if __name__ == "__main__": 146 | main() 147 | -------------------------------------------------------------------------------- /anidbcli/encryptors.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from Crypto.Cipher import AES 3 | 4 | class TextCrypto: 5 | @abstractmethod 6 | def Encrypt(self, message): pass 7 | @abstractmethod 8 | def Decrypt(self, message): pass 9 | 10 | class PlainTextCrypto(TextCrypto): 11 | def Encrypt(self, message): 12 | return bytes(message, "utf-8") 13 | def Decrypt(self, message): 14 | return message.decode("utf-8", errors="replace") 15 | 16 | BS = 16 17 | pad = lambda s: s + (BS - len(s) % BS) * chr(BS - len(s) % BS) 18 | unpad = lambda s : s[0:-ord(s[-1])] 19 | 20 | class Aes128TextEncryptor(TextCrypto): 21 | def __init__(self, encryption_key): 22 | self.aes = AES.new(encryption_key, AES.MODE_ECB) 23 | def Encrypt(self, message): 24 | message = pad(message) 25 | message = bytes(message, "utf-8") 26 | return self.aes.encrypt(message) 27 | def Decrypt(self, message): 28 | ret = self.aes.decrypt(message) 29 | ret = ret.decode("utf-8", errors="ignore") 30 | return unpad(ret) -------------------------------------------------------------------------------- /anidbcli/libed2k.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import functools 3 | import os 4 | import multiprocessing 5 | from joblib import Parallel, delayed 6 | 7 | CHUNK_SIZE = 9728000 # 9500KB 8 | MAX_CORES = 4 9 | 10 | def get_ed2k_link(file_path, file_hash=None): 11 | name = os.path.basename(file_path) 12 | filesize = os.path.getsize(file_path) 13 | if file_hash is None: 14 | md4 = hash_file(file_path) 15 | else: 16 | md4 = file_hash 17 | return "ed2k://|file|%s|%d|%s|" % (name,filesize, md4) 18 | 19 | def md4_hash(data): 20 | m = hashlib.new('md4') 21 | m.update(data) 22 | return m.digest() 23 | 24 | def hash_file(file_path): 25 | """ Returns the ed2k hash of a given file. """ 26 | 27 | 28 | 29 | def generator(f): 30 | while True: 31 | x = f.read(CHUNK_SIZE) 32 | if x: 33 | yield x 34 | else: 35 | return 36 | 37 | with open(file_path, 'rb') as f: 38 | a = generator(f) 39 | num_cores = min(multiprocessing.cpu_count(), MAX_CORES) 40 | hashes = Parallel(n_jobs=num_cores)(delayed(md4_hash)(i) for i in a) 41 | if len(hashes) == 1: 42 | return hashes[0].hex() 43 | else: 44 | return md4_hash(b"".join(hashes)).hex() 45 | -------------------------------------------------------------------------------- /anidbcli/operations.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import os 3 | import datetime 4 | import re 5 | import glob 6 | import errno 7 | import time 8 | import shutil 9 | 10 | import anidbcli.libed2k as libed2k 11 | 12 | # ed2k,md5,sha1,crc32,resolution,aired,year,romanji,kanji,english,epno,epname,epromanji,epkanji,groupname,shortgroupname 13 | API_ENDPOINT_FILE = "FILE size=%d&ed2k=%s&fmask=79FAFFE900&amask=F2FCF0C0" 14 | API_ENDPOINT_FILE_ONLY_ANIMEINFO = "FILE size=%d&ed2k=%s&fmask=0000000000&amask=F2FCF0C0" 15 | 16 | 17 | API_ENDPOINT_MYLYST_ADD = "MYLISTADD size=%d&ed2k=%s&viewed=%d&state=%s" 18 | API_ENDPOINT_MYLYST_EDIT = "MYLISTADD size=%d&ed2k=%s&edit=1&viewed=%d&state=%s" 19 | 20 | RESULT_FILE = 220 21 | RESULT_MYLIST_ENTRY_ADDED = 210 22 | RESULT_MYLIST_ENTRY_EDITED = 311 23 | RESULT_ALREADY_IN_MYLIST = 310 24 | 25 | 26 | def IsNullOrWhitespace(s): 27 | return s is None or s.isspace() or s == "" 28 | 29 | class Operation: 30 | @abstractmethod 31 | def Process(self, file): pass 32 | 33 | class MylistAddOperation(Operation): 34 | def __init__(self, connector, output, state, unwatched): 35 | self.connector = connector 36 | self.output = output 37 | self.state = state 38 | if unwatched: 39 | self.viewed = 0 40 | else: 41 | self.viewed = 1 42 | def Process(self, file): 43 | try: 44 | res = self.connector.send_request(API_ENDPOINT_MYLYST_ADD % (file["size"], file["ed2k"], self.viewed, int(self.state))) 45 | if res["code"] == RESULT_MYLIST_ENTRY_ADDED: 46 | self.output.success("Mylist entry added.") 47 | elif res["code"] == RESULT_ALREADY_IN_MYLIST: 48 | self.output.warning("Already in mylist.") 49 | res = self.connector.send_request(API_ENDPOINT_MYLYST_EDIT % (file["size"], file["ed2k"], self.viewed, int(self.state))) 50 | if res["code"] == RESULT_MYLIST_ENTRY_EDITED: 51 | self.output.success("Mylist entry state updated.") 52 | else: 53 | self.output.warning("Could not mark as watched.") 54 | else: 55 | self.output.error("Couldn't add to mylist: %s" % res["data"]) 56 | except Exception as e: 57 | self.output.error("Failed to add file to mylist: " + str(e)) 58 | 59 | return True 60 | 61 | class HashOperation(Operation): 62 | def __init__(self, output, show_ed2k): 63 | self.output = output 64 | self.show_ed2k = show_ed2k 65 | def Process(self, file): 66 | try: 67 | link = libed2k.hash_file(file["path"]) 68 | except Exception as e: 69 | self.output.error("Failed to generate hash: " + str(e)) 70 | return False 71 | file["ed2k"] = link 72 | file["size"] = os.path.getsize(file["path"]) 73 | self.output.success("Generated ed2k link.") 74 | if self.show_ed2k: 75 | self.output.info(libed2k.get_ed2k_link(file["path"], file["ed2k"])) 76 | return True 77 | 78 | 79 | class GetFileInfoOperation(Operation): 80 | def __init__(self, connector, output): 81 | self.connector = connector 82 | self.output = output 83 | 84 | 85 | def Process(self, file): 86 | try: 87 | res = self.connector.send_request(API_ENDPOINT_FILE % (file["size"], file["ed2k"])) 88 | except Exception as e: 89 | self.output.error("Failed to get file info: " + str(e)) 90 | return False 91 | if res["code"] != RESULT_FILE: 92 | self.output.error("Failed to get file info: %s" % res["data"]) 93 | return False 94 | parsed = parse_data(res["data"].split("\n")[1]) 95 | if len(parsed) < 42: 96 | try: 97 | parsed = parsed[:25] # Take file info only 98 | time.sleep(2) # UDP API allows max one request per 2 seconds 99 | res = self.connector.send_request(API_ENDPOINT_FILE_ONLY_ANIMEINFO % (file["size"], file["ed2k"])) 100 | parsed = parsed + parse_data(res["data"].split("\n")[1])[1:] # Add new anime info (file id on index 0) 101 | except Exception as e: 102 | self.output.error("Failed to get file info: " + str(e)) 103 | return False 104 | if res["code"] != RESULT_FILE: 105 | self.output.error("Failed to get file info: %s" % res["data"]) 106 | return False 107 | 108 | fileinfo = {} 109 | fileinfo["fid"] = parsed[0] 110 | fileinfo["aid"] = parsed[1] 111 | fileinfo["eid"] = parsed[2] 112 | fileinfo["gid"] = parsed[3] 113 | fileinfo["lid"] = parsed[4] 114 | fileinfo["file_state"] = parsed[5] 115 | fileinfo["size"] = parsed[6] 116 | fileinfo["ed2k"] = parsed[7] 117 | fileinfo["md5"] = parsed[8] 118 | fileinfo["sha1"] = parsed[9] 119 | fileinfo["crc32"] = parsed[10] 120 | fileinfo["color_depth"] = parsed[11] 121 | fileinfo["quality"] = parsed[12] 122 | fileinfo["source"] = parsed[13] 123 | fileinfo["audio_codec"] = parsed[14] 124 | fileinfo["audio_bitrate"] = parsed[15] 125 | fileinfo["video_codec"] = parsed[16] 126 | fileinfo["video_bitrate"] = parsed[17] 127 | fileinfo["resolution"] = parsed[18] 128 | fileinfo["filetype"] = parsed[19] 129 | fileinfo["dub_language"] = parsed[20] 130 | fileinfo["sub_language"] = parsed[21] 131 | fileinfo["length"] = parsed[22] 132 | fileinfo["aired"] = datetime.datetime.fromtimestamp(int(parsed[23])) 133 | fileinfo["filename"] = parsed[24] 134 | fileinfo["ep_total"] = parsed[25] 135 | fileinfo["ep_last"] = parsed[26] 136 | fileinfo["year"] = parsed[27] 137 | fileinfo["a_type"] = parsed[28] 138 | fileinfo["a_categories"] = parsed[29] 139 | fileinfo["a_romaji"] = parsed[30] 140 | fileinfo["a_kanji"] = parsed[31] 141 | fileinfo["a_english"] = parsed[32] 142 | fileinfo["a_other"] = parsed[33] 143 | fileinfo["a_short"] = parsed[34] 144 | fileinfo["a_synonyms"] = parsed[35] 145 | fileinfo["ep_no"] = parsed[36] 146 | fileinfo["ep_english"] = parsed[37] 147 | fileinfo["ep_romaji"] = parsed[38] 148 | fileinfo["ep_kanji"] = parsed[39] 149 | fileinfo["g_name"] = parsed[40] 150 | fileinfo["g_sname"] = parsed[41] 151 | fileinfo["version"] = "" 152 | fileinfo["censored"] = "" 153 | 154 | status = int(fileinfo["file_state"]) 155 | if status & 4: fileinfo["version"] = "v2" 156 | if status & 8: fileinfo["version"] = "v3" 157 | if status & 16: fileinfo["version"] = "v4" 158 | if status & 32: fileinfo["version"] = "v5" 159 | if status & 64: fileinfo["censored"] = "uncensored" 160 | if status & 128: fileinfo["censored"] = "censored" 161 | 162 | if IsNullOrWhitespace(fileinfo["ep_english"]): 163 | fileinfo["ep_english"] = fileinfo["ep_romaji"] 164 | if IsNullOrWhitespace(fileinfo["a_english"]): 165 | fileinfo["a_english"] = fileinfo["a_romaji"] 166 | 167 | file["info"] = construct_helper_tags(fileinfo) 168 | self.output.success("Successfully grabbed file info.") 169 | return True 170 | 171 | class RenameOperation(Operation): 172 | def __init__(self, output, target_path, date_format, delete_empty, keep_structure, soft_link, hard_link, abort): 173 | self.output = output 174 | self.target_path = target_path 175 | self.date_format = date_format 176 | self.delete_empty = delete_empty 177 | self.keep_structure = keep_structure 178 | self.soft_link = soft_link 179 | self.hard_link = hard_link 180 | self.abort = abort 181 | def Process(self, file): 182 | try: 183 | file["info"]["aired"] = file["info"]["aired"].strftime(self.date_format) 184 | except: 185 | self.output.warning("Invalid date format, using default one instead.") 186 | try: 187 | file["info"]["aired"] = file["info"]["aired"].strftime("%Y-%m-%d") 188 | except: 189 | pass # Invalid input format, leave as is 190 | target = self.target_path 191 | for tag in file["info"]: 192 | if (self.abort and ("%"+tag+"%" in target) and IsNullOrWhitespace(file["info"][tag])): 193 | self.output.error("Rename aborted, " + tag + " is empty.") 194 | return 195 | target = target.replace("%"+tag+"%", filename_friendly(file["info"][tag])) # Remove path invalid characters 196 | target = ' '.join(target.split()) # Replace multiple whitespaces with one 197 | filename, base_ext = os.path.splitext(file["path"]) 198 | for f in glob.glob(glob.escape(filename) + "*"): # Find subtitle files 199 | try: 200 | tmp_tgt = target 201 | if self.keep_structure: # Prepend original directory if set 202 | tmp_tgt = os.path.join(os.path.dirname(f),target) 203 | _, file_extension = os.path.splitext(f) 204 | try: 205 | os.makedirs(os.path.dirname(tmp_tgt + file_extension)) 206 | except: 207 | pass 208 | if self.soft_link: 209 | os.symlink(f, tmp_tgt + file_extension) 210 | self.output.success("Created soft link: \"%s\"" % (tmp_tgt + file_extension)) 211 | elif self.hard_link: 212 | os.link(f, tmp_tgt + file_extension) 213 | self.output.success("Created hard link: \"%s\"" % (tmp_tgt + file_extension)) 214 | else: 215 | shutil.move(f, tmp_tgt + file_extension) 216 | self.output.success("File renamed to: \"%s\"" % (tmp_tgt + file_extension)) 217 | except: 218 | self.output.error("Failed to rename/link to: \"%s\"" % (tmp_tgt + file_extension) + "\n") 219 | if self.delete_empty and len(os.listdir(os.path.dirname(file["path"]))) == 0: 220 | os.removedirs(os.path.dirname(file["path"])) 221 | file["path"] = target + base_ext 222 | 223 | def filename_friendly(input): 224 | replace_with_space = ["<", ">", "/", "\\", "*", "|"] 225 | for i in replace_with_space: 226 | input = input.replace(i, " ") 227 | input = input.replace("\"", "'") 228 | input = input.replace(":","") 229 | input = input.replace("?","") 230 | return input 231 | 232 | def parse_data(raw_data): 233 | res = raw_data.split("|") 234 | for idx, item in enumerate(res): 235 | item = item.replace("'", "§") # preseve lists by converting UDP list delimiter ' to § (§ seems unused in AniDB) 236 | item = item.replace("
", "\n") 237 | item = item.replace("/", "|") 238 | item = item.replace("`", "'") 239 | res[idx] = item 240 | return res 241 | 242 | def construct_helper_tags(fileinfo): 243 | year_list = re.findall('(\d{4})', fileinfo["year"]) 244 | if (len(year_list) > 0): 245 | fileinfo["year_start"] = year_list[0] 246 | fileinfo["year_end"] = year_list[-1] 247 | else: 248 | fileinfo["year_start"] = fileinfo["year_end"] = fileinfo["year"] 249 | 250 | res_match = re.findall('x(360|480|720|1080|2160)', fileinfo["resolution"]) 251 | if (len(res_match) > 0): 252 | fileinfo["resolution_abbr"] = res_match[0] + 'p' 253 | else: 254 | fileinfo["resolution_abbr"] = fileinfo["resolution"] 255 | return fileinfo 256 | -------------------------------------------------------------------------------- /anidbcli/output.py: -------------------------------------------------------------------------------- 1 | import colorama 2 | 3 | class CliOutput: 4 | def __init__(self, quiet): 5 | self.quiet = quiet 6 | self.initialized = False 7 | 8 | def __write_message(self, message): 9 | if self.initialized: 10 | colorama.reinit() 11 | else: 12 | colorama.init() 13 | self.initialized = True 14 | print(message) 15 | colorama.deinit() 16 | 17 | def info(self, message): 18 | if(self.quiet): return 19 | self.__write_message("%s[INFO]%s %s" % (colorama.Fore.BLUE, colorama.Style.RESET_ALL, str(message))) 20 | 21 | def success(self, message): 22 | if(self.quiet): return 23 | self.__write_message("%s[SUCCESS]%s %s" % (colorama.Fore.GREEN, colorama.Style.RESET_ALL, str(message))) 24 | 25 | def warning(self, message): 26 | self.__write_message("%s[WARNING]%s %s" % (colorama.Fore.YELLOW, colorama.Style.RESET_ALL, str(message))) 27 | 28 | def error(self, message): 29 | self.__write_message("%s[ERROR]%s %s" % (colorama.Fore.RED, colorama.Style.RESET_ALL, str(message))) 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = anidbcli 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | api 2 | =============================== 3 | This command uses the anidb API to add files to mylist or/and rename them. 4 | 5 | options 6 | ------------------------------- 7 | This is the complete list of options, some of them will be described further in following sections. 8 | * **"--username"**, **"-u"**: Your anidb username. Prompt will be displayed if unset. 9 | * **"--password"**, **"-p"**: Your anidb password. Prompt will be displayed if unset. 10 | * **"--apikey"**, **"-k"**: To enable encryption, you need to provide your apikey. 11 | * **"--add"**, **"-a"**: Add the files to mylist as watched. 12 | * **"--rename"**, **"-r"**: Renames the files using the provided format string. 13 | * **"--keep-structure"**, **"-s"**: Prepend the old file path to the provided format string. 14 | * **"--date-format"**, **"-d"**: Date format in python syntax. Default is "%Y-%m-%d". Should not contain any characters, that cannot be in filename. 15 | * **"--delete-empty"**, **"-x"**: Delete empty folders after moving the files. 16 | 17 | encryption 18 | ------------------------------- 19 | Anidb UDP api uses custom encryption based on AES128. To use encryption you must set your api key by visiting **Settings -> Account -> UDP API Key** on anidb website. 20 | http://anidb.net/perl-bin/animedb.pl?show=profile 21 | 22 | username and password 23 | ------------------------------- 24 | You can either pass those using options. If username and password are not passed via options, the user will be propmted to provide missing credenitals. 25 | 26 | So you can call either 27 | 28 | .. code-block:: bash 29 | 30 | anidb [OPTIONS] api -p password -u username [OTHER_OPTIONS] FILES 31 | 32 | or 33 | 34 | .. code-block:: bash 35 | 36 | anidb [OPTIONS] api [OPTIONS] FILES 37 | Enter your username: username 38 | Enter your password: ******** 39 | 40 | add 41 | ------------------------------- 42 | If this option is enabled, files are added to mylist as added. For example to add all mkv files from folder "Gintama" to mylist use: 43 | 44 | .. code-block:: bash 45 | 46 | anidb -r -e mkv api -a -u username -p password -k apikey "Gintama" 47 | 48 | rename 49 | ------------------------------- 50 | Option to move/rename files based on specified options. For rename option a format string needs to be provided. 51 | 52 | For example if you watched Gintama and Naruto and you want to move them to watched folder and add the files to mylist: 53 | 54 | .. code-block:: bash 55 | 56 | anidb -r -e mkv api -a -u username -p password -k apikey -r "watched/%a_english%/%ep_no% - %ep_english% [%g_name%][%resolution%]" "unwatched/Gintama" "unwatched/Naruto" 57 | 58 | If you wish to keep them in the original folder and just rename them, use the **"-s"** option, which will prepend original directory to format string when renaming. Like: 59 | 60 | .. code-block:: bash 61 | 62 | anidb -r -e mkv api -a -u username -p password -k apikey -sr "%a_english%/%ep_no% - %ep_english% [%g_name%][%resolution%]" "anime/Gintama" "anime/Naruto" 63 | 64 | If you want to delete empty folders after moving all files, use the **"-x"** option. 65 | 66 | .. code-block:: bash 67 | 68 | anidb -r -e mkv api -a -u username -p password -k apikey -xr "watched/%a_english%/%ep_no% - %ep_english% [%g_name%][%resolution%]" "unwatched/Gintama" "unwatched/Naruto" 69 | 70 | **NOTE: All files with same name and different extension will be renamed and moved as well. This is important if you have external subtitle files.** 71 | 72 | Complete list of usable tags in format string: 73 | * **%md5%** - md5 hash of file. 74 | * **%sha1%** - sha1 hash of file. 75 | * **%crc32%** - crc32 hash of file. 76 | * **%resolution%** - file resolution, for example "1920x1080" 77 | * **%aired%** - Episode aired date. Only option that needs "--date-format" option. You can find list of available tags at https://docs.python.org/3.6/library/time.html#time.strftime. 78 | * **%year%** - Year, the anime was aired. Can be a timespan, if the anime was aired several years "1990-2005" etc. 79 | * **%a_romaji%** - Anime title in romaji. 80 | * **%a_kanji%** - Anime title in kanji. 81 | * **%a_english%** - English anime title. 82 | * **%ep_no%** - Episode number. Prepends the necessary zeros, fx. 001, 01 83 | * **%ep_english%** - English episode name. 84 | * **%ep_romaji%** - Episode name in romaji. 85 | * **%ep_kanji%** - Episode name in kanji. 86 | * **%g_name%** - Group that released the anime. fx. HorribleSubs. 87 | * **%g_sname%** - Short group name. -------------------------------------------------------------------------------- /docs/basics.rst: -------------------------------------------------------------------------------- 1 | Basics 2 | ============================ 3 | There are 2 parameters common for both api and ed2k commands. Those are: 4 | * **"--recursive"**, **"-r"**: Look for files in given folders recursively. 5 | * **"--extensions"**, **"-e"**: Specify extensions, that are valid anime files. Program will ignore other files. Accepts a list of extensions (without .) seperated by comma (,). For example "mkv,avi,mp4". 6 | 7 | For example to recursively parse all mkv and mp4 files in given folders the arguments would be: 8 | 9 | .. code-block:: bash 10 | 11 | anidbcli -r -e mkv,mp4 [ed2k, api] FILES -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # anidbcli documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Feb 9 13:23:41 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ['sphinx.ext.autodoc', 35 | 'sphinx.ext.doctest', 36 | 'sphinx.ext.intersphinx'] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = 'anidbcli' 52 | copyright = '2018, Štěpán Adámek' 53 | author = 'Štěpán Adámek' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = '1.0' 61 | # The full version, including alpha/beta/rc tags. 62 | release = '1.0' 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This patterns also effect to html_static_path and html_extra_path 74 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = 'sphinx' 78 | 79 | # If true, `todo` and `todoList` produce output, else they produce nothing. 80 | todo_include_todos = False 81 | 82 | 83 | # -- Options for HTML output ---------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = 'alabaster' 89 | 90 | # Theme options are theme-specific and customize the look and feel of a theme 91 | # further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ['_static'] 100 | 101 | # Custom sidebar templates, must be a dictionary that maps document names 102 | # to template names. 103 | # 104 | # This is required for the alabaster theme 105 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 106 | html_sidebars = { 107 | '**': [ 108 | 'relations.html', # needs 'show_related': True theme option to display 109 | #'searchbox.html', 110 | ] 111 | } 112 | 113 | 114 | # -- Options for HTMLHelp output ------------------------------------------ 115 | 116 | # Output file base name for HTML help builder. 117 | htmlhelp_basename = 'anidbclidoc' 118 | 119 | 120 | # -- Options for LaTeX output --------------------------------------------- 121 | 122 | latex_elements = { 123 | # The paper size ('letterpaper' or 'a4paper'). 124 | # 125 | # 'papersize': 'letterpaper', 126 | 127 | # The font size ('10pt', '11pt' or '12pt'). 128 | # 129 | # 'pointsize': '10pt', 130 | 131 | # Additional stuff for the LaTeX preamble. 132 | # 133 | # 'preamble': '', 134 | 135 | # Latex figure (float) alignment 136 | # 137 | # 'figure_align': 'htbp', 138 | } 139 | 140 | # Grouping the document tree into LaTeX files. List of tuples 141 | # (source start file, target name, title, 142 | # author, documentclass [howto, manual, or own class]). 143 | latex_documents = [ 144 | (master_doc, 'anidbcli.tex', 'anidbcli Documentation', 145 | 'Štěpán Adámek', 'manual'), 146 | ] 147 | 148 | 149 | # -- Options for manual page output --------------------------------------- 150 | 151 | # One entry per manual page. List of tuples 152 | # (source start file, name, description, authors, manual section). 153 | man_pages = [ 154 | (master_doc, 'anidbcli', 'anidbcli Documentation', 155 | [author], 1) 156 | ] 157 | 158 | 159 | # -- Options for Texinfo output ------------------------------------------- 160 | 161 | # Grouping the document tree into Texinfo files. List of tuples 162 | # (source start file, target name, title, author, 163 | # dir menu entry, description, category) 164 | texinfo_documents = [ 165 | (master_doc, 'anidbcli', 'anidbcli Documentation', 166 | author, 'anidbcli', 'One line description of project.', 167 | 'Miscellaneous'), 168 | ] 169 | 170 | 171 | 172 | 173 | # Example configuration for intersphinx: refer to the Python standard library. 174 | intersphinx_mapping = {'https://docs.python.org/': None} 175 | -------------------------------------------------------------------------------- /docs/ed2k.rst: -------------------------------------------------------------------------------- 1 | ed2k 2 | ================================== 3 | This command only prints ed2k links to console and doesn't utilize the anidb API. Links can be added to mylist manually using "ed2k dump" feature on the website. 4 | The only option for this command is: 5 | * **"--clipboard"**, **"-c"**: Copy ed2k links to clipboard after generating all of them. -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. anidbcli documentation master file, created by 2 | sphinx-quickstart on Fri Feb 9 13:23:41 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Documentation - anidbcli 7 | ==================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | installation 13 | basics 14 | ed2k 15 | api -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ==================================== 3 | 4 | Install using pip 5 | ------------------------------------ 6 | The package can be installed automatically using the following command. 7 | 8 | .. code-block:: bash 9 | 10 | pip install anidbcli 11 | 12 | Install from source 13 | ------------------------------------ 14 | Package can be also installed from source using the following command. 15 | 16 | .. code-block:: bash 17 | 18 | python setup.py install 19 | 20 | Launching anidbcli 21 | ------------------------------------ 22 | Anidbcli can be launched as a python module like this 23 | 24 | .. code-block:: bash 25 | 26 | python -m anidbcli 27 | 28 | or directly by typing following in the command line 29 | 30 | .. code-block:: bash 31 | 32 | anidbcli -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=anidbcli 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | pycryptodome 3 | colorama 4 | pyperclip 5 | joblib -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest.main(["tests/test_operations.py"]) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from setuptools import setup, find_packages 3 | 4 | with open('README.rst') as f: 5 | long_description = ''.join(f.readlines()) 6 | 7 | setup( 8 | name='anidbcli', 9 | version='1.66', 10 | keywords='Anidb UDP API CLI client ed2k rename mylist', 11 | description='Simple CLI for managing your anime collection using AniDB UDP API.', 12 | long_description=long_description, 13 | author='Štěpán Adámek', 14 | author_email='adamek.stepan@gmail.com', 15 | license='MIT', 16 | url='https://github.com/adameste/anidbcli', 17 | zip_safe=False, 18 | packages=find_packages(), 19 | entry_points={ 20 | 'console_scripts': [ 21 | 'anidbcli = anidbcli:main', 22 | ] 23 | }, 24 | install_requires=[ 25 | 'click', 26 | 'pycryptodome', 27 | 'colorama', 28 | 'pyperclip', 29 | 'joblib' 30 | ], 31 | classifiers=[ 32 | 'Development Status :: 5 - Production/Stable', 33 | 'Environment :: Console', 34 | 'Intended Audience :: End Users/Desktop', 35 | 'License :: OSI Approved :: MIT License', 36 | 'Natural Language :: English', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.5', 40 | 'Programming Language :: Python :: 3.6', 41 | 'Topic :: Utilities', 42 | 'Topic :: Multimedia :: Video' 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import flexmock 2 | import hashlib 3 | import socket 4 | 5 | import anidbcli.encryptors as encryptors 6 | import anidbcli.anidbconnector as anidbconnector 7 | 8 | def test_encryption(): 9 | string = "Hello World!" 10 | key1 = hashlib.md5(b"key1").digest() 11 | key2 = hashlib.md5(b"key2").digest() 12 | crypto1 = encryptors.Aes128TextEncryptor(key1) 13 | crypto2 = encryptors.Aes128TextEncryptor(key2) 14 | assert bytes(string, "utf-8") != crypto1.Encrypt(string) 15 | assert crypto1.Encrypt(string) != crypto2.Encrypt(string) 16 | assert crypto1.Decrypt(crypto1.Encrypt(string)) == string 17 | 18 | def test_unecrypted_initialization(): 19 | sock=flexmock.flexmock(send=()) 20 | flexmock.flexmock(socket, socket=sock) 21 | sock.should_receive("connect").once() 22 | sock.should_receive("settimeout").once() 23 | sock.should_receive("send").with_args(b"AUTH user=username&pass=password&protover=3&client=anidbcli&clientver=1&enc=UTF8").once() 24 | sock.should_receive("send").with_args(b"TEST t=123&s=YXM21").once() 25 | sock.should_receive("recv").and_return(b"200 YXM21 LOGIN ACCEPTED").and_return(b"200 OK") 26 | cli = anidbconnector.AnidbConnector.create_plain("username", "password") 27 | res = cli.send_request("TEST t=123") 28 | assert res["code"] == 200 29 | 30 | def test_encrypted_initialization(): 31 | sock=flexmock.flexmock(send=()) 32 | flexmock.flexmock(socket, socket=sock) 33 | key = hashlib.md5(bytes("apikey" + "k1XaZIJD", "ascii")).digest() 34 | crypto = encryptors.Aes128TextEncryptor(key) 35 | sock.should_receive("connect").once() 36 | sock.should_receive("settimeout").once() 37 | sock.should_receive("send").with_args(b"ENCRYPT user=username&type=1").once() 38 | sock.should_receive("send").with_args(crypto.Encrypt("AUTH user=username&pass=password&protover=3&client=anidbcli&clientver=1&enc=UTF8")).once() 39 | sock.should_receive("send").with_args(crypto.Encrypt("TEST t=123&s=YXM21")).once() 40 | sock.should_receive("recv").and_return(b"209 k1XaZIJD ENCRYPTION ENABLED").and_return(crypto.Encrypt("200 YXM21 LOGIN ACCEPTED")).and_return(crypto.Encrypt("200 OK")) 41 | cli = anidbconnector.AnidbConnector.create_secure("username", "password", "apikey") 42 | res = cli.send_request("TEST t=123") 43 | assert res["code"] == 200 44 | 45 | def test_udp_send_retry(): 46 | sock=flexmock.flexmock(send=()) 47 | flexmock.flexmock(socket, socket=sock) 48 | sock.should_receive("connect").once() 49 | sock.should_receive("settimeout").once() 50 | sock.should_receive("send").at_least().times(2) 51 | sock.should_receive("recv").and_raise(Exception) 52 | try: 53 | anidbconnector.AnidbConnector.create_plain("username", "password") 54 | assert False 55 | except: 56 | assert True 57 | 58 | -------------------------------------------------------------------------------- /tests/test_operations.py: -------------------------------------------------------------------------------- 1 | import flexmock 2 | import anidbcli.operations as operations 3 | import tempfile 4 | import datetime 5 | import os 6 | import glob 7 | 8 | def test_add_ok(): 9 | conn = flexmock.flexmock(send_request=lambda x: {"code": 210, "data": "MYLIST ENTRY ADDED"}) 10 | conn.should_call("send_request").once().with_args("MYLISTADD size=42&ed2k=ABC1234&viewed=1&state=0") 11 | out = flexmock.flexmock() 12 | out.should_receive("success").once() 13 | oper = operations.MylistAddOperation(conn, out, 0, False) 14 | f = {"size": 42, "ed2k": "ABC1234"} 15 | oper.Process(f) 16 | 17 | def already_send_request(x): 18 | if ("edit" in x): 19 | return {"code": 311, "data": "MYLIST ENTRY EDITED"} 20 | else: 21 | return {"code": 310, "data": "ALREADY IN MYLIST"} 22 | 23 | 24 | def test_add_already_in_mylist(): 25 | conn = flexmock.flexmock(send_request=already_send_request) 26 | conn.should_call("send_request").with_args("MYLISTADD size=42&ed2k=ABC1234&viewed=1&state=0").and_return({"code": 310, "data": "ALREADY IN MYLIST"}).once().ordered() 27 | conn.should_call("send_request").with_args("MYLISTADD size=42&ed2k=ABC1234&edit=1&viewed=1&state=0").and_return({"code": 311, "data": "MYLIST ENTRY EDITED"}).once().ordered() 28 | out = flexmock.flexmock(error=lambda x: print(x)) 29 | out.should_receive("warning").once() 30 | out.should_receive("error") 31 | oper = operations.MylistAddOperation(conn, out, 0, False) 32 | f = {"size": 42, "ed2k": "ABC1234"} 33 | oper.Process(f) 34 | 35 | def test_add_send_exception(): 36 | conn = flexmock.flexmock() 37 | conn.should_receive("send_request").once().with_args("MYLISTADD size=42&ed2k=ABC1234&viewed=1&state=0").and_raise(Exception) 38 | out = flexmock.flexmock() 39 | out.should_receive("error").once() 40 | oper = operations.MylistAddOperation(conn, out, 0, False) 41 | f = {"size": 42, "ed2k": "ABC1234"} 42 | oper.Process(f) 43 | 44 | def test_parse_file_info(): 45 | data = "FILE\n"\ 46 | "2151394|13485|204354|7172|0|0|1064875798|3448d5bd5c28f352006f24582a091c58|"\ 47 | "28d457964c6c348b5d10bf035d33aea6|72b21799ed147d5874ddc792fe545ea9a84dba74|23d62d71||very high|"\ 48 | "www|AAC|128|H264/AVC|5888|1920x1080|mkv|japanese|english|1415|1535760000|"\ 49 | "Boku no Hero Academia (2018) - 21 - What`s the Big Idea? - [HorribleSubs](23d62d71).mkv|"\ 50 | "25|25|2018|TV Series||Boku no Hero Academia (2018)|僕のヒーローアカデミア (2018)|My Hero Academia Season 3|"\ 51 | "僕のヒーローアカデミア (2018)'My Hero Academia Season 3'My Hero Academia Saison 3'나의 히어로 아카데미아 3|"\ 52 | "heroaca3|Boku no Hero Academia Season 3|21|What`s the Big Idea?|Nani o Shitenda yo|何をしてんだよ|HorribleSubs|HorribleSubs" 53 | conn = flexmock.flexmock() 54 | conn.should_receive("send_request").once().with_args("FILE size=42&ed2k=ABC1234&fmask=79FAFFE900&amask=F2FCF0C0").and_return({"code": 220, "data": data}) 55 | out = flexmock.flexmock() 56 | out.should_receive("success").once() 57 | f = {"size": 42, "ed2k": "ABC1234"} 58 | oper = operations.GetFileInfoOperation(conn, out) 59 | oper.Process(f) 60 | assert f["info"]["crc32"] == "23d62d71" 61 | assert f["info"]["year"] == "2018" 62 | assert f["info"]["a_kanji"] == "僕のヒーローアカデミア (2018)" 63 | assert f["info"]["a_english"] == "My Hero Academia Season 3" 64 | assert f["info"]["ep_no"] == "21" 65 | assert f["info"]["ep_english"] == "What's the Big Idea?" 66 | assert f["info"]["ep_kanji"] == "何をしてんだよ" 67 | assert f["info"]["resolution"] == "1920x1080" 68 | assert f["info"]["g_name"] == "HorribleSubs" 69 | 70 | def test_parse_file_info_err(): 71 | conn = flexmock.flexmock() 72 | conn.should_receive("send_request").once().with_args("FILE size=42&ed2k=ABC1234&fmask=79FAFFE900&amask=F2FCF0C0").and_raise(Exception) 73 | out = flexmock.flexmock() 74 | out.should_receive("error").once() 75 | oper = operations.GetFileInfoOperation(conn, out) 76 | f = {"size": 42, "ed2k": "ABC1234"} 77 | oper.Process(f) 78 | 79 | def test_hash_operation(): 80 | filename = "file.tmp" 81 | with open(filename, "wb") as f: 82 | f.write(b"\x6F" * 31457280) 83 | f = {"path": filename} 84 | out = flexmock.flexmock(error=lambda x:print(x)) 85 | out.should_receive("success").once() 86 | oper = operations.HashOperation(out, False) 87 | oper.Process(f) 88 | os.remove(filename) 89 | assert f["size"] == 31457280 90 | assert f["ed2k"] == "7e7611fe2ffc72398124dd3f24c4135e" 91 | 92 | def test_hash_error(): 93 | filename = "asdasdasdasoasdjasiasd.tmp" 94 | out = flexmock.flexmock() 95 | out.should_receive("error").once() 96 | oper = operations.HashOperation(out, False) 97 | f = {"path": filename} 98 | assert not oper.Process(f) # pipeline should not continue without valid hash 99 | 100 | def test_rename_works_with_subtitles(): 101 | filename = "test/abcd.mkv" 102 | target = "" 103 | target_expanded = "" 104 | f = {"path": filename, "info": {}} 105 | for tag in ("tag_1","tag_2", "tag_3", "tag_4"): 106 | target += f"%{tag}%" 107 | target_expanded += tag 108 | f["info"][tag] = tag 109 | f["info"]["aired"] = datetime.datetime(2018,2,9) 110 | target_expanded=target_expanded.replace("aired", "2018-02-09") 111 | out = flexmock.flexmock(error=lambda x: print(x), warning=lambda x: None) 112 | out.should_receive("success") 113 | os_mock = flexmock.flexmock(os) 114 | glob_mock = flexmock.flexmock(glob) 115 | os_mock.should_receive("rename").with_args("test/abcd.mkv", target_expanded + ".mkv").once() 116 | os_mock.should_receive("rename").with_args("test/abcd.srt", target_expanded + ".srt").once() 117 | os_mock.should_receive("link").with_args("test/abcd.mkv", "test\\" + target_expanded + ".mkv").once() 118 | os_mock.should_receive("link").with_args("test/abcd.srt", "test\\" + target_expanded + ".srt").once() 119 | os_mock.should_receive("makedirs").at_least().once() 120 | lst = [filename, "test/abcd.srt"] 121 | glob_mock.should_receive("glob").and_return(lst) 122 | oper = operations.RenameOperation(out, target, "%Y-%m-%d", False, False, False, False, False) 123 | oper2 = operations.RenameOperation(out, target, "%Y-%m-%d", False, True, False, True, False) 124 | oper.Process(f) 125 | assert f["path"] != filename # Should be changed for next elements in pipeline 126 | f["path"] = filename 127 | oper2.Process(f) --------------------------------------------------------------------------------