Please also check out our release schedule to see when the next episode will 132 | get released.
\n\n\t\t├── .gitattributes ├── MANIFEST.in ├── codecov.yml ├── HorribleDownloader ├── __init__.py ├── default_conf.ini ├── config_manager.py ├── parser.py └── cmd.py ├── .gitignore ├── requirements.txt ├── .travis.yml ├── test ├── test_cmd_funcs.py ├── test_conf_manager.py ├── test_parser.py └── html-mocks │ ├── api-batch.html │ ├── shows.html │ └── api-show.html ├── LICENSE.txt ├── setup.py └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | test/html-mocks/* linguist-vendored 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include HorribleDownloader/default_conf.ini -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "test_*.py" # ignore tests. 3 | -------------------------------------------------------------------------------- /HorribleDownloader/__init__.py: -------------------------------------------------------------------------------- 1 | from HorribleDownloader.parser import Parser 2 | from HorribleDownloader.config_manager import ConfigManager 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | conf.ini 3 | idea/ 4 | .idea/ 5 | build/ 6 | horrible_downloader.egg-info/ 7 | dist/ 8 | htmlcov/ 9 | .coverage 10 | *.log 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4>=4 2 | lxml>=4 3 | pip>=10 4 | requests>=2 5 | setuptools>=39.0.1 6 | urllib3>=1.23 7 | rapidfuzz>=0.2.1 8 | sty>=1.0.0b9 9 | httmock>=1.0 10 | pytest>=5.4.1 11 | -------------------------------------------------------------------------------- /HorribleDownloader/default_conf.ini: -------------------------------------------------------------------------------- 1 | [settings] 2 | resolution = 1080 3 | download_dir = ~/videos 4 | 5 | [subscriptions] 6 | #ace attorney s2 = 0 7 | #bang dream! s2 = 0 8 | #black clover = 0 9 | #bonobono = 0 10 | #boogiepop wa warawanai (2019) = 0 11 | #boruto - naruto next generations = 0 12 | #cardfight!! vanguard (2018) = 0 13 | #detective conan = 0 14 | #egao no daika = 0 15 | #fairy tail final season = 0 16 | #gegege no kitarou (2018) = 0 17 | #hinomaru sumo = 0 18 | #jojo's bizarre adventure - golden wind = 0 19 | #karakuri circus = 0 20 | #kaze ga tsuyoku fuiteiru = 0 21 | #one piece = 0 22 | #radiant = 0 23 | #saint seiya - saintia shou = 0 24 | #shounen ashibe go! go! goma-chan = 0 25 | #sword art online - alicization = 0 26 | #tensei shitara slime datta ken = 0 27 | #toaru majutsu no index iii = 0 28 | #tsurune = 0 29 | #yu-gi-oh! vrains = 0 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - "3.7" 5 | jobs: 6 | include: 7 | - name: "python 3.7 on Linux" 8 | python: 3.7 9 | - name: "python 3.7 on windows" 10 | os: windows 11 | language: sh 12 | python: 3.7 13 | before_install: 14 | - choco install python --version 3.7.1 15 | - python -m pip install --upgrade pip 16 | env: PATH="/c/Python37:/c/Python37/Scripts:$PATH" 17 | - name: "python 3.7 on osx" 18 | os: osx 19 | osx_image: xcode11.2 20 | language: sh 21 | install: 22 | - pip3 install --upgrade pip 23 | - pip3 install httmock 24 | - pip3 install pytest 25 | - pip3 install coverage 26 | - pip3 install ".[test]" 27 | script: 28 | - coverage run -m pytest -vv 29 | after_success: 30 | - curl -s https://codecov.io/bash > uploader.sh 31 | - bash uploader.sh 32 | -------------------------------------------------------------------------------- /test/test_cmd_funcs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | from itertools import combinations 5 | 6 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 7 | 8 | from HorribleDownloader.cmd import valid_qualities, episode_filter 9 | 10 | 11 | def test_quality_verification(): 12 | for r in range(1, 3): 13 | for qualities in combinations(["480", "720", "1080"], r): 14 | assert valid_qualities(qualities) 15 | 16 | 17 | def test_episode_filter_generation(): 18 | data = [ 19 | ("1,2,3,4", [1, 3, 4.5, 5], [1, 3]), 20 | ("1,3,5-7", [0.5, 1, 2, 5, 6], [1, 5, 6]), 21 | ("=<3,9>", [0, 0.1, 2.9, 3, 5, 9, 10.5], [0, 0.1, 2.9, 3, 10.5]) 22 | ] 23 | for query, episodes, expected_output in data: 24 | def ep_filter(episode, filter_str=query): 25 | return episode_filter(episode, filter_str) 26 | filtered = list(filter(ep_filter, episodes)) 27 | assert filtered == expected_output 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Moshe Sherman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /test/test_conf_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | from unittest.mock import patch, mock_open 5 | 6 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 7 | 8 | from HorribleDownloader import ConfigManager 9 | 10 | 11 | data = """ 12 | [settings] 13 | resolution = 1080, 720 14 | download_dir = ~/videos 15 | 16 | [subscriptions] 17 | """ 18 | 19 | 20 | @patch("builtins.open", mock_open(read_data=data)) 21 | @patch("os.makedirs", return_value=True) 22 | def test_config_manager(mock_os_makedirs): 23 | config = ConfigManager(conf_dir="directory") 24 | # check if new directory was created 25 | assert mock_os_makedirs.called_with("directory") 26 | 27 | # check parsing of key elements: 28 | assert config.quality == "1080, 720" 29 | assert config.dir == "directory" 30 | assert config.file == "conf.ini" 31 | assert config.download_dir == "~/videos" 32 | assert dict(config.subscriptions) == {} 33 | 34 | # test writing abilities 35 | config.add_entry("test", "0") 36 | assert dict(config.subscriptions) == {"test": "0"} 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from setuptools.command.install import install 3 | import subprocess 4 | import os 5 | 6 | with open("README.md", 'r', encoding="utf8") as f: 7 | long_description = f.read() 8 | 9 | class custom_install(install): 10 | def run(self): 11 | # check if webtorrent is installed 12 | if subprocess.call(["webtorrent", "-v"], shell=True): 13 | subprocess.call("npm install webtorrent-cli -g", shell=True) 14 | install.run(self) 15 | 16 | setup( 17 | name='horrible-downloader', 18 | version='1.1.4', 19 | packages=['HorribleDownloader'], 20 | url='https://github.com/mtshrmn/horrible-downloader', 21 | license='MIT', 22 | author='Jelomite', 23 | author_email='mtshrmn@gmail.com', 24 | description='HorribleSubs API', 25 | long_description=long_description, 26 | long_description_content_type="text/markdown", 27 | install_requires=[ 28 | 'beautifulsoup4>=4', 29 | 'requests>=2', 30 | 'lxml>=4', 31 | 'sty>=1.0.0b9', 32 | 'rapidfuzz>=0.7.8', 33 | ], 34 | entry_points={ 35 | "console_scripts": ["horrible-downloader=HorribleDownloader.cmd:main"] 36 | }, 37 | include_package_data=True, 38 | zip_safe=False, 39 | classifiers=[ 40 | "Programming Language :: Python :: 3.7", 41 | "License :: OSI Approved :: MIT License", 42 | "Operating System :: OS Independent", 43 | ], 44 | cmdclass={"install": custom_install} 45 | ) 46 | -------------------------------------------------------------------------------- /HorribleDownloader/config_manager.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | import os 3 | import shutil 4 | 5 | 6 | class ConfigManager: 7 | def __init__(self, 8 | conf_dir=os.path.expanduser("~/.config/horrible-downloader/"), 9 | file="conf.ini"): 10 | 11 | self.dir = conf_dir 12 | self.file = file 13 | if not os.path.exists(self.dir): 14 | os.makedirs(self.dir) 15 | 16 | try: 17 | self.conf = self._parse_conf() 18 | self.quality = self.conf['settings']['resolution'] 19 | self.download_dir = self.conf['settings']['download_dir'] 20 | self.subscriptions = self.conf['subscriptions'] 21 | except (KeyError, ValueError, AssertionError): 22 | print('Invalid configuration file.') 23 | 24 | def add_entry(self, title, episode): 25 | entry = title.lower() 26 | if entry in self.subscriptions: 27 | return False, title 28 | 29 | self.update_entry(title, episode) 30 | return True, title 31 | 32 | def update_entry(self, title, episode): 33 | entry = title.lower() 34 | self.subscriptions[entry] = episode 35 | self.write() 36 | 37 | def write(self): 38 | # update the local file 39 | with open(os.path.join(self.dir, self.file), "w") as config_file: 40 | self.conf.write(config_file) 41 | 42 | def _parse_conf(self): 43 | conf = ConfigParser() 44 | specified_conf = os.path.join(self.dir, self.file) 45 | success = conf.read(specified_conf) 46 | if not success: 47 | print('Couldn\'t find configuration file at specified directory.') 48 | print('Generating from default') 49 | default_conf = os.path.join(os.path.dirname( 50 | os.path.abspath(__file__)), 'default_conf.ini') 51 | shutil.copyfile(default_conf, specified_conf) 52 | 53 | conf.read(specified_conf) 54 | 55 | for resolution in conf['settings']['resolution'].split(','): 56 | if resolution.strip() not in ('480', '720', '1080'): 57 | raise AssertionError 58 | if not isinstance(conf['settings']['download_dir'], str): 59 | raise AssertionError 60 | for sub in conf['subscriptions']: 61 | float(conf['subscriptions'][sub]) 62 | # simple check to validate all of the subscriptions 63 | # if it cant convert the value to a float, 64 | # it'll raise a ValueError, and we will catch it in the __init__. 65 | return conf 66 | -------------------------------------------------------------------------------- /test/test_parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | from httmock import urlmatch, HTTMock 5 | # from urllib.parse import parse_qs 6 | 7 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 8 | 9 | from HorribleDownloader import Parser 10 | 11 | TEST_DIR_PATH = os.path.dirname(os.path.abspath(__file__)) 12 | 13 | 14 | @urlmatch(scheme="https", netloc="horriblesubs.info") 15 | def shows_mock(url, request): 16 | path = os.path.join(TEST_DIR_PATH, "html-mocks/shows.html") 17 | with open(path, "r") as html: 18 | return html.read() 19 | 20 | 21 | def test_get_shows(): 22 | with HTTMock(shows_mock): 23 | parser = Parser() 24 | assert parser.shows == parser.current_shows 25 | assert parser.shows == { 26 | 'Test 1': 'test-1', 27 | 'Test 2': 'test-2', 28 | 'Test 3': 'test-3', 29 | 'Test 4': 'test-4', 30 | "Hello & World": "hello&world" 31 | } 32 | 33 | 34 | def test_get_proper_title(): 35 | with HTTMock(shows_mock): 36 | parser = Parser() 37 | reasonable_titles = [ 38 | "Test 1", # exact match 39 | "test 1", # almost same 40 | "tast 1", # typo 41 | ] 42 | for title in reasonable_titles: 43 | proper_title = parser.get_proper_title(title) 44 | assert proper_title == "Test 1" 45 | incorrect_titles = [ 46 | "", # blank 47 | "random", # non-matching text 48 | "123" 49 | ] 50 | for title in incorrect_titles: 51 | assert parser.get_proper_title(title, min_threshold=70) == "" 52 | 53 | assert parser.get_proper_title("Hello $amp; World") == "Hello & World" 54 | 55 | 56 | def test_get_episodes(): 57 | # without mocking, we'll do live testing on the website. 58 | parser = Parser() 59 | for limit in 0, 3, 11, 12, 13, 24, 28: 60 | title = parser.get_proper_title("one piece") 61 | showid = parser._get_show_id(title) 62 | shows_html = parser._get_html(showid, limit, "show") 63 | episodes = list(parser._parse_html(shows_html)) 64 | assert len(episodes) == 12 * (((limit - 1) // 12) + 1) 65 | 66 | proper_get_episodes = parser.get_episodes("one piece", limit=limit) 67 | assert len(proper_get_episodes) == limit 68 | 69 | for index, episode in enumerate(proper_get_episodes): 70 | episode.pop("title") 71 | proper = episode.keys() 72 | non_proper = episodes[index].keys() 73 | error_msg = f"proper: {proper} \nnon proper: {non_proper}" 74 | assert episode == episodes[index], error_msg 75 | 76 | 77 | def test_get_batches(): 78 | parser = Parser() 79 | title = "Kateikyoushi Hitman Reborn!" 80 | batches = parser.get_batches(title) 81 | assert len(batches) == 2 82 | 83 | for batch in batches: 84 | assert batch["title"] == title 85 | has_magnet = [] 86 | for resolution in "480", "720", "1080": 87 | assert resolution in batch 88 | has_magnet.append("Magnet" in batch[resolution]) 89 | 90 | assert has_magnet == sorted(has_magnet, reverse=True) 91 | assert has_magnet[0] 92 | 93 | 94 | def test_show_id(): 95 | parser = Parser() 96 | assert parser._get_show_id("no way this will ever be an anime") == -1 97 | -------------------------------------------------------------------------------- /test/html-mocks/api-batch.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /HorribleDownloader/parser.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | import requests 3 | import re 4 | from rapidfuzz import process, fuzz 5 | from typing import Iterable 6 | 7 | 8 | class Parser: 9 | def __init__(self): 10 | # because this data won't change as often 11 | # we can populate it once when initiating the parser 12 | # this will reduce overall running time. 13 | # the only downside is slow initiation (around 1s) 14 | self.shows = self._get_shows("shows") 15 | self.current_shows = self._get_shows("current-season") 16 | 17 | def get_proper_title(self, title: str, min_threshold=0) -> str: 18 | # because we're dealing with html, there will be character references. 19 | # there might be other references other than the ampersand. 20 | title = title.replace("&", "&") 21 | possible_titles = process.extract(title, self.shows.keys(), scorer=fuzz.token_set_ratio, limit=20) # select 20 best titles based on token_set_ratio 22 | only_best_matching_titles = [p[0] for p in possible_titles if p[1] >= possible_titles[0][1]] # selects only titles with token_set_ratio equal to best found ratio 23 | proper_title, ratio = process.extractOne(title, only_best_matching_titles, scorer=fuzz.token_sort_ratio) # selects title with best token_sort_ratio from previous selection 24 | # if the proper_title is too different than the title, return "". 25 | if ratio <= min_threshold: 26 | return "" 27 | return proper_title 28 | 29 | def get_episodes(self, show: str, limit=1000, batches=False) -> list: 30 | if batches: 31 | return self.get_batches(show, limit=limit) 32 | return self._get_uris(show, "show", limit) 33 | 34 | def get_batches(self, show: str, limit=1000) -> list: 35 | return self._get_uris(show, "batch", limit) 36 | 37 | @staticmethod 38 | def _get_shows(page: str) -> dict: 39 | # instead of writing two identical functions (shows, current_shows), 40 | # we have one function that parses the data from the given page. 41 | # shows: https://horriblesubs.info/shows/ 42 | # current_shows: https://horriblesubs.info/current-season/ 43 | ret = {} 44 | url = f"https://horriblesubs.info/{page}/" 45 | html = requests.get(url).text 46 | soup = BeautifulSoup(html, "lxml") 47 | div = soup.find("div", {"class": "shows-wrapper"}) 48 | shows = div.find_all("a") 49 | for show in shows: 50 | # each URI starts with `/shows/`, we will remove it here, 51 | # but we must append it later. 52 | href = show["href"].replace("/shows/", "") 53 | ret[show["title"]] = href 54 | 55 | # the return dictionary will have the titles as keys and URIs as values. 56 | return ret 57 | 58 | @staticmethod 59 | def _get_html(showid: int, limit: int, show_type: str) -> str: 60 | # another thing to note - the horriblesubs api runs in reverse 61 | # which means the first element will be the most recent episode 62 | # that guarantees us that the first episode will be the last element.1 63 | api = "https://horriblesubs.info/api.php" 64 | # the horriblesubs api returns html to be inserted directly into the site, 65 | # because the api works with pagination 66 | # we have a blank html variable that we'll append data onto. 67 | html = "" 68 | # the pagination is controlled by the `nextid` parameter. 69 | # if there are episodes, it will return the html, 70 | # otherwise it will return an end string (stop_text) 71 | # the end string is different for regular episodes and for batches 72 | show_stop_text = "DONE" 73 | batch_stop_text = "There are no batches for this show yet" 74 | stop_text = show_stop_text if show_type == "show" else batch_stop_text 75 | query = { 76 | "method": "getshows", 77 | "showid": showid, 78 | "nextid": 0, 79 | "type": show_type 80 | } 81 | # our python wrapper doesn't use pagination, so it must run over all the pages. 82 | # because some shows have a huge amount of episodes 83 | # we want to specify a limit for the amount. 84 | # sometimes we just care about the most recent episode. 85 | while True: 86 | response = requests.get(api, params=query) 87 | # the limit is counted in number of episodes (or batches) 88 | # because each page contains 12 episodes, we must divide it by 12. 89 | if response.text == stop_text or query["nextid"] > (limit - 1) // 12: 90 | break 91 | html += response.text 92 | query["nextid"] += 1 93 | # once all the pages were appended, return it as raw data to be parsed. 94 | return html 95 | 96 | @staticmethod 97 | def _parse_html(html: str) -> Iterable[dict]: 98 | soup = BeautifulSoup(html, "lxml") 99 | episodes = soup.find_all("div", {"class": "rls-info-container"}) 100 | 101 | for episode in episodes: 102 | downloads = episode.find("div", {"class": "rls-links-container"}) 103 | links = downloads.find_all("a", href=True) 104 | # the episode object, for now, we only know the episode number 105 | # all that is left is to add the URIs 106 | ret = { 107 | "episode": episode.find("strong").text.replace('v2', ''), 108 | "480": {}, 109 | "720": {}, 110 | "1080": {} 111 | } 112 | 113 | # ____populate the URIs____ 114 | # the links are as follows: 115 | # [Magnet, Torrent, XDCC, Uploaded.net, FileUpload, Uplod] * 3 116 | # sometimes, not all links exist, and not always all resoultions exist. 117 | # the premise is: 118 | # 1. each resolution has a magnet link (the text is: "Magnet"). 119 | # 2. the magnet link will be the first link for each resolution. 120 | # 3. the resolutions start from low to high. 121 | resolutions = iter(["480", "720", "1080"]) 122 | for link in links: 123 | # this is always true on the first iteration 124 | if link.text == "Magnet": 125 | resolution = next(resolutions) 126 | ret[resolution][link.text] = link["href"] 127 | 128 | yield ret 129 | 130 | def _get_show_id(self, title: str) -> int: 131 | # the horriblesubs api works with shows id 132 | # the id is a numeric value based on the order it was added to the site. 133 | # because the api is not meant to be public, 134 | # there's no easy way to retrieve the id. 135 | # the id appears in a script tag inside of the html of the show page. 136 | # var hs_showid = ID 137 | # because the task is simple, we dont need BeautifulSoup for that 138 | try: 139 | url = "https://horriblesubs.info/shows/" + self.shows[title] 140 | html = requests.get(url) 141 | match = re.findall(r"var hs_showid = \d+", html.text) 142 | return int(match[0].strip("var hs_showid = ")) 143 | except KeyError: 144 | # if no id was found, return default value 145 | return -1 146 | 147 | def _get_uris(self, show: str, show_type: str, limit: int) -> list: 148 | title = self.get_proper_title(show) 149 | showid = self._get_show_id(title) 150 | shows_html = self._get_html(showid, limit, show_type) 151 | # as discussed in https://github.com/Jelomite/horrible-downloader/issues/24 152 | # to reduce confusion, the length of episodes should be equal to the limit 153 | episodes = list(self._parse_html(shows_html))[:limit] 154 | # for each episode, append a title argument. 155 | # it's not ideal. it's for backward compatibility. 156 | # TODO: remove this in veriosn 2.0 157 | for episode in episodes: 158 | episode.update({"title": title}) 159 | return episodes 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Horrible Downloader [](https://travis-ci.com/mtshrmn/horrible-downloader) [](https://codecov.io/gh/mtshrmn/horrible-downloader) [](https://badge.fury.io/py/horrible-downloader) 2 | 3 |  4 | 5 | ### Horriblesubs is no longer active. 6 | #### Thank you everyone for helping to develop this library. As of today, this repository will be archived and no longer be maintained. 7 | 8 | *Horrible Downloader* is a Python wrapper around the [HorribleSubs](https://horriblesubs.info/) API. It comes with a powerful set of extra features, which allow users to automatically download new episodes and batches of existing shows. The module tracks the downloaded files and allows you to continue from where you left. 9 | 10 | ## Installation 11 | 12 | ```sh 13 | > pip install horrible-downloader 14 | ``` 15 | 16 | ## Dependencies 17 | **_horrible-downloader_** uses [WebTorrent-CLI](https://github.com/webtorrent/webtorrent-cli) to download its magnets. 18 | The dependency is automatically downloaded with the installation script, but for those who want to install it manually - simply run ```npm install webtorrent-cli -g```. 19 | 20 | **NOTE:** _WebTorrent_ is a NodeJS application, which means you must have Node installed. 21 | 22 | ## Documentation 23 | 24 | #### Usage 25 | Example usage of the API inside of Python: 26 | ```python 27 | from HorribleDownloader import Parser 28 | from subprocess import call 29 | 30 | parser = Parser() 31 | episodes = parser.get_episodes("tower of god") 32 | episode1_magnet = episodes[0]["1080"]["Magnet"] 33 | call(["xdg-open", episode1_magnet]) 34 | ``` 35 | 36 | ### Using the Parser 37 | For us to do simple interactions with the API we must first initiate a parser object using the `HorribleDownloader.Parser()`. 38 | 39 | The parser will allow us to fetch data from [horriblesubs](horriblesubs.info). Here are the methods and properties: 40 | 41 | - **shows** - List all available shows. Equivalent to https://horriblesubs.info/shows/. 42 | - **current_shows** - List all currently airing shows. Equivalent to https://horriblesubs.info/current-season/. 43 | - **get_proper_title(title: str, min_threshold=0)** - Returns the exact title using fuzzy string matching. 44 | - **get_episodes(show: str, limit=1000, batches=False)** - Returns a list of episodes from the specified show. By default will return the last 1000 episodes (of course, most shows don't even reach the 100th episode). If `batches` is set to true, it'll simply run as `get_batches` with the same arguments. The function works in reverse, this means the _limit_ argument goes from the latest episode until it reaches its limit (or it has reached the first episode). E.g.: 45 | ``` python 46 | parser = Parser() 47 | episodes = parser.get_episodes("one piece", limit=7) 48 | # lets assume the latest episode is 495 49 | map(lambda episode: episode["episode"], episodes) # -> [495, 494, 493, 492, 491, 490, 489] 50 | 51 | ``` 52 | - **get_batches(show: str)** - Returns the batches of the show (if it exists). 53 | 54 | #### Episode Object 55 | 56 | When referring to an episode, the actual representation of it is an object of the following structure: 57 | ```python 58 | { 59 | "title": "the title of the show", 60 | "episode": "episode number", # represented with a float. 61 | "480": { # all of the files are in 480p resolution 62 | "Magnet" "link to magnet", 63 | "Torrent": "link to the .torrent file", 64 | "XDCC": "XDCC query", # https://xdcc.horriblesubs.info/ 65 | "Uploaded.net": "uploaded.net link to .mkv", 66 | "FileUpload": "fileupload link to .mkv", 67 | "Uplod": "uplod link to .mkv" 68 | }, 69 | "720": { # exactly the same as the 480, but with 720p resolution 70 | "Magnet" "link to magnet", 71 | "Torrent": "link to the .torrent file", 72 | "XDCC": "XDCC query", 73 | "Uploaded.net": "uploaded.net link to .mkv", 74 | "FileUpload": "fileupload link to .mkv", 75 | "Uplod": "uplod link to .mkv" 76 | }, 77 | "1080": { # 1080p resolution 78 | "Magnet" "link to magnet", 79 | "Torrent": "link to the .torrent file", 80 | "XDCC": "XDCC query", 81 | "Uploaded.net": "uploaded.net link to .mkv", 82 | "FileUpload": "fileupload link to .mkv", 83 | "Uplod": "uplod link to .mkv" 84 | } 85 | } 86 | ``` 87 | 88 | --- 89 | 90 | ## Horrible-Subs CLI 91 | A powerful tool for managing and downloading anime in an automatic manner. To run it, simply call `horrible-downloader`. 92 | The CLI is simple, yet effective. It allows you to download the current airing anime, based on your specified subscriptions ([see Configuration](#configuration)), and downloading all the episodes of a desired anime. 93 | 94 |  95 | 96 | #### Features: 97 | * use **_horriblesubs_** from the command line 98 | * minimal configuration 99 | * supports download resuming - continue exactly where you left! 100 | * allows for smart episode specification parsing 101 | 102 | #### Flags & Options: 103 | The CLI supports manual downloads of different anime with various options. 104 | Full list of flags and options: 105 | ``` 106 | $ horrible-downloader --help 107 | usage: horrible-downloader [-h] [-d DOWNLOAD] [-o OUTPUT] [-e EPISODES] [-l] 108 | [-r RESOLUTION] [---subscribe SUBSCRIBE] [--batch] 109 | [-q] [-lc] [-c CONFIG] [--noconfirm] 110 | 111 | horrible script for downloading anime 112 | 113 | optional arguments: 114 | -h, --help show this help message and exit 115 | -l, --list display list of available shows 116 | -q, --quiet set quiet mode on 117 | -d DOWNLOAD, --download DOWNLOAD download a specific anime 118 | -o OUTPUT, --output OUTPUT directory to which it will download the files 119 | -e EPISODES, --episodes EPISODES manually specify episodes to download 120 | -r RESOLUTION, --resolution RESOLUTION specify resolution quality, defaults to config file 121 | --subscribe SHOW [-e EPISODE] add a show to the config file. 122 | --batch search for batches as well as regular files 123 | -c CONFIG, --config CONFIG config file location 124 | --noconfirm bypass any and all “Are you sure?” messages. 125 | -x, --export export magnet links to standard output 126 | ``` 127 | ##### Episodes & Resolution Formatting: 128 | Those two flags have a special syntax which allows for a better specification interface. 129 | 130 | ###### When using **_episodes_** flag, you can use the following: 131 | 132 | |Character|Usage|Example| 133 | |---------|-----|-----| 134 | |,| allows to specify more than one episode or option|1,6| 135 | |-| specify a range of episodes, including start and end| 4-10| 136 | |>| bigger than, must be last in order| 7>| 137 | |<| smaller than, must be first in order| <10| 138 | |=|equals, in conjunction with < or >, includes the episode number| 11>=| 139 | 140 | ###### The **_resolution_** flag syntax is simple, just separate the resolutions with a comma (,). 141 | 142 | `$ horrible-downloader -r 720,1080` 143 | 144 | ##### Exporting magnet links: 145 | 146 | **NOTE:** The **_export_** flag is not mutually inclusive with the **_quiet_** flag. If you wish to only output magnet links for scripting please include **_quiet_**. 147 | 148 | ###### To only output magnet links while using config file subscription entries 149 | 150 | `$ horrible-downloader -x` 151 | 152 | ###### To output only magnet links (oneshot) 153 | 154 | `$ horrible-downloader -d "one punch man" -x` 155 | 156 | ##### Example usage: 157 | The command for downloading episodes 1,2,4,5,6 of "One-Punch Man" to the `~/Videos/Anime` folder: 158 | ```bash 159 | $ horrible-downloader -d "one punch man" -e 1,2,4-6 -o ~/Videos/Anime 160 | ``` 161 | #### Configuration 162 | Once the script is called, the configuration file will be generated in the user's config directory: 163 | `~/.config/horrible-downloader/conf.ini`. 164 | By default, the config file contains all of the current airing anime commented out. To subscribe to an anime, simply uncomment it and specify which episode you're currently on. 165 | 166 | **NOTE:** The order of the shows in the config file will affect the order of downloading. 167 | 168 | ##### Example config file: 169 | ``` 170 | [settings] 171 | resolution = 1080 172 | download_dir = ~/Videos/Anime 173 | 174 | [subscriptions] 175 | one punch man = 11 176 | lupin iii part v = 8 177 | jojo's bizzare adventure - golden wind = 0 178 | ``` 179 | 180 | #### Known Issues: 181 | When you use Ctrl+C to interrupt the fetching phase, it will not quit gracefully and will print the traceback of the error. I have no idea how to redirect it to the log file. 182 | -------------------------------------------------------------------------------- /HorribleDownloader/cmd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import argparse 5 | import logging 6 | from subprocess import call 7 | from typing import List 8 | from multiprocessing import Manager, Lock, Process 9 | from sty import fg 10 | 11 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 12 | 13 | from HorribleDownloader import Parser, ConfigManager 14 | 15 | try: 16 | # POSIX system: Create and return a getch that manipulates the tty 17 | import termios 18 | import sys 19 | import tty 20 | 21 | # immitate Windows' msvcrt.getch 22 | def getch(): 23 | file_descriptor = sys.stdin.fileno() 24 | old_settings = termios.tcgetattr(file_descriptor) 25 | tty.setraw(file_descriptor) 26 | ch = sys.stdin.read(1) 27 | termios.tcsetattr(file_descriptor, termios.TCSADRAIN, old_settings) 28 | return ch 29 | 30 | # Read arrow keys correctly 31 | def get_key(): 32 | first_char = getch() 33 | if first_char == '\x1b': 34 | return {"[A": "up", "[B": "down"}[getch() + getch()] 35 | return first_char 36 | 37 | except ImportError: 38 | # Non-POSIX: Return msvcrt's (Windows') getch 39 | from msvcrt import getch 40 | 41 | # Read arrow keys correctly 42 | def get_key(): 43 | first_char = getch() 44 | if first_char == b'\xe0': 45 | return {"H": "up", "P": "down"}[getch().decode("UTF-8")] 46 | return first_char.decode("UTF-8") 47 | 48 | if os.name == "nt": 49 | # windows 50 | def clear(): 51 | os.system("cls") 52 | else: 53 | # linux or osx 54 | def clear(): 55 | os.system("clear") 56 | 57 | 58 | def valid_qualities(qualities: List[str]) -> bool: 59 | for quality in qualities: 60 | if quality not in ["480", "720", "1080"]: 61 | return False 62 | return True 63 | 64 | 65 | def episode_filter(episode: str, ep_filter: str) -> bool: 66 | # in charge of parsing the episode flag 67 | # to better understand this, read the documentation 68 | for token in ep_filter.split(","): 69 | # if it's a float (N) 70 | if token.replace('.', '', 1).isdigit(): 71 | if float(token) == episode: 72 | return True 73 | # if it's a range (N1-N2) 74 | elif "-" in token: 75 | lower, higher = token.split("-") 76 | if float(lower) <= episode <= float(higher): 77 | return True 78 | # if it's smaller or equal to (=