anipy-cli 3.4.8__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 CHANGED
@@ -1,2 +1,2 @@
1
1
  __appname__ = "anipy-cli"
2
- __version__ = "3.4.8"
2
+ __version__ = "3.8.2"
@@ -0,0 +1,229 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Dict, List, Optional, Set
3
+
4
+ from anipy_api.anime import Anime
5
+ from anipy_api.anilist import (
6
+ AniListAnime,
7
+ AniListMyListStatus,
8
+ AniListMyListStatusEnum,
9
+ AniList,
10
+ AniListAdapter,
11
+ )
12
+ from anipy_api.provider import LanguageTypeEnum, list_providers
13
+ from dataclasses_json import DataClassJsonMixin, config
14
+ from InquirerPy import inquirer
15
+
16
+ from anipy_cli.config import Config
17
+ from anipy_cli.util import error, get_prefered_providers
18
+
19
+
20
+ @dataclass
21
+ class ProviderMapping(DataClassJsonMixin):
22
+ provider: str = field(metadata=config(field_name="pv"))
23
+ name: str = field(metadata=config(field_name="na"))
24
+ identifier: str = field(metadata=config(field_name="id"))
25
+ languages: Set[LanguageTypeEnum] = field(metadata=config(field_name="la"))
26
+
27
+
28
+ @dataclass
29
+ class AniListProviderMapping(DataClassJsonMixin):
30
+ anilist_anime: AniListAnime
31
+ mappings: Dict[str, ProviderMapping]
32
+
33
+
34
+ @dataclass
35
+ class AniListLocalList(DataClassJsonMixin):
36
+ mappings: Dict[int, AniListProviderMapping]
37
+
38
+ def write(self, user_id: int):
39
+ config = Config()
40
+ local_list = config._anilist_local_user_list_path.with_stem(
41
+ f"{config._anilist_local_user_list_path.stem}_{user_id}"
42
+ )
43
+ local_list.write_text(self.to_json())
44
+
45
+ @staticmethod
46
+ def read(user_id: int) -> "AniListLocalList":
47
+ config = Config()
48
+ local_list = config._anilist_local_user_list_path.with_stem(
49
+ f"{config._anilist_local_user_list_path.stem}_{user_id}"
50
+ )
51
+
52
+ if not local_list.is_file():
53
+ local_list.parent.mkdir(exist_ok=True, parents=True)
54
+ local_list.touch()
55
+ mylist = AniListLocalList({})
56
+ mylist.write(user_id)
57
+ return mylist
58
+
59
+ try:
60
+ mylist: AniListLocalList = AniListLocalList.from_json(
61
+ local_list.read_text()
62
+ )
63
+ except KeyError:
64
+ choice = inquirer.confirm(
65
+ message=f"Your local AniList ({str(local_list)}) is not in the correct format, should it be deleted?",
66
+ default=False,
67
+ ).execute()
68
+ if choice:
69
+ local_list.unlink()
70
+ return AniListLocalList.read(user_id)
71
+ else:
72
+ error("could not read your AniList", fatal=True)
73
+
74
+ return mylist
75
+
76
+
77
+ class AniListProxy:
78
+ def __init__(self, anilist: AniList):
79
+ self.anilist = anilist
80
+ self.user_id = anilist.get_user().id
81
+ self.local_list = AniListLocalList.read(self.user_id)
82
+
83
+ def _cache_list(self, mylist: List[AniListAnime]):
84
+ config = Config()
85
+ for e in mylist:
86
+ if self.local_list.mappings.get(e.id, None):
87
+ if (
88
+ e.my_list_status
89
+ and config.tracker_ignore_tag in e.my_list_status.tags
90
+ ):
91
+ self.local_list.mappings.pop(e.id)
92
+ else:
93
+ self.local_list.mappings[e.id].anilist_anime = e
94
+ else:
95
+ self.local_list.mappings[e.id] = AniListProviderMapping(e, {})
96
+
97
+ self.local_list.write(self.user_id)
98
+
99
+ def _write_mapping(self, anilist_anime: AniListAnime, mapping: Anime):
100
+ self._cache_list([anilist_anime])
101
+
102
+ self.local_list.mappings[anilist_anime.id].mappings[
103
+ f"{mapping.provider.NAME}:{mapping.identifier}"
104
+ ] = ProviderMapping(
105
+ mapping.provider.NAME, mapping.name, mapping.identifier, mapping.languages
106
+ )
107
+
108
+ self.local_list.write(self.user_id)
109
+
110
+ def get_list(
111
+ self, status_catagories: Optional[Set[AniListMyListStatusEnum]] = None
112
+ ) -> List[AniListAnime]:
113
+ config = Config()
114
+ mylist: List[AniListAnime] = []
115
+
116
+ catagories = (
117
+ status_catagories
118
+ if status_catagories is not None
119
+ else set(
120
+ [
121
+ AniListMyListStatusEnum[s.upper()]
122
+ for s in config.tracker_status_categories
123
+ ]
124
+ )
125
+ )
126
+
127
+ for c in catagories:
128
+ mylist.extend(
129
+ filter(
130
+ lambda e: (
131
+ config.tracker_ignore_tag not in e.my_list_status.tags
132
+ if e.my_list_status
133
+ else True
134
+ ),
135
+ self.anilist.get_anime_list(c),
136
+ )
137
+ )
138
+
139
+ self._cache_list(mylist)
140
+ filtered_list = filter(
141
+ lambda x: (
142
+ x.my_list_status.status in catagories if x.my_list_status else False
143
+ ),
144
+ mylist,
145
+ )
146
+ return list(filtered_list)
147
+
148
+ def update_show(
149
+ self,
150
+ anime: AniListAnime,
151
+ status: Optional[AniListMyListStatusEnum] = None,
152
+ episode: Optional[int] = None,
153
+ tags: Set[str] = set(),
154
+ ) -> AniListMyListStatus:
155
+ config = Config()
156
+ tags |= set(config.tracker_tags)
157
+ result = self.anilist.update_anime_list(
158
+ anime.id, status=status, watched_episodes=episode, tags=list(tags)
159
+ )
160
+ anime.my_list_status = result
161
+ self._cache_list([anime])
162
+ return result
163
+
164
+ def delete_show(self, anime: AniListAnime) -> None:
165
+ self.local_list.mappings.pop(anime.id)
166
+ self.local_list.write(self.user_id)
167
+
168
+ self.anilist.remove_from_anime_list(anime.id)
169
+
170
+ def map_from_anilist(
171
+ self, anime: AniListAnime, mapping: Optional[Anime] = None
172
+ ) -> Optional[Anime]:
173
+ if mapping is not None:
174
+ self._write_mapping(anime, mapping)
175
+ return mapping
176
+
177
+ if self.local_list.mappings[anime.id].mappings:
178
+ for map in self.local_list.mappings[anime.id].mappings.values():
179
+ provider = next(
180
+ filter(lambda x: x.NAME == map.provider, list_providers()), None
181
+ )
182
+
183
+ if provider is None:
184
+ continue
185
+
186
+ return Anime(provider(), map.name, map.identifier, map.languages)
187
+
188
+ config = Config()
189
+ result = None
190
+ for p in get_prefered_providers("anilist"):
191
+ adapter = AniListAdapter(self.anilist, p)
192
+ result = adapter.from_anilist(
193
+ anime,
194
+ config.tracker_mapping_min_similarity,
195
+ config.tracker_mapping_use_filters,
196
+ config.tracker_mapping_use_alternatives,
197
+ )
198
+ if result is not None:
199
+ break
200
+
201
+ if result:
202
+ self._write_mapping(anime, result)
203
+
204
+ return result
205
+
206
+ def map_from_provider(
207
+ self, anime: Anime, mapping: Optional[AniListAnime] = None
208
+ ) -> Optional[AniListAnime]:
209
+ if mapping is not None:
210
+ self._write_mapping(mapping, anime)
211
+ return mapping
212
+
213
+ for _, m in self.local_list.mappings.items():
214
+ existing = m.mappings.get(f"{anime.provider.NAME}:{anime.identifier}", None)
215
+ if existing:
216
+ return m.anilist_anime
217
+
218
+ config = Config()
219
+ adapter = AniListAdapter(self.anilist, anime.provider)
220
+ result = adapter.from_provider(
221
+ anime,
222
+ config.tracker_mapping_min_similarity,
223
+ config.tracker_mapping_use_alternatives,
224
+ )
225
+
226
+ if result:
227
+ self._write_mapping(result, anime)
228
+
229
+ return result
anipy_cli/arg_parser.py CHANGED
@@ -13,18 +13,24 @@ class CliArgs:
13
13
  history: bool
