anipy-cli 2.7.17__py3-none-any.whl → 3.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anipy_cli/__init__.py +2 -20
- anipy_cli/anilist_proxy.py +229 -0
- anipy_cli/arg_parser.py +109 -21
- anipy_cli/cli.py +98 -0
- anipy_cli/clis/__init__.py +17 -0
- anipy_cli/clis/anilist_cli.py +62 -0
- anipy_cli/clis/base_cli.py +34 -0
- anipy_cli/clis/binge_cli.py +96 -0
- anipy_cli/clis/default_cli.py +115 -0
- anipy_cli/clis/download_cli.py +85 -0
- anipy_cli/clis/history_cli.py +96 -0
- anipy_cli/clis/mal_cli.py +71 -0
- anipy_cli/{cli/clis → clis}/seasonal_cli.py +9 -6
- anipy_cli/colors.py +14 -8
- anipy_cli/config.py +387 -90
- anipy_cli/discord.py +34 -0
- anipy_cli/download_component.py +194 -0
- anipy_cli/logger.py +200 -0
- anipy_cli/mal_proxy.py +228 -0
- anipy_cli/menus/__init__.py +6 -0
- anipy_cli/menus/anilist_menu.py +671 -0
- anipy_cli/{cli/menus → menus}/base_menu.py +9 -14
- anipy_cli/menus/mal_menu.py +657 -0
- anipy_cli/menus/menu.py +265 -0
- anipy_cli/menus/seasonal_menu.py +270 -0
- anipy_cli/prompts.py +387 -0
- anipy_cli/util.py +268 -0
- anipy_cli-3.8.2.dist-info/METADATA +71 -0
- anipy_cli-3.8.2.dist-info/RECORD +31 -0
- {anipy_cli-2.7.17.dist-info → anipy_cli-3.8.2.dist-info}/WHEEL +1 -2
- anipy_cli-3.8.2.dist-info/entry_points.txt +3 -0
- anipy_cli/cli/__init__.py +0 -1
- anipy_cli/cli/cli.py +0 -37
- anipy_cli/cli/clis/__init__.py +0 -6
- anipy_cli/cli/clis/base_cli.py +0 -43
- anipy_cli/cli/clis/binge_cli.py +0 -54
- anipy_cli/cli/clis/default_cli.py +0 -46
- anipy_cli/cli/clis/download_cli.py +0 -92
- anipy_cli/cli/clis/history_cli.py +0 -64
- anipy_cli/cli/clis/mal_cli.py +0 -27
- anipy_cli/cli/menus/__init__.py +0 -3
- anipy_cli/cli/menus/mal_menu.py +0 -411
- anipy_cli/cli/menus/menu.py +0 -102
- anipy_cli/cli/menus/seasonal_menu.py +0 -174
- anipy_cli/cli/util.py +0 -118
- anipy_cli/download.py +0 -454
- anipy_cli/history.py +0 -83
- anipy_cli/mal.py +0 -645
- anipy_cli/misc.py +0 -227
- anipy_cli/player/__init__.py +0 -1
- anipy_cli/player/player.py +0 -33
- anipy_cli/player/players/__init__.py +0 -3
- anipy_cli/player/players/base.py +0 -106
- anipy_cli/player/players/mpv.py +0 -19
- anipy_cli/player/players/mpv_contrl.py +0 -37
- anipy_cli/player/players/syncplay.py +0 -19
- anipy_cli/player/players/vlc.py +0 -18
- anipy_cli/query.py +0 -92
- anipy_cli/run_anipy_cli.py +0 -14
- anipy_cli/seasonal.py +0 -106
- anipy_cli/url_handler.py +0 -442
- anipy_cli/version.py +0 -1
- anipy_cli-2.7.17.dist-info/LICENSE +0 -674
- anipy_cli-2.7.17.dist-info/METADATA +0 -159
- anipy_cli-2.7.17.dist-info/RECORD +0 -43
- anipy_cli-2.7.17.dist-info/entry_points.txt +0 -2
- anipy_cli-2.7.17.dist-info/top_level.txt +0 -1
anipy_cli/prompts.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import TYPE_CHECKING, Optional, List, Tuple
|
|
3
|
+
from InquirerPy import inquirer
|
|
4
|
+
from InquirerPy.base.control import Choice
|
|
5
|
+
from anipy_api.mal import MyAnimeListAdapter
|
|
6
|
+
from anipy_api.provider import (
|
|
7
|
+
BaseProvider,
|
|
8
|
+
FilterCapabilities,
|
|
9
|
+
Filters,
|
|
10
|
+
LanguageTypeEnum,
|
|
11
|
+
Season,
|
|
12
|
+
)
|
|
13
|
+
from anipy_api.anime import Anime
|
|
14
|
+
|
|
15
|
+
from anipy_cli.util import (
|
|
16
|
+
DotSpinner,
|
|
17
|
+
find_closest,
|
|
18
|
+
get_anime_season,
|
|
19
|
+
get_prefered_providers,
|
|
20
|
+
error,
|
|
21
|
+
parse_episode_ranges,
|
|
22
|
+
convert_letter_to_season,
|
|
23
|
+
)
|
|
24
|
+
from anipy_cli.colors import colors
|
|
25
|
+
from anipy_cli.config import Config
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from anipy_api.provider import Episode
|
|
30
|
+
from anipy_api.locallist import LocalList
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def search_show_prompt(
|
|
34
|
+
mode: str, skip_season_search: bool = False
|
|
35
|
+
) -> Optional["Anime"]:
|
|
36
|
+
if not (Config().skip_season_search or skip_season_search):
|
|
37
|
+
anime = season_search_pre_prompt(mode)
|
|
38
|
+
if anime is not None:
|
|
39
|
+
return anime
|
|
40
|
+
|
|
41
|
+
query = inquirer.text( # type: ignore
|
|
42
|
+
"Search Anime:",
|
|
43
|
+
long_instruction="To cancel this prompt press ctrl+z",
|
|
44
|
+
mandatory=False,
|
|
45
|
+
).execute()
|
|
46
|
+
|
|
47
|
+
if not (query and query.strip()):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
with DotSpinner("Searching for ", colors.BLUE, query, "..."):
|
|
51
|
+
results: List[Anime] = []
|
|
52
|
+
for provider in get_prefered_providers(mode):
|
|
53
|
+
results.extend(
|
|
54
|
+
[
|
|
55
|
+
Anime.from_search_result(provider, x)
|
|
56
|
+
for x in provider.get_search(query)
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if len(results) == 0:
|
|
61
|
+
error("no search results")
|
|
62
|
+
# Do not prompt for season again
|
|
63
|
+
return search_show_prompt(mode, skip_season_search=True)
|
|
64
|
+
|
|
65
|
+
anime = inquirer.fuzzy( # type: ignore
|
|
66
|
+
message="Select Show:",
|
|
67
|
+
choices=[
|
|
68
|
+
Choice(value=r, name=f"{n + 1}. {repr(r)}") for n, r in enumerate(results)
|
|
69
|
+
],
|
|
70
|
+
long_instruction=(
|
|
71
|
+
"\nS = Anime is available in sub\n"
|
|
72
|
+
"D = Anime is available in dub\n"
|
|
73
|
+
"First two letters indicate the provider\n"
|
|
74
|
+
"To skip this prompt press ctrl+z"
|
|
75
|
+
),
|
|
76
|
+
mandatory=False,
|
|
77
|
+
).execute()
|
|
78
|
+
|
|
79
|
+
return anime
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _get_season_provider(mode: str) -> Optional["BaseProvider"]:
|
|
83
|
+
season_provider = None
|
|
84
|
+
for p in get_prefered_providers(mode):
|
|
85
|
+
if p.FILTER_CAPS & (
|
|
86
|
+
FilterCapabilities.SEASON
|
|
87
|
+
| FilterCapabilities.YEAR
|
|
88
|
+
| FilterCapabilities.NO_QUERY
|
|
89
|
+
):
|
|
90
|
+
season_provider = p
|
|
91
|
+
return season_provider
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def season_search_pre_prompt(
|
|
95
|
+
mode: str, year: Optional[int] = None, season: Optional[str] = None
|
|
96
|
+
) -> Optional["Anime"]:
|
|
97
|
+
season_provider = _get_season_provider(mode)
|
|
98
|
+
assume_season_search = Config().assume_season_search
|
|
99
|
+
|
|
100
|
+
# If there is no proper season provider
|
|
101
|
+
if season_provider is None:
|
|
102
|
+
if not assume_season_search:
|
|
103
|
+
return
|
|
104
|
+
# If assume search was on, and there is no proper season provider
|
|
105
|
+
print(
|
|
106
|
+
f"`assume_season_search` was set to true, but the providers ({', '.join(Config().providers[mode])}) you have selected do not have seasonal capabilities"
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if assume_season_search or (year and season):
|
|
111
|
+
return season_search_prompt(season_provider, year, season)
|
|
112
|
+
|
|
113
|
+
should_search = inquirer.confirm("Do you want to search in season?", default=False).execute() # type: ignore
|
|
114
|
+
|
|
115
|
+
if should_search:
|
|
116
|
+
return season_search_prompt(season_provider)
|
|
117
|
+
|
|
118
|
+
print(
|
|
119
|
+
"Hint: you can set `skip_season_search` to `true` in the config to skip this prompt!"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def season_search_prompt(
|
|
124
|
+
provider: "BaseProvider", year: Optional[int] = None, season: Optional[str] = None
|
|
125
|
+
) -> Optional["Anime"]:
|
|
126
|
+
if year is None:
|
|
127
|
+
curr_year = time.localtime().tm_year
|
|
128
|
+
year = inquirer.number( # type: ignore
|
|
129
|
+
message="Enter year:",
|
|
130
|
+
long_instruction="To skip this prompt press ctrl+z",
|
|
131
|
+
default=curr_year,
|
|
132
|
+
mandatory=False,
|
|
133
|
+
).execute()
|
|
134
|
+
|
|
135
|
+
if not year or not str(year).strip():
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if season is None:
|
|
139
|
+
season = inquirer.select( # type: ignore
|
|
140
|
+
message="Select Season:",
|
|
141
|
+
choices=["Winter", "Spring", "Summer", "Fall"],
|
|
142
|
+
instruction="The season selected by default is the current season.",
|
|
143
|
+
long_instruction="To skip this prompt press ctrl+z",
|
|
144
|
+
default=get_anime_season(time.localtime().tm_mon),
|
|
145
|
+
mandatory=False,
|
|
146
|
+
).execute()
|
|
147
|
+
|
|
148
|
+
if season is None:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
discovered_anime = get_anime_by_season(provider, year, Season[season.upper()])
|
|
152
|
+
|
|
153
|
+
if not discovered_anime:
|
|
154
|
+
error(f"No anime found in {season} {year}")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
anime = inquirer.fuzzy( # type: ignore
|
|
158
|
+
message="Select Show:",
|
|
159
|
+
choices=[
|
|
160
|
+
Choice(value=r, name=f"{n + 1}. {repr(r)}")
|
|
161
|
+
for n, r in enumerate(discovered_anime)
|
|
162
|
+
],
|
|
163
|
+
long_instruction=(
|
|
164
|
+
"\nS = Anime is available in sub\n"
|
|
165
|
+
"D = Anime is available in dub\n"
|
|
166
|
+
"First two letters indicate the provider\n"
|
|
167
|
+
"To skip this prompt press ctrl+z"
|
|
168
|
+
),
|
|
169
|
+
mandatory=False,
|
|
170
|
+
).execute()
|
|
171
|
+
|
|
172
|
+
return anime
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_anime_by_season(provider: "BaseProvider", year: int, season: Season):
|
|
176
|
+
with DotSpinner(
|
|
177
|
+
"Retrieving anime in ", colors.BLUE, f"{season.name} {year}", "..."
|
|
178
|
+
):
|
|
179
|
+
filters = Filters(year=year, season=season)
|
|
180
|
+
return [
|
|
181
|
+
Anime.from_search_result(provider, r)
|
|
182
|
+
for r in provider.get_search(query="", filters=filters)
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def pick_episode_prompt(
|
|
187
|
+
anime: "Anime", lang: LanguageTypeEnum, instruction: str = ""
|
|
188
|
+
) -> Optional["Episode"]:
|
|
189
|
+
with DotSpinner("Fetching episode list for ", colors.BLUE, anime.name, "..."):
|
|
190
|
+
episodes = anime.get_episodes(lang)
|
|
191
|
+
|
|
192
|
+
if not episodes:
|
|
193
|
+
error(f"No episodes available for {anime.name}")
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
return inquirer.fuzzy( # type: ignore
|
|
197
|
+
message="Select Episode:",
|
|
198
|
+
instruction=instruction,
|
|
199
|
+
choices=episodes,
|
|
200
|
+
long_instruction="To skip this prompt press ctrl+z",
|
|
201
|
+
mandatory=False,
|
|
202
|
+
).execute()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def pick_episode_range_prompt(
|
|
206
|
+
anime: "Anime", lang: LanguageTypeEnum
|
|
207
|
+
) -> List["Episode"]:
|
|
208
|
+
with DotSpinner("Fetching episode list for ", colors.BLUE, anime.name, "..."):
|
|
209
|
+
episodes = anime.get_episodes(lang)
|
|
210
|
+
|
|
211
|
+
if not episodes:
|
|
212
|
+
error(f"No episodes available for {anime.name}")
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
res = inquirer.text( # type: ignore
|
|
216
|
+
message=f"Input Episode Range(s) from episodes {episodes[0]} to {episodes[-1]}:",
|
|
217
|
+
long_instruction="Type e.g. `1-10 19-20` or `3-4` or `3`\nTo skip this prompt press ctrl+z",
|
|
218
|
+
mandatory=False,
|
|
219
|
+
).execute()
|
|
220
|
+
|
|
221
|
+
if res is None:
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
return parse_episode_ranges(res, episodes)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def lang_prompt(anime: "Anime") -> LanguageTypeEnum:
|
|
228
|
+
config = Config()
|
|
229
|
+
preferred = (
|
|
230
|
+
LanguageTypeEnum[config.preferred_type.upper()]
|
|
231
|
+
if config.preferred_type is not None
|
|
232
|
+
else None
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if preferred in anime.languages:
|
|
236
|
+
return preferred
|
|
237
|
+
|
|
238
|
+
if LanguageTypeEnum.DUB not in anime.languages:
|
|
239
|
+
return LanguageTypeEnum.SUB
|
|
240
|
+
|
|
241
|
+
if len(anime.languages) == 2:
|
|
242
|
+
res = inquirer.confirm("Want to watch in dub?").execute() # type: ignore
|
|
243
|
+
print("Hint: you can set a default in the config with `preferred_type`!")
|
|
244
|
+
|
|
245
|
+
if res:
|
|
246
|
+
return LanguageTypeEnum.DUB
|
|
247
|
+
else:
|
|
248
|
+
return LanguageTypeEnum.SUB
|
|
249
|
+
else:
|
|
250
|
+
return next(iter(anime.languages))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_seasonal_search(mode: str, passed: str | bool) -> Optional["Anime"]:
|
|
254
|
+
"""
|
|
255
|
+
Takes the mode we are in, as well as the search parameters.
|
|
256
|
+
Asks the user to choose an anime.
|
|
257
|
+
|
|
258
|
+
`Mode`: The provider to use.
|
|
259
|
+
`Passed`: The search terms passed (ex: `year:season`) or True,
|
|
260
|
+
if True we'll ask the user for this information
|
|
261
|
+
"""
|
|
262
|
+
if isinstance(passed, bool):
|
|
263
|
+
if not passed:
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
provider = _get_season_provider(mode)
|
|
267
|
+
|
|
268
|
+
if not provider:
|
|
269
|
+
error(
|
|
270
|
+
"No valid provider was found for season search in the current mode",
|
|
271
|
+
fatal=True,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return season_search_prompt(provider)
|
|
275
|
+
|
|
276
|
+
options = iter(passed.split(":"))
|
|
277
|
+
year = next(options, None)
|
|
278
|
+
season = next(options, None)
|
|
279
|
+
|
|
280
|
+
if (not year) or not year.isnumeric():
|
|
281
|
+
error("A year was either not provided, or was not a number", fatal=True)
|
|
282
|
+
|
|
283
|
+
if not season:
|
|
284
|
+
error("A season was not provided", fatal=True)
|
|
285
|
+
|
|
286
|
+
season = convert_letter_to_season(season)
|
|
287
|
+
|
|
288
|
+
if not season:
|
|
289
|
+
error("The given season was not a valid season", fatal=True)
|
|
290
|
+
|
|
291
|
+
return season_search_pre_prompt(mode, int(year), season)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def parse_auto_search(
|
|
295
|
+
mode: str, passed: str
|
|
296
|
+
) -> Tuple["Anime", LanguageTypeEnum, List["Episode"]]:
|
|
297
|
+
options = iter(passed.split(":"))
|
|
298
|
+
query = next(options, None)
|
|
299
|
+
ranges = next(options, None)
|
|
300
|
+
ltype = next(options, None)
|
|
301
|
+
|
|
302
|
+
if not query:
|
|
303
|
+
error("you provided the search parameter but no query", fatal=True)
|
|
304
|
+
|
|
305
|
+
if not ranges:
|
|
306
|
+
error("you provided the search parameter but no episode ranges", fatal=True)
|
|
307
|
+
|
|
308
|
+
if not (ltype == "sub" or ltype == "dub"):
|
|
309
|
+
ltype = Config().preferred_type
|
|
310
|
+
|
|
311
|
+
with DotSpinner("Searching for ", colors.BLUE, query, "..."):
|
|
312
|
+
results: List[Anime] = []
|
|
313
|
+
for provider in get_prefered_providers(mode):
|
|
314
|
+
results.extend(
|
|
315
|
+
[
|
|
316
|
+
Anime.from_search_result(provider, x)
|
|
317
|
+
for x in provider.get_search(query)
|
|
318
|
+
]
|
|
319
|
+
)
|
|
320
|
+
if len(results) == 0:
|
|
321
|
+
error(f"no anime found for query {query}", fatal=True)
|
|
322
|
+
|
|
323
|
+
result = results[0]
|
|
324
|
+
if ltype is None:
|
|
325
|
+
lang = lang_prompt(result)
|
|
326
|
+
else:
|
|
327
|
+
lang = LanguageTypeEnum[ltype.upper()]
|
|
328
|
+
|
|
329
|
+
if lang not in result.languages:
|
|
330
|
+
error(f"{lang} is not available for {result.name}", fatal=True)
|
|
331
|
+
|
|
332
|
+
episodes = result.get_episodes(lang)
|
|
333
|
+
chosen = parse_episode_ranges(ranges, episodes)
|
|
334
|
+
if not chosen:
|
|
335
|
+
error("could not determine any epiosdes from search parameter", fatal=True)
|
|
336
|
+
|
|
337
|
+
return result, lang, chosen
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def migrate_provider(mode: str, local_list: "LocalList"):
|
|
341
|
+
config = Config()
|
|
342
|
+
all_entries = local_list.get_all()
|
|
343
|
+
current_providers = list(get_prefered_providers(mode))
|
|
344
|
+
print(
|
|
345
|
+
f"Migrating to configured providers ({mode}):",
|
|
346
|
+
", ".join([p.NAME for p in current_providers]),
|
|
347
|
+
)
|
|
348
|
+
for s in all_entries:
|
|
349
|
+
if s.provider in [p.NAME for p in current_providers]:
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
print(f"Mapping: {s.name}")
|
|
353
|
+
|
|
354
|
+
search_results: List[Anime] = []
|
|
355
|
+
for p in current_providers:
|
|
356
|
+
search_results.extend(
|
|
357
|
+
[Anime.from_search_result(p, r) for r in p.get_search(s.name)]
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
best_anime = None
|
|
361
|
+
best_ratio = 0
|
|
362
|
+
for r in search_results:
|
|
363
|
+
titles = {r.name}
|
|
364
|
+
titles |= set(r.get_info().alternative_names or [])
|
|
365
|
+
ratio = MyAnimeListAdapter._find_best_ratio(titles, {s.name})
|
|
366
|
+
|
|
367
|
+
if ratio > best_ratio:
|
|
368
|
+
best_ratio = ratio
|
|
369
|
+
best_anime = r
|
|
370
|
+
|
|
371
|
+
if best_ratio == 1:
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
if best_anime is None:
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
if best_ratio >= config.mal_mapping_min_similarity:
|
|
378
|
+
local_list.delete(s)
|
|
379
|
+
else:
|
|
380
|
+
print(f"Could not autmatically map {s.name}, you can map it manually.")
|
|
381
|
+
best_anime = search_show_prompt("seasonal", skip_season_search=True)
|
|
382
|
+
if best_anime is None:
|
|
383
|
+
continue
|
|
384
|
+
local_list.delete(s)
|
|
385
|
+
|
|
386
|
+
episode = find_closest(best_anime.get_episodes(s.language), s.episode)
|
|
387
|
+
local_list.update(best_anime, language=s.language, episode=episode)
|
anipy_cli/util.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import subprocess as sp
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import (
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
Any,
|
|
9
|
+
Iterator,
|
|
10
|
+
List,
|
|
11
|
+
Literal,
|
|
12
|
+
NoReturn,
|
|
13
|
+
Optional,
|
|
14
|
+
Union,
|
|
15
|
+
overload,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import anipy_cli.logger as logger
|
|
19
|
+
|
|
20
|
+
from anipy_api.anime import Anime
|
|
21
|
+
from anipy_api.download import Downloader, PostDownloadCallback
|
|
22
|
+
from anipy_api.locallist import LocalListData
|
|
23
|
+
from anipy_api.player import get_player
|
|
24
|
+
from anipy_api.provider import list_providers
|
|
25
|
+
from InquirerPy import inquirer
|
|
26
|
+
from yaspin.core import Yaspin
|
|
27
|
+
from yaspin.spinners import Spinners
|
|
28
|
+
|
|
29
|
+
from anipy_cli.colors import color, colors
|
|
30
|
+
from anipy_cli.config import Config
|
|
31
|
+
from anipy_cli.discord import DiscordPresence
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from anipy_api.player import PlayerBase
|
|
35
|
+
from anipy_api.provider import BaseProvider, Episode, ProviderStream
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DotSpinner(Yaspin):
|
|
39
|
+
def __init__(self, *text_and_colors: Any, **spinner_args: Any):
|
|
40
|
+
super().__init__(
|
|
41
|
+
text=color(*text_and_colors),
|
|
42
|
+
color="cyan",
|
|
43
|
+
spinner=Spinners.dots,
|
|
44
|
+
**spinner_args,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def __enter__(self) -> "DotSpinner":
|
|
48
|
+
self.start()
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def set_text(self, *text_and_colors: Any):
|
|
52
|
+
self.text = color(*text_and_colors)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@overload
|
|
56
|
+
def error(error: str, fatal: Literal[True]) -> NoReturn: ...
|
|
57
|
+
@overload
|
|
58
|
+
def error(
|
|
59
|
+
error: str, fatal: Literal[False] = ..., log_level: int = logging.INFO
|
|
60
|
+
) -> None: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def error(
|
|
64
|
+
error: str, fatal: bool = False, log_level: int = logging.INFO
|
|
65
|
+
) -> Union[NoReturn, None]:
|
|
66
|
+
if not fatal:
|
|
67
|
+
sys.stderr.write(
|
|
68
|
+
color(colors.RED, "anipy-cli: error: ", colors.END, f"{error}\n")
|
|
69
|
+
)
|
|
70
|
+
logger.log(log_level, error)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
sys.stderr.write(
|
|
74
|
+
color(
|
|
75
|
+
colors.RED,
|
|
76
|
+
"anipy-cli: fatal error: ",
|
|
77
|
+
colors.END,
|
|
78
|
+
f"{error}, exiting\n",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
logger.warn(error)
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def clear_screen():
|
|
86
|
+
if logger.get_console_log_level() < 60:
|
|
87
|
+
return
|
|
88
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_prefered_providers(mode: str) -> Iterator["BaseProvider"]:
|
|
92
|
+
config = Config()
|
|
93
|
+
preferred_providers = config.providers[mode]
|
|
94
|
+
|
|
95
|
+
if not preferred_providers:
|
|
96
|
+
error(
|
|
97
|
+
f"you have no providers set for '{mode}' mode, look into your config",
|
|
98
|
+
fatal=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
providers = []
|
|
102
|
+
for i in list_providers():
|
|
103
|
+
if i.NAME in preferred_providers:
|
|
104
|
+
url_override = config.provider_urls.get(i.NAME, None)
|
|
105
|
+
providers.append(i(url_override))
|
|
106
|
+
|
|
107
|
+
if not providers:
|
|
108
|
+
error(
|
|
109
|
+
f"there are no working providers for '{mode}' mode, look into your config",
|
|
110
|
+
fatal=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
for p in providers:
|
|
114
|
+
yield p
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_download_path(
|
|
118
|
+
anime: "Anime",
|
|
119
|
+
stream: "ProviderStream",
|
|
120
|
+
parent_directory: Optional[Path] = None,
|
|
121
|
+
) -> Path:
|
|
122
|
+
config = Config()
|
|
123
|
+
download_folder = parent_directory or config.download_folder_path
|
|
124
|
+
|
|
125
|
+
anime_name = Downloader._get_valid_pathname(anime.name)
|
|
126
|
+
filename = config.download_name_format.format(
|
|
127
|
+
show_name=anime_name,
|
|
128
|
+
episode_number=str(stream.episode).zfill(2),
|
|
129
|
+
quality=stream.resolution,
|
|
130
|
+
provider=anime.provider.NAME,
|
|
131
|
+
type=stream.language,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
filename = Downloader._get_valid_pathname(filename)
|
|
135
|
+
|
|
136
|
+
return download_folder / anime_name / filename
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_post_download_scripts_hook(
|
|
140
|
+
mode: str, anime: "Anime", spinner: DotSpinner
|
|
141
|
+
) -> PostDownloadCallback:
|
|
142
|
+
config = Config()
|
|
143
|
+
scripts = config.post_download_scripts[mode]
|
|
144
|
+
timeout = config.post_download_scripts["timeout"]
|
|
145
|
+
|
|
146
|
+
def hook(path: Path, stream: "ProviderStream"):
|
|
147
|
+
spinner.hide()
|
|
148
|
+
arguments = [
|
|
149
|
+
str(path),
|
|
150
|
+
anime.name,
|
|
151
|
+
str(stream.episode),
|
|
152
|
+
anime.provider.NAME,
|
|
153
|
+
str(stream.resolution),
|
|
154
|
+
stream.language.name,
|
|
155
|
+
]
|
|
156
|
+
for s in scripts:
|
|
157
|
+
sub_proc = sp.Popen([s, *arguments])
|
|
158
|
+
sub_proc.wait(timeout) # type: ignore
|
|
159
|
+
spinner.show()
|
|
160
|
+
|
|
161
|
+
return hook
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_episode_ranges(ranges: str, episodes: List["Episode"]) -> List["Episode"]:
|
|
165
|
+
picked = set()
|
|
166
|
+
for r in ranges.split():
|
|
167
|
+
numbers = [parsenum(n) for n in r.split("-")]
|
|
168
|
+
if numbers[0] > numbers[-1]:
|
|
169
|
+
error(f"invalid range: {r}")
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
picked = picked | set(
|
|
173
|
+
episodes[episodes.index(numbers[0]) : episodes.index(numbers[-1]) + 1]
|
|
174
|
+
)
|
|
175
|
+
except ValueError:
|
|
176
|
+
error(f"range `{r}` is not contained in episodes {episodes}")
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
return sorted(picked)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parsenum(n: str):
|
|
183
|
+
try:
|
|
184
|
+
return int(n)
|
|
185
|
+
except ValueError:
|
|
186
|
+
return float(n)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def find_closest(episodes: List["Episode"], target: "Episode") -> "Episode":
|
|
190
|
+
left, right = 0, len(episodes) - 1
|
|
191
|
+
while left < right:
|
|
192
|
+
if abs(episodes[left] - target) <= abs(episodes[right] - target):
|
|
193
|
+
right -= 1
|
|
194
|
+
else:
|
|
195
|
+
left += 1
|
|
196
|
+
|
|
197
|
+
return episodes[left]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_configured_player(player_override: Optional[str] = None) -> "PlayerBase":
|
|
201
|
+
config = Config()
|
|
202
|
+
player = Path(player_override or config.player_path)
|
|
203
|
+
if config.dc_presence:
|
|
204
|
+
# If the cache size is 0, it means that DiscordPresence was
|
|
205
|
+
# not intialized once in the run_cli function and therefore we
|
|
206
|
+
# can assume that it failed to initialize beacuse of some error.
|
|
207
|
+
if DiscordPresence.cache_info().currsize > 0:
|
|
208
|
+
discord_cb = DiscordPresence().dc_presence_callback
|
|
209
|
+
else:
|
|
210
|
+
discord_cb = None
|
|
211
|
+
else:
|
|
212
|
+
discord_cb = None
|
|
213
|
+
|
|
214
|
+
if "mpv" in player.stem:
|
|
215
|
+
args = config.mpv_commandline_options
|
|
216
|
+
elif "vlc" in player.stem:
|
|
217
|
+
args = config.vlc_commandline_options
|
|
218
|
+
elif "iina" in player.stem:
|
|
219
|
+
args = config.iina_commandline_options
|
|
220
|
+
else:
|
|
221
|
+
args = []
|
|
222
|
+
|
|
223
|
+
return get_player(player, args, discord_cb)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_anime_season(month: int):
|
|
227
|
+
if 1 <= month <= 3:
|
|
228
|
+
return "Winter"
|
|
229
|
+
elif 4 <= month <= 6:
|
|
230
|
+
return "Spring"
|
|
231
|
+
elif 7 <= month <= 9:
|
|
232
|
+
return "Summer"
|
|
233
|
+
else:
|
|
234
|
+
return "Fall"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def convert_letter_to_season(letter: str) -> Optional[str]:
|
|
238
|
+
"""
|
|
239
|
+
Converts the beginning of the name of a season to that season name.
|
|
240
|
+
|
|
241
|
+
Ex:
|
|
242
|
+
```
|
|
243
|
+
win -> Winter
|
|
244
|
+
su -> Summer
|
|
245
|
+
sp -> Spring
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Returns None if the letter does not correspond to a season
|
|
249
|
+
"""
|
|
250
|
+
for season in ["Spring", "Summer", "Fall", "Winter"]:
|
|
251
|
+
if season.startswith(letter.capitalize()):
|
|
252
|
+
return season
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def migrate_locallist(file: Path) -> LocalListData:
|
|
257
|
+
error(f"{file} is in an unsuported format...")
|
|
258
|
+
|
|
259
|
+
new_list = LocalListData({})
|
|
260
|
+
choice = inquirer.confirm( # type: ignore
|
|
261
|
+
message="Should it be delted?",
|
|
262
|
+
default=False,
|
|
263
|
+
).execute()
|
|
264
|
+
if choice:
|
|
265
|
+
file.unlink()
|
|
266
|
+
return new_list
|
|
267
|
+
else:
|
|
268
|
+
error("could not read {file}", fatal=True)
|