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/__init__.py
CHANGED
|
@@ -1,20 +1,2 @@
|
|
|
1
|
-
""
|
|
2
|
-
|
|
3
|
-
██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
|
4
|
-
███████ ██ ██ ██ ██ ██████ ████ █████ ██ ██ ██
|
|
5
|
-
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
|
6
|
-
██ ██ ██ ████ ██ ██ ██ ██████ ███████ ██
|
|
7
|
-
|
|
8
|
-
~ The best tool to watch and Download your favourite anime.
|
|
9
|
-
|
|
10
|
-
https://github.com/sdaqo/anipy-cli
|
|
11
|
-
|
|
12
|
-
"""
|
|
13
|
-
from anipy_cli.download import download
|
|
14
|
-
from anipy_cli.url_handler import epHandler, videourl
|
|
15
|
-
from anipy_cli import config
|
|
16
|
-
from anipy_cli.query import query
|
|
17
|
-
from anipy_cli.player import get_player
|
|
18
|
-
from anipy_cli.misc import Entry, get_anime_info
|
|
19
|
-
from anipy_cli.seasonal import Seasonal
|
|
20
|
-
from anipy_cli.history import history
|
|
1
|
+
__appname__ = "anipy-cli"
|
|
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
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
import argparse
|
|
2
|
-
from pathlib import Path
|
|
3
2
|
from dataclasses import dataclass
|
|
4
|
-
from
|
|
5
|
-
from
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Union
|
|
6
5
|
|
|
6
|
+
from anipy_cli import __version__
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
@dataclass()
|
|
9
10
|
class CliArgs:
|
|
10
11
|
download: bool
|
|
11
12
|
binge: bool
|
|
12
13
|
history: bool
|
|
13
14
|
seasonal: bool
|
|
14
15
|
mal: bool
|
|
16
|
+
anilist: bool
|
|
15
17
|
delete: bool
|
|
18
|
+
migrate_hist: bool
|
|
16
19
|
quality: Optional[Union[str, int]]
|
|
17
20
|
ffmpeg: bool
|
|
18
|
-
no_season_search: bool
|
|
19
21
|
auto_update: bool
|
|
22
|
+
mal_sync_seasonals: bool
|
|
23
|
+
anilist_sync_seasonals: bool
|
|
20
24
|
optional_player: Optional[str]
|
|
25
|
+
search: Optional[str]
|
|
21
26
|
location: Optional[Path]
|
|
27
|
+
verbosity: int
|
|
28
|
+
stack_always: bool
|
|
29
|
+
mal_user: Optional[str]
|
|
22
30
|
mal_password: Optional[str]
|
|
23
31
|
config: bool
|
|
32
|
+
seasonal_search: Optional[str]
|
|
33
|
+
subtitles: bool
|
|
24
34
|
|
|
25
35
|
|
|
26
|
-
def parse_args() -> CliArgs:
|
|
36
|
+
def parse_args(override_args: Optional[list[str]] = None) -> CliArgs:
|
|
27
37
|
parser = argparse.ArgumentParser(
|
|
28
|
-
description="Play Animes from
|
|
38
|
+
description="Play Animes from online anime providers locally or download them, and much more.",
|
|
29
39
|
add_help=False,
|
|
30
40
|
)
|
|
31
41
|
|
|
@@ -88,6 +98,15 @@ def parse_args() -> CliArgs:
|
|
|
88
98
|
"(requires MAL account credentials to be set in config).",
|
|
89
99
|
)
|
|
90
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
|
+
|
|
91
110
|
actions_group.add_argument(
|
|
92
111
|
"--delete-history",
|
|
93
112
|
required=False,
|
|
@@ -96,12 +115,41 @@ def parse_args() -> CliArgs:
|
|
|
96
115
|
help="Delete your History.",
|
|
97
116
|
)
|
|
98
117
|
|
|
118
|
+
actions_group.add_argument(
|
|
119
|
+
"--migrate-history",
|
|
120
|
+
required=False,
|
|
121
|
+
dest="migrate_hist",
|
|
122
|
+
action="store_true",
|
|
123
|
+
help="Migrate your history to the current provider.",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
options_group.add_argument(
|
|
127
|
+
"-s",
|
|
128
|
+
"--search",
|
|
129
|
+
required=False,
|
|
130
|
+
dest="search",
|
|
131
|
+
action="store",
|
|
132
|
+
help="Provide a search term to Default, Download or Binge mode in this format: {query}:{episode range}:{dub/sub}. Examples: 'frieren:1-10:sub' or 'frieren:1:sub' or 'frieren:1-3 7-12:dub', this argument may be appended to any of the modes mentioned like so: 'anipy-cli (-D/B) -s <search>'",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
options_group.add_argument(
|
|
136
|
+
"-ss",
|
|
137
|
+
"--seasonal-search",
|
|
138
|
+
required=False,
|
|
139
|
+
dest="seasonal_search",
|
|
140
|
+
nargs="?", # 1 or none possible args
|
|
141
|
+
default=None, # Used if flag is not present (added this line for clarity, because default is always None)
|
|
142
|
+
const=True, # Used if flag is present, but no value
|
|
143
|
+
help="Provide search parameters for seasons to Default, Download, or Binge mode in this format: {year}:{season}. You can only use part of the season name if you wish. Examples: '2024:win' or '2020:fa'",
|
|
144
|
+
)
|
|
145
|
+
|
|
99
146
|
options_group.add_argument(
|
|
100
147
|
"-q",
|
|
101
148
|
"--quality",
|
|
102
149
|
action="store",
|
|
103
150
|
required=False,
|
|
104
|
-
default="
|
|
151
|
+
default="best",
|
|
152
|
+
type=lambda v: int(v) if v.isdigit() else v,
|
|
105
153
|
help="Change the quality of the video, accepts: best, worst or 360, 480, 720 etc. Default: best",
|
|
106
154
|
)
|
|
107
155
|
|
|
@@ -114,30 +162,20 @@ def parse_args() -> CliArgs:
|
|
|
114
162
|
help="Use ffmpeg to download m3u8 playlists, may be more stable but is way slower than internal downloader",
|
|
115
163
|
)
|
|
116
164
|
|
|
117
|
-
options_group.add_argument(
|
|
118
|
-
"-o",
|
|
119
|
-
"--no-seas-search",
|
|
120
|
-
required=False,
|
|
121
|
-
dest="no_season_search",
|
|
122
|
-
action="store_true",
|
|
123
|
-
help="Turn off search in season. "
|
|
124
|
-
"Disables prompting if GoGoAnime is to be searched for anime in specific season.",
|
|
125
|
-
)
|
|
126
|
-
|
|
127
165
|
options_group.add_argument(
|
|
128
166
|
"-a",
|
|
129
167
|
"--auto-update",
|
|
130
168
|
required=False,
|
|
131
169
|
dest="auto_update",
|
|
132
170
|
action="store_true",
|
|
133
|
-
help="Automatically update and download all Anime in seasonals
|
|
171
|
+
help="Automatically update and download all Anime in seasonals or mal mode from start EP to newest.",
|
|
134
172
|
)
|
|
135
173
|
|
|
136
174
|
options_group.add_argument(
|
|
137
175
|
"-p",
|
|
138
176
|
"--optional-player",
|
|
139
177
|
required=False,
|
|
140
|
-
choices=["mpv", "vlc", "syncplay", "mpvnet"],
|
|
178
|
+
choices=["mpv", "vlc", "iina", "syncplay", "mpvnet", "mpv-controlled"],
|
|
141
179
|
help="Override the player set in the config.",
|
|
142
180
|
)
|
|
143
181
|
|
|
@@ -152,6 +190,33 @@ def parse_args() -> CliArgs:
|
|
|
152
190
|
help="Override all configured download locations",
|
|
153
191
|
)
|
|
154
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
|
+
|
|
155
220
|
options_group.add_argument(
|
|
156
221
|
"--mal-password",
|
|
157
222
|
required=False,
|
|
@@ -159,6 +224,29 @@ def parse_args() -> CliArgs:
|
|
|
159
224
|
action="store",
|
|
160
225
|
help="Provide password for MAL login (overrides password set in config)",
|
|
161
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
|
+
)
|
|
234
|
+
|
|
235
|
+
options_group.add_argument(
|
|
236
|
+
"--mal-sync-to-seasonals",
|
|
237
|
+
required=False,
|
|
238
|
+
dest="mal_sync_seasonals",
|
|
239
|
+
action="store_true",
|
|
240
|
+
help="Automatically sync myanimelist to seasonals (only works with `-M`)",
|
|
241
|
+
)
|
|
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
|
+
)
|
|
162
250
|
|
|
163
251
|
info_group.add_argument(
|
|
164
252
|
"-h", "--help", action="help", help="show this help message and exit"
|
|
@@ -174,4 +262,4 @@ def parse_args() -> CliArgs:
|
|
|
174
262
|
help="Print path to the config file.",
|
|
175
263
|
)
|
|
176
264
|
|
|
177
|
-
return CliArgs(**vars(parser.parse_args()))
|
|
265
|
+
return CliArgs(**vars(parser.parse_args(args=override_args)))
|
anipy_cli/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from types import TracebackType
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pypresence.exceptions import DiscordNotFound
|
|
6
|
+
|
|
7
|
+
from anipy_api.locallist import LocalList
|
|
8
|
+
from anipy_cli.prompts import migrate_provider
|
|
9
|
+
|
|
10
|
+
from anipy_cli.arg_parser import CliArgs, parse_args
|
|
11
|
+
from anipy_cli.clis import *
|
|
12
|
+
from anipy_cli.colors import color, colors, cprint
|
|
13
|
+
from anipy_cli.util import error, DotSpinner, migrate_locallist
|
|
14
|
+
from anipy_cli.config import Config
|
|
15
|
+
from anipy_cli.discord import DiscordPresence
|
|
16
|
+
import anipy_cli.logger as logger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_cli(override_args: Optional[list[str]] = None):
|
|
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):
|
|
40
|
+
config = Config()
|
|
41
|
+
# This updates the config, adding new values doc changes and the like.
|
|
42
|
+
config._create_config()
|
|
43
|
+
|
|
44
|
+
if config.dc_presence:
|
|
45
|
+
with DotSpinner("Intializing Discord Presence...") as s:
|
|
46
|
+
try:
|
|
47
|
+
DiscordPresence()
|
|
48
|
+
s.set_text(colors.GREEN, "Initialized Discord Presence")
|
|
49
|
+
s.ok("✔")
|
|
50
|
+
except DiscordNotFound:
|
|
51
|
+
s.set_text(
|
|
52
|
+
colors.RED,
|
|
53
|
+
"Discord is not opened, can't initialize Discord Presence",
|
|
54
|
+
)
|
|
55
|
+
s.fail("✘")
|
|
56
|
+
except ConnectionError:
|
|
57
|
+
s.set_text(
|
|
58
|
+
colors.RED,
|
|
59
|
+
"Can't Connect to discord, can't initialize Discord Presence",
|
|
60
|
+
)
|
|
61
|
+
s.fail("✘")
|
|
62
|
+
|
|
63
|
+
if args.config:
|
|
64
|
+
print(config._config_file)
|
|
65
|
+
return
|
|
66
|
+
elif args.delete:
|
|
67
|
+
try:
|
|
68
|
+
config._history_file_path.unlink()
|
|
69
|
+
cprint(colors.RED, "Done")
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
error("no history file found")
|
|
72
|
+
return
|
|
73
|
+
elif args.migrate_hist:
|
|
74
|
+
history_list = LocalList(
|
|
75
|
+
Config()._history_file_path, migrate_cb=migrate_locallist
|
|
76
|
+
)
|
|
77
|
+
migrate_provider("default", history_list)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
clis_dict = {
|
|
81
|
+
args.download: DownloadCli,
|
|
82
|
+
args.binge: BingeCli,
|
|
83
|
+
args.seasonal: SeasonalCli,
|
|
84
|
+
args.history: HistoryCli,
|
|
85
|
+
args.mal: MalCli,
|
|
86
|
+
args.anilist: AniListCli,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
cli_class = clis_dict.get(True, DefaultCli)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
cli_class(options=args).run()
|
|
93
|
+
except KeyboardInterrupt:
|
|
94
|
+
error("interrupted", fatal=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
run_cli()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from anipy_cli.clis.default_cli import DefaultCli
|
|
2
|
+
from anipy_cli.clis.history_cli import HistoryCli
|
|
3
|
+
from anipy_cli.clis.mal_cli import MalCli
|
|
4
|
+
from anipy_cli.clis.seasonal_cli import SeasonalCli
|
|
5
|
+
from anipy_cli.clis.binge_cli import BingeCli
|
|
6
|
+
from anipy_cli.clis.download_cli import DownloadCli
|
|
7
|
+
from anipy_cli.clis.anilist_cli import AniListCli
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"DefaultCli",
|
|
11
|
+
"HistoryCli",
|
|
12
|
+
"MalCli",
|
|
13
|
+
"AniListCli",
|
|
14
|
+
"SeasonalCli",
|
|
15
|
+
"BingeCli",
|
|
16
|
+
"DownloadCli",
|
|
17
|
+
]
|
|
@@ -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()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import TYPE_CHECKING, Optional
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from anipy_cli.arg_parser import CliArgs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CliBase(ABC):
|
|
9
|
+
def __init__(self, options: "CliArgs"):
|
|
10
|
+
self.options = options
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def print_header(self) -> Optional[bool]: ...
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def take_input(self) -> Optional[bool]: ...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def process(self) -> Optional[bool]: ...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def show(self) -> Optional[bool]: ...
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def post(self) -> Optional[bool]: ...
|
|
26
|
+
|
|
27
|
+
def run(self):
|
|
28
|
+
funcs = ["print_header", "take_input", "process", "show", "post"]
|
|
29
|
+
for f in funcs:
|
|
30
|
+
func = getattr(self, f)
|
|
31
|
+
ret = func()
|
|
32
|
+
|
|
33
|
+
if ret is False:
|
|
34
|
+
break
|