14
14
  seasonal: bool
15
15
  mal: bool
16
+ anilist: bool
16
17
  delete: bool
17
18
  migrate_hist: bool
18
19
  quality: Optional[Union[str, int]]
19
20
  ffmpeg: bool
20
21
  auto_update: bool
21
22
  mal_sync_seasonals: bool
23
+ anilist_sync_seasonals: bool
22
24
  optional_player: Optional[str]
23
25
  search: Optional[str]
24
26
  location: Optional[Path]
27
+ verbosity: int
28
+ stack_always: bool
29
+ mal_user: Optional[str]
25
30
  mal_password: Optional[str]
26
31
  config: bool
27
32
  seasonal_search: Optional[str]
33
+ subtitles: bool
28
34
 
29
35
 
30
36
  def parse_args(override_args: Optional[list[str]] = None) -> CliArgs:
@@ -92,6 +98,15 @@ def parse_args(override_args: Optional[list[str]] = None) -> CliArgs:
92
98
  "(requires MAL account credentials to be set in config).",
93
99
  )
94
100
 
101
+ actions_group.add_argument(
102
+ "-A",
103
+ "--anilist",
104
+ required=False,
105
+ dest="anilist",
106
+ action="store_true",
107
+ help="Anilist mode. Similar to seasonal mode, but using Anilist",
108
+ )
109
+
95
110
  actions_group.add_argument(
96
111
  "--delete-history",
97
112
  required=False,
@@ -175,6 +190,33 @@ def parse_args(override_args: Optional[list[str]] = None) -> CliArgs:
175
190
  help="Override all configured download locations",
176
191
  )
