├── .gitignore ├── pyproject.toml ├── LICENSE.txt ├── README.md └── src └── jutsu_api └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | dist/ 3 | *.egg-info/ 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "jutsu-api" 7 | version = "2.5" 8 | authors = [ 9 | { name="dev_null", email="natgaev@gmail.com" }, 10 | ] 11 | description = "Simple and flexible API for jut.su" 12 | readme = "README.md" 13 | requires-python = ">=3.10" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = ["requests"] 20 | 21 | [project.urls] 22 | "Homepage" = "https://github.com/gxlg/jutsu-api" 23 | "Bug Tracker" = "https://github.com/gxlg/jutsu-api/issues" 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 /dev/null 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jutsu API 2 | 3 | Simple and flexible API for [jut.su](https://jut.su/). 4 | 5 | ![sketch1673141061953](https://user-images.githubusercontent.com/65429873/211176796-f799629b-1b00-43e2-94da-6e43a6e63151.png) 6 | 7 | As a weeb, I have my own favourite site for watching anime. 8 | Now that I am also a super h4ck3r, I created an API 9 | for accessing this site and it turned out pretty well. 10 | 11 | # Installation 12 | 13 | Install the module from PyPi: 14 | ``` 15 | pip install jutsu-api 16 | ``` 17 | Then import it just like that: 18 | ```py 19 | from jutsu_api import API 20 | ``` 21 | 22 | # Documentation 23 | 24 | ## API 25 | 26 | ```py 27 | API(verbosity:int = 0) 28 | ``` 29 | 30 | Class `API` is a singleton and can be initialized with 31 | one parameter `verbosity`, which is responsible on how 32 | much feedback the API sends. The feedback is being printed to stderr, 33 | the default verbosity level is `0`. 34 | 35 | ### API.verbosity() 36 | 37 | ```py 38 | .verbosity(v:int) 39 | ``` 40 | 41 | Set vebosity level explicitly. 42 | 43 | ### API.search() 44 | 45 | ```py 46 | .search(keyword:str = "", filter:Filter = Filter(), maxpage:int = -1) -> list[Anime] 47 | ``` 48 | 49 | Searches the site for anime with given keyword and filter. 50 | The [`Filter`](#filter) object is explained later. 51 | `maxpage` parameter is used to limit the output by stopping 52 | parsing after an amount of pages. `-1` is the default value, 53 | and means that there should be no limitation. A list of [`Anime`](#anime) 54 | objects is returned. If there are no results, the list will be empty. 55 | 56 | ### API.anime() 57 | 58 | ```py 59 | .anime(id:str) -> Anime 60 | ``` 61 | 62 | Returns [`Anime`](#anime) object generated by parsing the `id` of that anime. 63 | Anime's id is used and can be found in the link to the anime. 64 | 65 | Example `id`: `stein-gate` 66 | 67 | ### API.episode() 68 | 69 | ```py 70 | .episode(id:str) -> Episode 71 | ``` 72 | 73 | Returns a single [`Episode`](#episode) object generated by parsing the `id` of that episode. 74 | Episode's id is the path link to the episode. 75 | 76 | Example `id`: `stein-gate/season-1/episode-1.html` 77 | 78 | ## Anime 79 | 80 | ```py 81 | Anime( 82 | name:Name|None = None, 83 | thumbnail:str|None = None, 84 | info:Filter|None = None, 85 | years:list[int]|None = None, 86 | age:int|None = None, 87 | description:str|None = None, 88 | content:Content|None = None, 89 | ongoing:bool|None = None, 90 | id:str|None = None 91 | ) 92 | ``` 93 | 94 | Class `Anime` provides a full information about the selected anime. 95 | Parsing anime from [`API.search()`](#apisearch) does not give full information, 96 | therefore some parameters have a getter, and if the corresponding parameter is `None`, 97 | additional information is being fetched from anime's main page. 98 | All parameters are optional, but at least one of `name:Name, id:str` must be specified. 99 | 100 | ### Anime.download() 101 | 102 | ```py 103 | .download(quality:int|None = None, path:str = "", threads:int = 1) 104 | ``` 105 | 106 | Downloads the whole anime, walking over seasons and episodes 107 | iteratively and downloading each. `quality` parameter specifies 108 | the quality of the videos to be downloaded. 109 | If `quality` is `None`, highest quality will be used. 110 | `threads` parameter with default value `1`, if it is specified to be not equal `1`, 111 | will create a ThreadPool and download simultaneously. 112 | Downloading whole anime will create a folder with it's name and subfolders for seasons. 113 | Parameter `path` will add a path in front of the output file and save the file 114 | where specified. 115 | 116 | Example `quality`: `720` 117 | 118 | ### Anime.selector 119 | 120 | ```py 121 | .selector:Selector 122 | ``` 123 | 124 | Property of an `Anime` object, helps with selecting specific episodes for download. 125 | See more in [`Selector`](#selector) 126 | 127 | ## Content 128 | 129 | ```py 130 | Content(seasons:list[Season], films:Season|None = None) 131 | ``` 132 | 133 | `Content` object provides an accessor for the [`Season`](#season)s and films of an anime. 134 | Some animes have no films, therefore sometimes the parameter `films` is `None` 135 | 136 | ### Content.count 137 | 138 | ```py 139 | .count:int 140 | ``` 141 | 142 | The count property gives the total count of episodes in seasons. 143 | 144 | ## Season 145 | 146 | ```py 147 | Season(title:str|None, episodes:list[Episode], name:Name|None = None) 148 | ``` 149 | 150 | A `Season` may be a normal season or the container for anime's films. 151 | Seasons' `title` is the general name of the season, which is moslty `Season ` 152 | or `Full-length Films` for films container. 153 | State of `name` can be different in following situations: 154 | 155 | * Anime has only one season: name is `None` 156 | * Anime has multiple unnamed seasons: name's `id` is present 157 | * Anime has mutliple named seasons: name is fully present 158 | 159 | More about [`Name`](#name) object is written further down. 160 | 161 | ### Season.download() 162 | 163 | ```py 164 | .download(quality:int|None = None, path:str = "", threads:int = 1) 165 | ``` 166 | 167 | Pretty much the same as [`Anime.download()`](#animedownload). 168 | `path` is added in front of all locations, so that 169 | the actual downloading happens at that location. 170 | Creates folders if the anime has multiple seasons, 171 | downloads in the final folder directly, if the season is only one. 172 | 173 | ## Episode 174 | 175 | ```py 176 | Episode( 177 | title:str|None = None, 178 | name:Name|None = None, 179 | duration:int|None = None, 180 | opening:Opening|None = None, 181 | ending:Ending|None = None, 182 | players:list[Player]|None = None, 183 | thumbnail:str|None = None, 184 | preview:str|None = None, 185 | id:str|None = None 186 | ) 187 | ``` 188 | 189 | Pretty much the same as [`Anime`](#anime) class. 190 | `title` is the general name of the episode. 191 | Fetching episode from [`Anime`](#anime) and getting it with 192 | an id from [`API.anime()`](#apianime) will produce different titles. 193 | `duration` is the length of the episode in seconds. 194 | `preview` and `thumbnail` are links to pictures of 195 | the preview and thumbnail respectively. 196 | [`Opening`](#opening) and [`Ending`](#ending) objects will be explained later. 197 | `players` property is a list of [`Player`](#player) objects, 198 | which also will be explained later. 199 | 200 | ### Episode.download() 201 | 202 | ```py 203 | .download(quality:int|None = None, path:str = "") 204 | ``` 205 | 206 | Downloads an episode with given `quality`. 207 | If `quality` is `None`, highest quality will be used. 208 | `path` will be added to the title and the episode 209 | will be downloaded into the resulting file. 210 | 211 | ### Episode.player() 212 | 213 | ```py 214 | .player(quality:int|None = None) -> Player|None 215 | ``` 216 | 217 | Gets a player with the given `quality` or 218 | returns `None` if the player is not found. 219 | If `quality` is `None`, returns highest quality player. 220 | 221 | ## Opening 222 | 223 | ```py 224 | Opening(begin:int, end:int, link:str) 225 | ``` 226 | 227 | `begin` and `end` are timestamps of the opening. 228 | `link` is the link to the music provided by the site. 229 | 230 | ## Ending 231 | 232 | ```py 233 | Ending(begin:int, link:str) 234 | ``` 235 | 236 | Pretty much the same as the [`Opening`](#opening) class. 237 | 238 | ## Player 239 | 240 | ```py 241 | Player(quality:int|None = None, link:str) 242 | ``` 243 | 244 | Internal object to manage the video of an episode. 245 | 246 | ### Player.download() 247 | 248 | ```py 249 | .download(local:str|None = None) 250 | ``` 251 | 252 | Downloads the video to a local file with the name 253 | of parameter `local`. If it is `None` then the 254 | file name used on the server will be chosen. 255 | 256 | ## Filter 257 | 258 | ```py 259 | Filter( 260 | genres:list[Name] = [], 261 | types:list[Name] = [], 262 | years:list[Name] = [], 263 | sorting:list[Name] = [], 264 | link:str|None = None 265 | ) 266 | ``` 267 | 268 | The `Filter` object is used to filter out 269 | results in searching the API, or to describe 270 | an already found anime with it's genres, types, 271 | publishing years etc. In addition to those 272 | lists, a link can be given, which describes the filter. 273 | 274 | Example `link`: `adventure-comedy-game/2000-2007-and-2008-2014/` 275 | 276 | ### Filter.available 277 | 278 | ```py 279 | Filter.available:Filter 280 | ``` 281 | 282 | Static property of the object `Filter`, gives 283 | back another `Filter` object, which has all 284 | the genres, types etc. available on the site. 285 | 286 | ## Name 287 | 288 | ```py 289 | Name(name:str|None, id:str, orig:str|None = None) 290 | ``` 291 | 292 | The `Name` object is used in various places to name things. 293 | Since the jut.su site is in russian language, the `name` 294 | is the visible name, `orig` is the english or japanese name 295 | and `id` is the id used on the site for navigation, fetching etc. 296 | 297 | ## Selector 298 | 299 | ```py 300 | Selector(parent:Anime) 301 | ``` 302 | 303 | A helper object for specific selections of episodes for download. 304 | 305 | ### Selector.select_episodes() 306 | 307 | ```py 308 | .select_episodes(quality:int|None = None, items:Iterable[int]|None = None) -> Downloader 309 | ``` 310 | 311 | Selects episodes with specific indexes. This ignores seasons and 312 | counts each next episode as `index + 1`. Be careful: Index of episodes starts with a `0`. 313 | If `items` is `None`, everything will be selected. 314 | The selection quality is the parameter `quality`. If it is `None`, highest 315 | quality will be selected. 316 | 317 | ### Selector.select_seasons() 318 | 319 | ```py 320 | .select_seasons(quality:int|None = None, items:Iterable[int]|None = None) -> Downloader 321 | ``` 322 | 323 | Selects seasons with specific indexes. Those selected seasons are selected 324 | completely including all their episodes. Indexing begins with a `0`. 325 | If `items` is `None`, everything will be selected. 326 | The selection quality is the parameter `quality`. If it is `None`, highest 327 | quality will be selected. 328 | 329 | 330 | ### Selector.select_in_seasons() 331 | 332 | ```py 333 | .select_in_seasons(quality:int|None = None, items:dict[int, Iterable[int]|None]) -> Downloader 334 | ``` 335 | 336 | Selects specific episodes in specific seasons. Seasons index goes into 337 | the key of dictionary, the value is an iterator of indexes inside this episode. 338 | The selection quality is the parameter `quality`. If it is `None`, highest 339 | quality will be selected. 340 | 341 | ## Downloader 342 | 343 | ```py 344 | Downloader(items:list[list[Player, str]]) 345 | ``` 346 | 347 | The object being returned from [`Selector`](#selector). 348 | 349 | ### Downloader.add() 350 | 351 | ```py 352 | .add(downloader:Downloader) 353 | ``` 354 | 355 | Adds items of another downloader to itself. Created for merging queues. 356 | 357 | ### Downloader.download() 358 | 359 | ```py 360 | .download(path:str = "", threads:int = 1) 361 | ``` 362 | 363 | Downloads the queue of the object using threads and a `path`, which is being added 364 | in front of the location. 365 | 366 | # Example 367 | 368 | The following example will download the anime 369 | "One Punch Man" into weeb's secret anime folder 370 | in full quality and will use 3 threads 371 | to download simultaneously. 372 | 373 | ```py 374 | from jutsu_api import API 375 | 376 | api = API(verbosity = 1) 377 | 378 | search = api.search(keyword = "punch") 379 | onepunch = search[0] 380 | 381 | onepunch.download( 382 | path = "/home/weeb/homework/", 383 | threads = 3 384 | ) 385 | ``` 386 | 387 | This example uses [`Selector`](#selector) to download everything after episode 5: 388 | 389 | ```py 390 | from jutsu_api import API 391 | 392 | api = API(verbosity = 1) 393 | 394 | search = api.search(keyword = "punch") 395 | onepunch = search[0] 396 | 397 | episodes = onepunch.selector.select_episodes( 398 | range(5, onepunch.content.count) 399 | ) 400 | 401 | episodes.download( 402 | path = "/home/weeb/homework/", 403 | threads = 3 404 | ) 405 | ``` 406 | -------------------------------------------------------------------------------- /src/jutsu_api/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Self, Iterable 3 | 4 | import requests 5 | import re 6 | import html 7 | import base64 8 | import os 9 | from multiprocessing.pool import ThreadPool 10 | 11 | class Utils: 12 | @classmethod 13 | def parse_anime(clazz, html:str, id:str, full:bool = False) -> Anime: 14 | if "
" in html: 15 | raise NameError("Anime with this id not found") 16 | pic = re.findall("background: url\\('(.*?\\.jpg)'\\)", html)[0] 17 | if full: 18 | na = re.findall("\\", html)[0] 19 | orig = re.findall("\\", html)[0] 20 | else: 21 | na, orig = re.findall("class=\"tooltip_title_in_anime\"\\>(.*?)\\\\(.*?)\\", html)[0] 22 | name = Name(na, id, orig) 23 | years = [] 24 | ys = [] 25 | for yy in Filter.available.years: 26 | y = re.findall(f"href=\"/anime/{yy.id}/\"\\>(\\d+)", html) 27 | if y: 28 | years.append(int(y[0])) 29 | ys.append(yy) 30 | genres = [] 31 | for gg in Filter.available.genres: 32 | g = re.findall(f"href=\"/anime/{gg.id}/\"\\>", html) 33 | if g: genres.append(gg) 34 | types = [] 35 | for tt in Filter.available.types: 36 | t = re.findall(f"href=\"/anime/{tt.id}/\"\\>", html) 37 | if t: types.append(tt) 38 | info = Filter(genres = genres, types = types, years = ys) 39 | if full: 40 | dd = re.findall("(?ms)\\

(.*?)\\", html) 41 | if dd: 42 | d = dd[0].replace("", "").replace("", "").replace("
", "\n") 43 | desc = re.findall("(?ms)\\(.*?)\\")[0] 51 | s = s.split("

")[1] 52 | s = re.split("(?ms)\\

\\s*\\(?:\\s*\\)?", s, 1)[1].strip() 53 | ss = s.split("
") 54 | 55 | seasons = [] 56 | films = None 57 | last = None 58 | for ht in ss: 59 | episodes = [] 60 | e = ht.split("", 1)[1] if "" in ht else ht 61 | for i in re.findall(f"\\(.*?)\\", e): 62 | episodes.append(Episode(title = i[1], id = i[0])) 63 | 64 | if "films_title" in ht: 65 | ti = re.findall("\\

(.*?)\\", ht)[0] 66 | films = Season(title = ti, episodes = episodes, name = None) 67 | else: 68 | if "the_invis" in ht: 69 | title = re.findall("\\

(.*?)( \\(.*?\\))?\\", ht)[0] 70 | href = re.findall(f"the_invis\"\\>\\(.*?)( \\(.*?\\))?\\", ht)[0] 81 | if title[0]: 82 | ori = title[0].split("\"")[1] 83 | nn = Name(id = None, name = title[1], orig = ori) 84 | if title[2]: ti = title[2][2:-1] 85 | else: ti = None 86 | else: 87 | nn = Name(id = None, name = None) 88 | ti = title[1] 89 | season = Season(title = ti, episodes = episodes, name = nn) 90 | else: 91 | season = Season(title = None, episodes = episodes, name = None) 92 | 93 | if not episodes: 94 | last = [season.title, season.name.id] 95 | continue 96 | elif last is not None: 97 | season.title = last[0] 98 | season.name.id = last[1] 99 | last = None 100 | 101 | seasons.append(season) 102 | 103 | content = Content(seasons = seasons, films = films) 104 | 105 | return Anime( 106 | name = name, 107 | thumbnail = pic, 108 | info = info, 109 | years = years, 110 | age = age, 111 | description = desc, 112 | content = content, 113 | ongoing = ongoing 114 | ) 115 | else: 116 | return Anime( 117 | name = name, 118 | thumbnail = pic, 119 | info = info, 120 | years = years 121 | ) 122 | 123 | @classmethod 124 | def log(clazz, message:str, level:int = 0) -> None: 125 | if API.instance.verbosity >= level: 126 | print(message, file = os.sys.stderr) 127 | 128 | @classmethod 129 | def clean_path(clazz, path:str) -> str: 130 | for i in "?<\">'": 131 | path = path.replace(i, "") 132 | for i in "\\|*:/": 133 | path = path.replace(i, "-") 134 | return path 135 | 136 | class Name: 137 | def __init__(self, name:str|None, id:str, orig:str|None = None): 138 | self.name = name 139 | self.id = id 140 | self.orig = orig 141 | 142 | def __repr__(self) -> str: 143 | return f"[{self.name}]({self.id})" 144 | 145 | class classproperty: 146 | def __init__(self, func): 147 | self.func = func 148 | 149 | def __get__(self, ins, own): 150 | return self.func(own) 151 | 152 | class Filter: 153 | _cache_available = None 154 | 155 | @classproperty 156 | def available(clazz) -> Self: 157 | if clazz._cache_available is not None: 158 | return clazz._cache_available 159 | 160 | r = requests.get( 161 | "https://jut.su/anime/", headers = { 162 | "User-Agent": "Mozilla/5.0" 163 | } 164 | ) 165 | i = re.findall("(?ms)\\
.*?\\ОК\\\\", html.unescape(r.text))[0] 166 | 167 | genres = [] 168 | g = re.findall("(?ms)\\
.*?\\\\r\\n", i)[0] 169 | for j in re.findall("id=\"anime_ganre_(.*?).*?href=\"/anime/(\\1)/\"\\>(.*?)\\<", g): 170 | genres.append(Name(j[2], j[0])) 171 | 172 | types = [] 173 | g = re.findall("(?ms)\\
.*?\\
", i)[0] 174 | for j in re.findall("id=\"anime_ganre_(.*?).*?/(\\1)/\"\\>(.*?)\\<", g): 175 | types.append(Name(j[2], j[0])) 176 | 177 | years = [] 178 | for j in re.findall("id=\"anime_year_(.*?).*?/(\\1)/\"\\>(.*?)\\<", i): 179 | years.append(Name(j[2], j[0])) 180 | 181 | sorting = [] 182 | g = re.findall("(?ms)\\
.*?\\\\r\\n", i)[0] 183 | for j in re.findall("href=\"/anime/(.*?)\"\\>(.*?)\\<", g): 184 | sorting.append(Name(j[1], j[0].strip("/"))) 185 | 186 | clazz._cache_available = Filter(genres, types, years, sorting) 187 | return clazz._cache_available 188 | 189 | def __init__( 190 | self, 191 | genres:list[Name] = [], 192 | types:list[Name] = [], 193 | years:list[Name] = [], 194 | sorting:list[Name] = [], 195 | link:str|None = None 196 | ): 197 | if link is not None: 198 | ps = link.split("/") 199 | for p in ps: 200 | for g in Filter.available.genres: 201 | if g.id in p.split("-"): 202 | genres.append(g) 203 | else: 204 | for t in Filter.available.types: 205 | if t.id in p.split("-"): 206 | types.append(t) 207 | else: 208 | for y in Filter.available.years: 209 | if y.id in p.split("-and-"): 210 | years.append(y) 211 | else: 212 | for s in Filter.available.sorting: 213 | if s.id == p: 214 | sorting.append(s) 215 | 216 | self.genres = [*{*genres}] 217 | self.types = [*{*types}] 218 | self.years = [*{*years}] 219 | self.sorting = [*{*sorting}] 220 | 221 | def __repr__(self) -> str: 222 | gt = self.genres + self.types 223 | g = "-".join(i.id for i in gt) 224 | if g: g += "/" 225 | y = "-and-".join(i.id for i in self.years) 226 | if y: y += "/" 227 | if len(self.sorting) > 1: 228 | raise ValueError("Filter with more than one sorting cannot be used as a URL") 229 | s = "".join(i.id for i in self.sorting) 230 | if s: s += "/" 231 | return f"{g}{y}{s}" 232 | 233 | class API: 234 | instance = None 235 | 236 | def __init__(self, verbosity:int = 0): 237 | if API.instance is not None: 238 | raise ValueError("Only one instance of the API is possible") 239 | 240 | self.verbosity = verbosity 241 | API.instance = self 242 | 243 | def verbosity(self, v:int) -> None: 244 | self.verbosity = v 245 | 246 | def search(self, keyword:str = "", filter:Filter = Filter(), maxpage:int = -1) -> list[Anime]: 247 | t = "" 248 | page = 1 249 | while True: 250 | if ~maxpage: 251 | if page > maxpage: break 252 | r = requests.post( 253 | f"https://jut.su/anime/{filter}", headers = { 254 | "User-Agent": "Mozilla/5.0", 255 | "Content-Type": "application/x-www-form-urlencoded" 256 | }, 257 | data = (f"ajax_load=yes&start_from_page={page}&show_search={keyword}&anime_of_user=").encode("utf-8") 258 | ) 259 | n = html.unescape(r.text) 260 | if n == "empty": break 261 | n = re.sub("(?ms)\\", "", n) 262 | t += n.strip() 263 | page += 1 264 | 265 | l = [] 266 | for i in re.findall("(?ms)(\\.*?\\.*?\\)", t): 267 | anime = Utils.parse_anime(i[0], i[1]) 268 | l.append(anime) 269 | 270 | return l 271 | 272 | def anime(self, id:str) -> Anime: 273 | return Anime(id = id) 274 | 275 | def episode(self, id:str) -> Episode: 276 | return Episode(id = id) 277 | 278 | class Anime: 279 | def __init__( 280 | self, 281 | name:Name|None = None, 282 | thumbnail:str|None = None, 283 | info:Filter|None = None, 284 | years:list[int]|None = None, 285 | age:int|None = None, 286 | description:str|None = None, 287 | content:Content|None = None, 288 | ongoing:bool|None = None, 289 | id:str|None = None 290 | ): 291 | if name is None and id is None: 292 | raise ValueError("At least one of (name:Name, id:str) must have a value") 293 | 294 | self._cache_name = name or Name(None, id) 295 | self._cache_thumbnail = thumbnail 296 | self._cache_info = info 297 | self._cache_years = years 298 | self._cache_age = age 299 | self._cache_description = description 300 | self._cache_content = content 301 | self._cache_ongoing = ongoing 302 | 303 | self.selector = Selector(self) 304 | 305 | def __repr__(self) -> str: 306 | return f"{self.name.name} - {self.name.orig} {self.years}:\n{self.content}" 307 | 308 | def download(self, quality:int|None = None, path:str = "", threads:int = 1) -> None: 309 | if path and path[-1] != "/": path += "/" 310 | n = path + Utils.clean_path(self.name.name) 311 | try: os.mkdir(n) 312 | except FileExistsError: pass 313 | n += "/" 314 | with open(n + "README.md", "w") as f: 315 | f.write( 316 | f"""# {self.name.name} 317 | 318 | Original name: {self.name.orig} 319 | 320 | Description: {self.description} 321 | 322 | Recommended age: {self.age}+ 323 | 324 | Genres: {", ".join(g.name for g in self.info.genres)} 325 | 326 | Types: {", ".join(t.name for t in self.info.types)} 327 | 328 | Years: {", ".join(map(str, self.years))}{" (ongoing)" if self.ongoing else ""} 329 | """ 330 | ) 331 | if threads == 1: 332 | for s in self.content.seasons: 333 | s.download(quality, path = n) 334 | if self.content.films is not None: 335 | self.content.films.download(quality, path = n) 336 | else: 337 | poolmap = [] 338 | for s in self.content.seasons: 339 | s._download(quality, path = n, poolmap = poolmap) 340 | if self.content.films is not None: 341 | self.content.films._download(quality, path = n, poolmap = poolmap) 342 | Utils.log(f"Pool Map collection finished with final {len(poolmap)} tasks", 1) 343 | pool = ThreadPool(threads) 344 | def downloader(l:list[Episode, str]): 345 | e, p = l 346 | e.download(quality, p) 347 | pool.map(downloader, poolmap) 348 | 349 | @property 350 | def name(self) -> Name: 351 | if self._cache_name.name is None: 352 | self._fetch() 353 | return self._cache_name 354 | 355 | @property 356 | def thumbnail(self) -> str: 357 | if self._cache_thumbnail is None: 358 | self._fetch() 359 | return self._cache_thumbnail 360 | 361 | @property 362 | def info(self) -> Filter: 363 | if self._cache_info is None: 364 | self._fetch() 365 | return self._cache_info 366 | 367 | @property 368 | def years(self) -> list[int]: 369 | if self._cache_years is None: 370 | self._fetch() 371 | return self._cache_years 372 | 373 | @property 374 | def content(self) -> Content: 375 | if self._cache_content is None: 376 | self._fetch() 377 | return self._cache_content 378 | 379 | @property 380 | def age(self) -> int: 381 | if self._cache_age is None: 382 | self._fetch() 383 | return self._cache_age 384 | 385 | @property 386 | def ongoing(self) -> bool: 387 | if self._cache_ongoing is None: 388 | self._fetch() 389 | return self._cache_ongoing 390 | 391 | @property 392 | def description(self) -> str: 393 | if self._cache_description is None: 394 | self._fetch() 395 | return self._cache_description 396 | 397 | def _fetch(self) -> None: 398 | Utils.log("Fetching missing information for Anime", 3) 399 | r = requests.get( 400 | f"https://jut.su/{self._cache_name.id}", headers = { 401 | "User-Agent": "Mozilla/5.0" 402 | } 403 | ) 404 | t = re.sub("\\.*?\\", "", html.unescape(r.text).split("")[1]) 405 | a = Utils.parse_anime(t, self._cache_name.id, full = True) 406 | self._cache_name = a.name 407 | self._cache_thumbnail = a._cache_thumbnail 408 | self._cache_info = a.info 409 | self._cache_years = a.years 410 | self._cache_content = a.content 411 | self._cache_age = a.age 412 | self._cache_description = a.description 413 | self._cache_ongoing = a.ongoing 414 | 415 | class Selector: 416 | def __init__( 417 | self, 418 | parent:Anime 419 | ): 420 | self.parent = parent 421 | 422 | def select_episodes( 423 | self, 424 | quality:int|None = None, 425 | items:Iterable[int]|None = None 426 | ) -> Downloader: 427 | ep = [] 428 | i = 0 429 | for s in self.parent.content.seasons: 430 | for e in s.episodes: 431 | if items is None or i in items: 432 | ep.append([e.player(quality), f"s:{i} {e.title}"]) 433 | i += 1 434 | 435 | if items is not None: 436 | if len(ep) < len(items): 437 | Utils.log("Warning: Unprocessed items left in Selector", 0) 438 | 439 | return Downloader(items = ep) 440 | 441 | def select_seasons( 442 | self, 443 | quality:int|None = None, 444 | items:list[int]|None = None, 445 | ) -> Downloader: 446 | ep = [] 447 | i = 0 448 | for s in self.parent.content.seasons: 449 | t = (" " + s.title) if s.title is not None else "" 450 | if items is None or i in items: 451 | for e in s.episodes: 452 | ep.append([e.player(quality), f"s:{i}{t}/{e.title}"]) 453 | i += 1 454 | 455 | if items is not None: 456 | if len(ep) < len(items): 457 | Utils.log("Warning: Unprocessed items left in Selector", 0) 458 | 459 | return Downloader(items = ep) 460 | 461 | def select_in_seasons( 462 | self, 463 | quality:int|None = None, 464 | items:dict[int, Iterable[int]|None] = { } 465 | ) -> Downloader: 466 | ep = [] 467 | for it in items: 468 | i = 0 469 | s = self.parent.content.seasons[it] 470 | t = (" " + s.title) if s.title is not None else "" 471 | for e in s.episodes: 472 | if items[it] is None or i in items[it]: 473 | ep.append([e.player(quality), f"s:{it}{t}/s:{i} {e.title}"]) 474 | i += 1 475 | 476 | return Downloader(items = ep) 477 | 478 | class Downloader: 479 | def __init__(self, items:list[list[Player, str]] = []): 480 | self.items = items 481 | 482 | def add(self, downloader:Downloader) -> None: 483 | self.items.extend(downloader.items) 484 | 485 | def download(self, path:str = "", threads:int = 1) -> None: 486 | if path and path[-1] != "/": path += "/" 487 | if threads == 1: 488 | for e, p in self.items: 489 | if "/" in p: 490 | try: os.mkdir(path + p.split("/")[0]) 491 | except FileExistsError: pass 492 | e.download(local = path + p) 493 | else: 494 | pool = ThreadPool(threads) 495 | def downloader(l:list[Player, str]): 496 | e, p = l 497 | if "/" in p: 498 | try: os.mkdir(path + p.split("/")[0]) 499 | except FileExistsError: pass 500 | e.download(local = path + p) 501 | pool.map(downloader, self.items) 502 | 503 | class Content: 504 | def __init__( 505 | self, 506 | seasons:list[Season], 507 | films:Season|None = None 508 | ): 509 | self.seasons = seasons 510 | self.films = films 511 | l = 0 512 | for s in seasons: 513 | l += len(s.episodes) 514 | self.count = l 515 | 516 | def __repr__(self) -> str: 517 | return f"Seasons: {self.seasons}\nFilms: {self.films}" 518 | 519 | class Season: 520 | def __init__( 521 | self, 522 | title:str|None, 523 | episodes:list[Episode], 524 | name:Name|None = None, 525 | ): 526 | self.title = title 527 | self.episodes = episodes 528 | self.name = name 529 | 530 | def download( 531 | self, 532 | quality:int|None = None, 533 | path:str = "", 534 | threads:int = 1 535 | ) -> None: 536 | 537 | s = self._path(path) 538 | 539 | if threads == 1: 540 | for e in self.episodes: 541 | e.download(quality, path = s) 542 | else: 543 | pool = ThreadPool(threads) 544 | def downloader(e:Episode): 545 | e.download(quality, path = s) 546 | pool.map(downloader, self.episodes) 547 | 548 | def _download( 549 | self, 550 | quality:int|None, 551 | path:str, 552 | poolmap:list[list[Episode, str]] 553 | ) -> None: 554 | s = self._path(path = path) 555 | 556 | for e in self.episodes: 557 | poolmap.append([e, s]) 558 | 559 | def _path(self, path:str = "") -> str: 560 | if path and path[-1] != "/": path += "/" 561 | if self.name is not None and self.name.name is not None: 562 | t = self.name.name 563 | else: 564 | t = "" 565 | if self.title: 566 | n = self.title 567 | if t: n += " - " 568 | else: 569 | n = "" 570 | 571 | s = n + t 572 | if s: 573 | try: os.mkdir(path + s) 574 | except FileExistsError: pass 575 | s += "/" 576 | 577 | return path + s 578 | 579 | def __repr__(self) -> str: 580 | t = "" 581 | if self.title is not None: 582 | t = self.title 583 | n = "" 584 | if self.name is not None: 585 | if self.name.name is not None: 586 | n = self.name.name 587 | if n: 588 | if t: t += ", " 589 | n += ": " 590 | else: 591 | if t: t += ": " 592 | return f"{t}{n}{self.episodes}" 593 | 594 | class Episode: 595 | def __init__( 596 | self, 597 | title:str|None = None, 598 | name:Name|None = None, 599 | duration:int|None = None, 600 | opening:Opening|None = None, 601 | ending:Ending|None = None, 602 | players:list[Player]|None = None, 603 | thumbnail:str|None = None, 604 | preview:str|None = None, 605 | id:str|None = None 606 | ): 607 | if name is None and id is None: 608 | raise ValueError("At least one of (name:Name, id:str) must have a value") 609 | self._cache_title = title 610 | self._cache_name = name or Name(None, id) 611 | self._cache_duration = duration 612 | self._cache_opening = opening 613 | self._cache_ending = ending 614 | self._cache_players = players 615 | self._cache_thumbnail = thumbnail 616 | self._cache_preview = preview 617 | 618 | def __repr__(self) -> str: 619 | return f"{self.title}" 620 | 621 | def download(self, quality:int|None = None, path:str = "") -> None: 622 | if path and path[-1] != "/": path += "/" 623 | if self.name.name is not None: 624 | t = " - " + Utils.clean_path(self.name.name) 625 | else: 626 | t = "" 627 | n = Utils.clean_path(self.title) + t 628 | self.player(quality).download(path + n) 629 | 630 | @property 631 | def title(self) -> str: 632 | if self._cache_title is None: 633 | self._fetch() 634 | return self._cache_title 635 | 636 | @property 637 | def name(self) -> Name: 638 | if self._cache_name.name is None: 639 | self._fetch() 640 | return self._cache_name 641 | 642 | @property 643 | def duration(self) -> int: 644 | if self._cache_duration is None: 645 | self._fetch() 646 | return self._cache_duration 647 | 648 | @property 649 | def opening(self) -> Opening: 650 | if self._cache_opening is None: 651 | self._fetch() 652 | return self._cache_opening 653 | 654 | @property 655 | def ending(self) -> Ending: 656 | if self._cache_ending is None: 657 | self._fetch() 658 | return self._cache_ending 659 | 660 | @property 661 | def players(self) -> list[Player]: 662 | if self._cache_players is None: 663 | self._fetch() 664 | return self._cache_players 665 | 666 | def player(self, quality:int|None = None) -> Player|None: 667 | if quality is not None: 668 | for p in self.players: 669 | if p.quality == quality: 670 | return p 671 | return None 672 | else: 673 | q = [p.quality for p in self.players] 674 | return self.player(quality = max(q)) 675 | 676 | @property 677 | def thumbnail(self) -> str: 678 | if self._cache_thumbnail is None: 679 | self._fetch() 680 | return self._cache_thumbnail 681 | 682 | @property 683 | def preview(self) -> str: 684 | if self._cache_preview is None: 685 | self._fetch() 686 | return self._cache_preview 687 | 688 | def _fetch(self) -> None: 689 | Utils.log("Fetching missing information for Episode", 3) 690 | r = requests.get( 691 | f"https://jut.su/{self._cache_name.id}", headers = { 692 | "User-Agent": "Mozilla/5.0", 693 | } 694 | ) 695 | n = html.unescape(r.text) 696 | n = n.split("")[1] 697 | n = n.split("")[0] 698 | n = n.strip() 699 | if "
" in n: 700 | raise NameError("Episode with this id not found") 701 | 702 | titl = re.findall("\\\\.*?\\(.*?)\\", n)[0] 703 | 704 | if "video_plate_title" in n: 705 | na = re.findall("\\(.*?)\\", n)[0] 706 | else: 707 | na = None 708 | nn = Name(id = self._cache_name.id, name = na) 709 | 710 | base = re.findall("Base64.decode\\( \"(.*)\" \\)", n)[0] 711 | data = base64.b64decode(base).decode("utf-8") 712 | 713 | dur = int(re.findall("this_video_duration = (\\d+)", data)[0]) 714 | 715 | oph = re.findall("video_music_intro = \"(.*?)\"", data) 716 | if oph: 717 | opl = oph[0] 718 | opi = int(re.findall("video_intro_start = (\\d+)", data)[0]) 719 | opo = int(re.findall("video_intro_end = (\\d+)", data)[0]) 720 | op = Opening(opi, opo, opl) 721 | else: 722 | op = None 723 | 724 | edh = re.findall("video_music_outro = \"(.*?)\"", data) 725 | if edh: 726 | edl = edh[0] 727 | edi = int(re.findall("video_outro_start = (\\d+)", data)[0]) 728 | ed = Ending(edi, edl) 729 | else: 730 | ed = None 731 | 732 | thum = re.findall("preload=\"none\" poster=\"(.*?)\"", n)[0] 733 | prev = re.findall("previews=\"(.*?)\\|\\d+\\|\\d+\"", n)[0] 734 | 735 | pl = [] 736 | for i in re.findall("\\ str: 768 | return f"{self.link} ({self.quality}p)" 769 | 770 | def download(self, local:str|None = None) -> None: 771 | if local is None: 772 | local = self.link.split("?")[0].split("/")[-1] 773 | ending = self.link.split("?")[0].split(".")[-1] 774 | p = f"{local} ({self.quality}p).{ending}" 775 | if os.path.exists(p): 776 | Utils.log(f"Skipping episode, because file '{p}' exists", 1) 777 | return 778 | dl = p + ".jutsu-dl" 779 | if os.path.exists(dl): 780 | Utils.log(f"Resuming download of '{p}'", 1) 781 | skip = os.stat(dl).st_size 782 | else: skip = 0 783 | with requests.get( 784 | self.link, headers = { 785 | "User-Agent": "Mozilla/5.0", 786 | "Range": f"bytes={skip}-" 787 | }, stream = True 788 | ) as r: 789 | if not skip: 790 | Utils.log(f"Downloading episode to '{p}'...", 1) 791 | size = int(r.headers["Content-Length"]) + skip 792 | r.raise_for_status() 793 | with open(dl, "ab") as f: 794 | d = skip 795 | for chunk in r.iter_content(chunk_size = 512 * 1024): 796 | f.write(chunk) 797 | d += len(chunk) 798 | if not d % 1024 * 1024 * 10: 799 | Utils.log(f"Progress: {100 * d // size}% with {d} bytes", 2) 800 | os.rename(dl, p) 801 | --------------------------------------------------------------------------------