├── .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)
--------------------------------------------------------------------------------