177
192
 
193
+ options_group.add_argument(
194
+ "-V",
195
+ "--verbose",
196
+ required=False,
197
+ dest="verbosity",
198
+ action="count",
199
+ default=0,
200
+ help="Verbosity levels in the console: -V = 'fatal' -VV = 'warnings' -VVV = 'info'",
201
+ )
202
+
203
+ options_group.add_argument(
204
+ "--stack-always",
205
+ required=False,
206
+ dest="stack_always",
207
+ action="store_true",
208
+ help="Always show the stack trace on any log outputs.",
209
+ )
210
+
211
+ options_group.add_argument(
212
+ "-so",
213
+ "--sub-only",
214
+ required=False,
215
+ dest="subtitles",
216
+ action="store_true",
217
+ help="Download only subtitles",
218
+ )
219
+
178
220
  options_group.add_argument(
179
221
  "--mal-password",
180
222
  required=False,
@@ -182,6 +224,13 @@ def parse_args(override_args: Optional[list[str]] = None) -> CliArgs:
182
224
  action="store",
183
225
  help="Provide password for MAL login (overrides password set in config)",
184
226
  )
227
+ options_group.add_argument(
228
+ "--mal-user",
229
+ required=False,
230
+ dest="mal_user",
231
+ action="store",
232
+ help="Provide username for MAL login (overrides username set in config)",
233
+ )
185
234
 
186
235
  options_group.add_argument(
187
236
  "--mal-sync-to-seasonals",
@@ -191,6 +240,14 @@ def parse_args(override_args: Optional[list[str]] = None) -> CliArgs:
191
240
  help="Automatically sync myanimelist to seasonals (only works with `-M`)",
192
241
  )
193
242
 
243
+ options_group.add_argument(
244
+ "--anilist-sync-to-seasonals",
245
+ required=False,
246
+ dest="anilist_sync_seasonals",
247
+ action="store_true",
248
+ help="Automatically sync anilist to seasonals (only works with `-A`)",
249
+ )
250
+
194
251
  info_group.add_argument(
195
252
  "-h", "--help", action="help", help="show this help message and exit"
196
253
  )
anipy_cli/cli.py CHANGED
@@ -1,3 +1,5 @@
1
+ from pathlib import Path
2
+ from types import TracebackType
1
3
  from typing import Optional
2
4
 
3
5
  from pypresence.exceptions import DiscordNotFound
@@ -5,16 +7,36 @@ from pypresence.exceptions import DiscordNotFound
5
7
  from anipy_api.locallist import LocalList
6
8
  from anipy_cli.prompts import migrate_provider
7
9
 
8
- from anipy_cli.arg_parser import parse_args
10
+ from anipy_cli.arg_parser import CliArgs, parse_args
9
11
  from anipy_cli.clis import *
10
- from anipy_cli.colors import colors, cprint
12
+ from anipy_cli.colors import color, colors, cprint
11
13
  from anipy_cli.util import error, DotSpinner, migrate_locallist
12
14
  from anipy_cli.config import Config
13
15
  from anipy_cli.discord import DiscordPresence
16
+ import anipy_cli.logger as logger
14
17
 
15
18
 
16
19
  def run_cli(override_args: Optional[list[str]] = None):
17
20
  args = parse_args(override_args)
21
+
22
+ logger.set_cli_verbosity(args.verbosity)
23
+ logger.set_stack_always(args.stack_always)
24
+
25
+ def fatal_handler(
26
+ exc_val: BaseException, exc_tb: TracebackType, logs_location: Path
27
+ ):
28
+ print(
29
+ color(
30
+ colors.RED,
31
+ f'A fatal error of type [{exc_val.__class__.__name__}] has occurred with message "{exc_val.args[0]}". Logs can be found at {logs_location}.',
32
+ )
33
+ )
34
+
35
+ with logger.safe(fatal_handler):
36
+ _safe_cli(args)
37
+
38
+
39
+ def _safe_cli(args: CliArgs):
18
40
  config = Config()
19
41
  # This updates the config, adding new values doc changes and the like.
20
42
  config._create_config()
@@ -61,6 +83,7 @@ def run_cli(override_args: Optional[list[str]] = None):
61
83
  args.seasonal: SeasonalCli,
62
84
  args.history: HistoryCli,
63
85
  args.mal: MalCli,
86
+ args.anilist: AniListCli,
64
87
  }
65
88
 
66
89
  cli_class = clis_dict.get(True, DefaultCli)
@@ -4,11 +4,13 @@ from anipy_cli.clis.mal_cli import MalCli
4
4
  from anipy_cli.clis.seasonal_cli import SeasonalCli
5
5
  from anipy_cli.clis.binge_cli import BingeCli
6
6
  from anipy_cli.clis.download_cli import DownloadCli
7
+ from anipy_cli.clis.anilist_cli import AniListCli
7
8
 
8
9
  __all__ = [
9
10
  "DefaultCli",
10
11
  "HistoryCli",
11
12
  "MalCli",
13
+ "AniListCli",
12
14
  "SeasonalCli",
13
15
  "BingeCli",
14
16
  "DownloadCli",
@@ -0,0 +1,62 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from anipy_api.error import AniListError
4
+ from anipy_api.anilist import AniList
5
+ from InquirerPy import inquirer
6
+
7
+ from anipy_cli.clis.base_cli import CliBase
8
+ from anipy_cli.config import Config
9
+ from anipy_cli.menus import AniListMenu
10
+ from anipy_cli.util import DotSpinner, error
11
+ import webbrowser
12
+
13
+ if TYPE_CHECKING:
14
+ from anipy_cli.arg_parser import CliArgs
15
+
16
+
17
+ class AniListCli(CliBase):
18
+ def __init__(self, options: "CliArgs"):
19
+ super().__init__(options)
20
+ self.access_token = ""
21
+ self.anilist = None
22
+
23
+ def print_header(self):
24
+ pass
25
+
26
+ def take_input(self):
27
+ config = Config()
28
+ self.access_token = config.anilist_token
29
+
30
+ if not self.access_token:
31
+ webbrowser.open(AniList.AUTH_URL)
32
+ self.access_token = inquirer.text( # type: ignore
33
+ "Paste the token from the browser for Auth",
34
+ validate=lambda x: len(x) > 1,
35
+ invalid_message="You must enter a access_token!",
36
+ long_instruction="Hint: You can save your access token in the config!",
37
+ ).execute()
38
+
39
+ def process(self):
40
+ try:
41
+ with DotSpinner("Logging into AniList..."):
42
+ self.anilist = AniList.from_implicit_grant(self.access_token)
43
+ except AniListError as e:
44
+ error(
45
+ f"{str(e)}\nCannot login to AniList, it is likely your response token is wrong",
46
+ fatal=True,
47
+ )
48
+
49
+ def show(self):
50
+ pass
51
+
52
+ def post(self):
53
+ assert self.anilist is not None
54
+
55
+ menu = AniListMenu(anilist=self.anilist, options=self.options)
56
+
57
+ if self.options.auto_update:
58
+ menu.download()
59
+ elif self.options.anilist_sync_seasonals:
60
+ menu.sync_anilist_seasonls()
61
+ else:
62
+ menu.run()
@@ -30,5 +30,5 @@ class CliBase(ABC):
30
30
  func = getattr(self, f)
31
31
  ret = func()
32
32
 
33
- if ret == False: # noqa: E712
33
+ if ret is False:
34
34
  break
@@ -13,7 +13,7 @@ from anipy_cli.prompts import (
13
13
  lang_prompt,
14
14
  parse_auto_search,
15
15
  )
16
- from anipy_cli.util import DotSpinner, get_configured_player, migrate_locallist
16
+ from anipy_cli.util import DotSpinner, get_configured_player, migrate_locallist, error
17
17
 
18
18
  if TYPE_CHECKING:
19
19
  from anipy_cli.arg_parser import CliArgs
@@ -83,6 +83,9 @@ class BingeCli(CliBase):
83
83
  stream = self.anime.get_video(
84
84
  e, self.lang, preferred_quality=self.options.quality
85
85
  )
86
+ if stream is None:
87
+ error("Could not find stream for requested episode, skipping")
88
+ continue
86
89
  s.ok("✔")
87
90
 
88
91
  self.history_list.update(self.anime, episode=e, language=self.lang)
@@ -7,21 +7,18 @@ from anipy_cli.colors import colors
7
7
  from anipy_cli.config import Config
8
8
  from anipy_cli.menus import Menu
9
9
  from anipy_cli.prompts import (
10
- pick_episode_prompt,
11
- search_show_prompt,
12
10
  lang_prompt,
13
11
  parse_auto_search,
14
12
  parse_seasonal_search,
13
+ pick_episode_prompt,
14
+ search_show_prompt,
15
15
  )
16
- from anipy_cli.util import (
17
- DotSpinner,
18
- get_configured_player,
19
- migrate_locallist,
20
- )
16
+ from anipy_cli.util import DotSpinner, error, get_configured_player, migrate_locallist
21
17
 
22
18
  if TYPE_CHECKING:
23
19
  from anipy_api.anime import Anime
24
- from anipy_api.provider import Episode, ProviderStream, LanguageTypeEnum
20
+ from anipy_api.provider import Episode, LanguageTypeEnum, ProviderStream
21
+
25
22
  from anipy_cli.arg_parser import CliArgs
26
23
 
27
24
 
@@ -91,6 +88,11 @@ class DefaultCli(CliBase):
91
88
  self.stream = self.anime.get_video(
92
89
  self.epsiode, self.lang, preferred_quality=self.options.quality
93
90
  )
91
+ if not self.stream:
92
+ error(
93
+ f"Could not find any streams for {self.anime.name} ({self.lang}) Episode {self.epsiode}",
94
+ fatal=True,
95
+ )
94
96
 
95
97
  def show(self):
96
98
  assert self.anime is not None
@@ -69,8 +69,12 @@ class DownloadCli(CliBase):
69
69
  assert self.anime is not None
70
70
  assert self.lang is not None
71
71
 
72
- errors = DownloadComponent(self.options, self.dl_path, "download").download_anime(
73
- [(self.anime, self.lang, self.episodes)], only_skip_ep_on_err=True
72
+ errors = DownloadComponent(
73
+ self.options, self.dl_path, "download"
74
+ ).download_anime(
75
+ [(self.anime, self.lang, self.episodes)],
76
+ only_skip_ep_on_err=True,
77
+ sub_only=self.options.subtitles,
74
78
  )
75
79
  DownloadComponent.serve_download_errors(errors, only_skip_ep_on_err=True)
76
80
 
anipy_cli/clis/mal_cli.py CHANGED
@@ -25,7 +25,7 @@ class MalCli(CliBase):
25
25
 
26
26
  def take_input(self):
27
27
  config = Config()
28
- self.user = config.mal_user
28
+ self.user = self.options.mal_user or config.mal_user
29
29
  self.password = self.options.mal_password or config.mal_password
30
30
 
31
31
  if not self.user:
anipy_cli/colors.py CHANGED
@@ -1,4 +1,7 @@
1
- class colors:
1
+ from typing import Any
2
+
3
+
4
+ class colors: # noqa: N801
2
5
  """Just a class for colors."""
3
6
 
4
7
  GREEN = "\033[92m"
@@ -14,7 +17,7 @@ class colors:
14
17
  RESET = "\033[0m"
15
18
 
16
19
 
17
- def color(*values, sep: str = "") -> str:
20
+ def color(*values: Any, sep: str = "") -> str:
18
21
  """Decorate a string with color codes.
19
22
 
20
23
  Basically just ensures that the color doesn't "leak"
@@ -24,13 +27,13 @@ def color(*values, sep: str = "") -> str:
24
27
  return sep.join(map(str, values)) + colors.END
25
28
 
26
29
 
27
- def cinput(*prompt, input_color: str = "") -> str:
30
+ def cinput(*prompt: Any, input_color: str = "") -> str:
28
31
  """An input function that handles coloring input."""
29
32
  inp = input(color(*prompt) + input_color)
30
33
  print(colors.END, end="")
31
34
  return inp
32
35
 
33
36
 
34
- def cprint(*values, sep: str = "", **kwargs) -> None:
37
+ def cprint(*values: Any, sep: str = "", **kwargs: Any) -> None:
35
38
  """Prints colored text."""
36
39
  print(color(*values, sep=sep), **kwargs)