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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List, Protocol, Tuple
|
|
3
|
+
from anipy_cli.arg_parser import CliArgs
|
|
4
|
+
from anipy_cli.colors import color, colors
|
|
5
|
+
from anipy_cli.config import Config
|
|
6
|
+
from anipy_cli.util import DotSpinner, get_download_path, get_post_download_scripts_hook
|
|
7
|
+
import anipy_cli.logger as logger
|
|
8
|
+
|
|
9
|
+
from anipy_api.anime import Anime
|
|
10
|
+
from anipy_api.download import Downloader
|
|
11
|
+
from anipy_api.provider.base import Episode, LanguageTypeEnum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SuccessfulEpDownload(Protocol):
|
|
15
|
+
"""
|
|
16
|
+
Callback for when an episode successfully downloads
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __call__(self, anime: Anime, ep: Episode, lang: LanguageTypeEnum):
|
|
20
|
+
"""
|
|
21
|
+
Args:
|
|
22
|
+
anime: The relevant anime
|
|
23
|
+
ep: An int/float for the episode
|
|
24
|
+
lang: The language that downloaded
|
|
25
|
+
"""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DownloadComponent:
|
|
30
|
+
"""
|
|
31
|
+
A component used to download anime for
|
|
32
|
+
the ani-py CLI.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, options: CliArgs, dl_path: Path, mode: str) -> None:
|
|
36
|
+
self.options = options
|
|
37
|
+
self.dl_path = dl_path
|
|
38
|
+
self.mode = mode
|
|
39
|
+
|
|
40
|
+
def download_anime(
|
|
41
|
+
self,
|
|
42
|
+
picked: List[Tuple[Anime, LanguageTypeEnum, List[Episode]]],
|
|
43
|
+
after_success_ep: SuccessfulEpDownload = lambda anime, ep, lang: None,
|
|
44
|
+
only_skip_ep_on_err: bool = False,
|
|
45
|
+
sub_only: bool = False,
|
|
46
|
+
) -> List[Tuple[Anime, Episode]]:
|
|
47
|
+
"""
|
|
48
|
+
Attributes:
|
|
49
|
+
picked: The chosen animes to download
|
|
50
|
+
after_success_ep: The code to run when an anime successful downloads
|
|
51
|
+
only_skip_ep_on_err: If we should skip the specific episode on an error. If false, we skip the entire anime.
|
|
52
|
+
"""
|
|
53
|
+
with DotSpinner("Starting download...") as s:
|
|
54
|
+
|
|
55
|
+
def progress_indicator(percentage: float):
|
|
56
|
+
s.set_text(f"Progress: {percentage:.1f}%")
|
|
57
|
+
|
|
58
|
+
def info_display(message: str, exc_info: BaseException | None = None):
|
|
59
|
+
logger.info(message, exc_info, exc_info is not None)
|
|
60
|
+
s.write(f"> {message}")
|
|
61
|
+
|
|
62
|
+
def error_display(message: str, exc_info: BaseException | None = None):
|
|
63
|
+
logger.error(message, exc_info)
|
|
64
|
+
s.write(f"{colors.RED}! {message}{colors.END}")
|
|
65
|
+
|
|
66
|
+
downloader = Downloader(progress_indicator, info_display, error_display)
|
|
67
|
+
|
|
68
|
+
failed: List[Tuple[Anime, Episode]] = []
|
|
69
|
+
|
|
70
|
+
for anime, lang, eps in picked:
|
|
71
|
+
failed = self.download_episodes(
|
|
72
|
+
s,
|
|
73
|
+
downloader,
|
|
74
|
+
anime,
|
|
75
|
+
lang,
|
|
76
|
+
eps,
|
|
77
|
+
after_success_ep,
|
|
78
|
+
only_skip_ep_on_err,
|
|
79
|
+
sub_only,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return failed
|
|
83
|
+
|
|
84
|
+
def download_episodes(
|
|
85
|
+
self,
|
|
86
|
+
spinner: DotSpinner,
|
|
87
|
+
downloader: Downloader,
|
|
88
|
+
anime: Anime,
|
|
89
|
+
lang: LanguageTypeEnum,
|
|
90
|
+
eps: List[Episode],
|
|
91
|
+
after_success_ep: SuccessfulEpDownload = lambda anime, ep, lang: None,
|
|
92
|
+
only_skip_ep_on_err: bool = False,
|
|
93
|
+
sub_only: bool = False,
|
|
94
|
+
) -> List[Tuple[Anime, Episode]]:
|
|
95
|
+
fails = []
|
|
96
|
+
for ep in eps:
|
|
97
|
+
try:
|
|
98
|
+
self.download_ep(spinner, downloader, anime, lang, ep, sub_only)
|
|
99
|
+
except Exception as anime_download_error:
|
|
100
|
+
# Log it first so we don't run into another error below
|
|
101
|
+
logger.error(
|
|
102
|
+
f"Error downloading episode {ep} of {anime.name}. Skipped.",
|
|
103
|
+
anime_download_error,
|
|
104
|
+
)
|
|
105
|
+
if only_skip_ep_on_err:
|
|
106
|
+
error_msg = f"! Issues downloading episode {ep} of {anime.name}. Skipping..."
|
|
107
|
+
else:
|
|
108
|
+
error_msg = f"! Issues occurred while downloading the series ${anime.name}. Skipping..."
|
|
109
|
+
spinner.write(
|
|
110
|
+
color(
|
|
111
|
+
colors.RED,
|
|
112
|
+
f"! Error: {anime_download_error}\n",
|
|
113
|
+
error_msg,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
fails.append((anime, ep))
|
|
117
|
+
if only_skip_ep_on_err:
|
|
118
|
+
continue
|
|
119
|
+
return fails
|
|
120
|
+
|
|
121
|
+
after_success_ep(anime, ep, lang)
|
|
122
|
+
return fails
|
|
123
|
+
|
|
124
|
+
def download_ep(
|
|
125
|
+
self,
|
|
126
|
+
spinner: DotSpinner,
|
|
127
|
+
downloader: Downloader,
|
|
128
|
+
anime: Anime,
|
|
129
|
+
lang: LanguageTypeEnum,
|
|
130
|
+
ep: Episode,
|
|
131
|
+
sub_only: bool = False,
|
|
132
|
+
):
|
|
133
|
+
config = Config()
|
|
134
|
+
|
|
135
|
+
spinner.set_text(
|
|
136
|
+
"Extracting streams for ",
|
|
137
|
+
colors.BLUE,
|
|
138
|
+
f"{anime.name} ({lang})",
|
|
139
|
+
colors.END,
|
|
140
|
+
" Episode ",
|
|
141
|
+
ep,
|
|
142
|
+
"...",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
stream = anime.get_video(ep, lang, preferred_quality=self.options.quality)
|
|
146
|
+
|
|
147
|
+
if stream is None:
|
|
148
|
+
logger.info("Could not find a stream")
|
|
149
|
+
DownloadComponent.serve_download_errors(
|
|
150
|
+
[(anime, ep)], only_skip_ep_on_err=True
|
|
151
|
+
)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
download_message_update = (
|
|
155
|
+
f"Downloading Episode {stream.episode} of {anime.name} ({lang})"
|
|
156
|
+
)
|
|
157
|
+
logger.info(download_message_update)
|
|
158
|
+
spinner.write(f"> {download_message_update}")
|
|
159
|
+
|
|
160
|
+
spinner.set_text("Downloading...")
|
|
161
|
+
|
|
162
|
+
if not sub_only:
|
|
163
|
+
downloader.download(
|
|
164
|
+
stream,
|
|
165
|
+
get_download_path(anime, stream, parent_directory=self.dl_path),
|
|
166
|
+
container=config.remux_to,
|
|
167
|
+
ffmpeg=self.options.ffmpeg or config.ffmpeg_hls,
|
|
168
|
+
post_dl_cb=get_post_download_scripts_hook(self.mode, anime, spinner),
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
downloader.download_sub(
|
|
172
|
+
stream, get_download_path(anime, stream, parent_directory=self.dl_path)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def serve_download_errors(
|
|
177
|
+
failures: List[Tuple[Anime, Episode]],
|
|
178
|
+
only_skip_ep_on_err: bool = False,
|
|
179
|
+
):
|
|
180
|
+
if not failures:
|
|
181
|
+
return
|
|
182
|
+
text = ", ".join([f"\n\tEpisode {i[1]} of {i[0].name}" for i in failures])
|
|
183
|
+
extra_info = (
|
|
184
|
+
" (and the remaining episodes in that series)"
|
|
185
|
+
if not only_skip_ep_on_err
|
|
186
|
+
else ""
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
print(
|
|
190
|
+
color(
|
|
191
|
+
colors.RED,
|
|
192
|
+
f"! Unable to download the following episodes{extra_info}: {text}",
|
|
193
|
+
)
|
|
194
|
+
)
|
anipy_cli/logger.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import logging.handlers
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
import datetime
|
|
10
|
+
|
|
11
|
+
from anipy_cli.config import Config
|
|
12
|
+
from anipy_cli import __appname__
|
|
13
|
+
from appdirs import user_data_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FatalHandler(Protocol):
|
|
17
|
+
def __call__(
|
|
18
|
+
self, exc_val: BaseException, exc_tb: TracebackType, logs_location: Path
|
|
19
|
+
): ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FatalCatcher:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
logs_location: Path,
|
|
26
|
+
fatal_handler: FatalHandler | None = None,
|
|
27
|
+
ignore_system_exit: bool = True,
|
|
28
|
+
):
|
|
29
|
+
self._fatal_handler = fatal_handler
|
|
30
|
+
|
|
31
|
+
self.logs_location = logs_location
|
|
32
|
+
self.ignore_system_exit = ignore_system_exit
|
|
33
|
+
|
|
34
|
+
def __enter__(self):
|
|
35
|
+
info("Initializing program...")
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(
|
|
39
|
+
self,
|
|
40
|
+
exc_type: type[BaseException] | None,
|
|
41
|
+
exc_val: BaseException | None,
|
|
42
|
+
exc_tb: TracebackType | None,
|
|
43
|
+
):
|
|
44
|
+
if (not exc_type) or (not exc_val) or (not exc_tb):
|
|
45
|
+
info("Program exited successfully...")
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
if exc_type is SystemExit and self.ignore_system_exit:
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
# Attempt to let a handler know something is up
|
|
53
|
+
# so it can get to the user
|
|
54
|
+
if self._fatal_handler:
|
|
55
|
+
self._fatal_handler(exc_val, exc_tb, self.logs_location)
|
|
56
|
+
except Exception:
|
|
57
|
+
# If that fails, at least get something to the user
|
|
58
|
+
sys.stderr.write("An extra fatal error occurred...")
|
|
59
|
+
|
|
60
|
+
fatal(f"A fatal error has occurred - {','.join(exc_val.args)}", exc_val)
|
|
61
|
+
info("Program exited with fatal errors...")
|
|
62
|
+
|
|
63
|
+
return True # Return true because we have processed the error
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
LOGGER_NAME = "cli_logger"
|
|
67
|
+
MAX_LOGS = 5
|
|
68
|
+
DEFAULT_FILE_LOG_LEVEL = 10
|
|
69
|
+
DEFAULT_CONSOLE_LOG_LEVEL = 60
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_logs_location():
|
|
73
|
+
user_file_path = Path()
|
|
74
|
+
try:
|
|
75
|
+
user_file_path = Config().user_files_path
|
|
76
|
+
except Exception:
|
|
77
|
+
user_file_path = Path(user_data_dir(__appname__, appauthor=False))
|
|
78
|
+
finally:
|
|
79
|
+
return user_file_path / "logs"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_logger = logging.getLogger(LOGGER_NAME)
|
|
83
|
+
|
|
84
|
+
_logger.setLevel(10)
|
|
85
|
+
|
|
86
|
+
file_formatter = logging.Formatter(
|
|
87
|
+
"{asctime} - {levelname} - {message}", style="{", datefmt=r"%Y-%m-%d %H:%M:%S"
|
|
88
|
+
)
|
|
89
|
+
console_formatter = logging.Formatter("{levelname} -> {message}", style="{")
|
|
90
|
+
|
|
91
|
+
console_handler = logging.StreamHandler()
|
|
92
|
+
console_handler.setFormatter(console_formatter)
|
|
93
|
+
console_handler.setLevel(DEFAULT_CONSOLE_LOG_LEVEL)
|
|
94
|
+
_logger.addHandler(console_handler)
|
|
95
|
+
|
|
96
|
+
log_dir = get_logs_location()
|
|
97
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
current_time = datetime.datetime.now()
|
|
100
|
+
file_handler = logging.handlers.RotatingFileHandler(
|
|
101
|
+
get_logs_location() / f"{current_time.isoformat().replace(':', '.')}.log",
|
|
102
|
+
backupCount=5,
|
|
103
|
+
encoding="utf-8",
|
|
104
|
+
)
|
|
105
|
+
file_handler.setFormatter(file_formatter)
|
|
106
|
+
file_handler.setLevel(DEFAULT_FILE_LOG_LEVEL)
|
|
107
|
+
_logger.addHandler(file_handler)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_console_log_level():
|
|
111
|
+
return console_handler.level
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def set_console_log_level(value: logging._Level):
|
|
115
|
+
console_handler.setLevel(value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_file_log_level():
|
|
119
|
+
return file_handler.level
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def set_file_log_level(value: logging._Level):
|
|
123
|
+
file_handler.setLevel(value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def set_cli_verbosity(level: int):
|
|
127
|
+
"""
|
|
128
|
+
Set how extreme the error has to
|
|
129
|
+
be for it to be printed in the CLI.
|
|
130
|
+
|
|
131
|
+
Default is 0.
|
|
132
|
+
|
|
133
|
+
0 = No Statements To CLI
|
|
134
|
+
1 = Fatal
|
|
135
|
+
2 = Warnings
|
|
136
|
+
3 = Info
|
|
137
|
+
"""
|
|
138
|
+
level_conversion = {
|
|
139
|
+
0: 60,
|
|
140
|
+
1: 50,
|
|
141
|
+
2: 30,
|
|
142
|
+
3: 20,
|
|
143
|
+
}
|
|
144
|
+
other = 10 # If anything else, default to debug.
|
|
145
|
+
console_handler.setLevel(level_conversion.get(level, other))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def safe(fatal_handler: FatalHandler | None = None):
|
|
149
|
+
return FatalCatcher(get_logs_location(), fatal_handler)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_stack_always = False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def set_stack_always(value: bool):
|
|
156
|
+
global _stack_always
|
|
157
|
+
|
|
158
|
+
_stack_always = value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_stack_always(passthrough: bool):
|
|
162
|
+
"""
|
|
163
|
+
If _stack_always is true, return true.
|
|
164
|
+
|
|
165
|
+
Otherwise return passthrough.
|
|
166
|
+
"""
|
|
167
|
+
return True if _stack_always else passthrough
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def debug(
|
|
171
|
+
content: str, exc_info: logging._ExcInfoType = None, stack_info: bool = False
|
|
172
|
+
):
|
|
173
|
+
_logger.debug(content, exc_info=exc_info, stack_info=is_stack_always(stack_info))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def info(content: str, exc_info: logging._ExcInfoType = None, stack_info: bool = False):
|
|
177
|
+
_logger.info(content, exc_info=exc_info, stack_info=is_stack_always(stack_info))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def warn(content: str, exc_info: logging._ExcInfoType = None, stack_info: bool = False):
|
|
181
|
+
_logger.warning(content, exc_info=exc_info, stack_info=is_stack_always(stack_info))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def error(content: str, exc_info: logging._ExcInfoType = None):
|
|
185
|
+
_logger.error(content, exc_info=exc_info, stack_info=True)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def fatal(content: str, exc_info: logging._ExcInfoType = None):
|
|
189
|
+
_logger.critical(content, exc_info=exc_info, stack_info=True)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def log(
|
|
193
|
+
level: int,
|
|
194
|
+
content: str,
|
|
195
|
+
exc_info: logging._ExcInfoType = None,
|
|
196
|
+
stack_info: bool = False,
|
|
197
|
+
):
|
|
198
|
+
_logger.log(
|
|
199
|
+
level, content, exc_info=exc_info, stack_info=is_stack_always(stack_info)
|
|
200
|
+
)
|
anipy_cli/mal_proxy.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
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.mal import (
|
|
6
|
+
MALAnime,
|
|
7
|
+
MALMyListStatus,
|
|
8
|
+
MALMyListStatusEnum,
|
|
9
|
+
MyAnimeList,
|
|
10
|
+
MyAnimeListAdapter,
|
|
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 MALProviderMapping(DataClassJsonMixin):
|
|
30
|
+
mal_anime: MALAnime
|
|
31
|
+
mappings: Dict[str, ProviderMapping]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class MALLocalList(DataClassJsonMixin):
|
|
36
|
+
mappings: Dict[int, MALProviderMapping]
|
|
37
|
+
|
|
38
|
+
def write(self, user_id: int):
|
|
39
|
+
config = Config()
|
|
40
|
+
local_list = config._mal_local_user_list_path.with_stem(
|
|
41
|
+
f"{config._mal_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) -> "MALLocalList":
|
|
47
|
+
config = Config()
|
|
48
|
+
local_list = config._mal_local_user_list_path.with_stem(
|
|
49
|
+
f"{config._mal_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 = MALLocalList({})
|
|
56
|
+
mylist.write(user_id)
|
|
57
|
+
return mylist
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
mylist: MALLocalList = MALLocalList.from_json(local_list.read_text())
|
|
61
|
+
except KeyError:
|
|
62
|
+
choice = inquirer.confirm(
|
|
63
|
+
message=f"Your local MyAnimeList ({str(local_list)}) is not in the correct format, should it be deleted?",
|
|
64
|
+
default=False,
|
|
65
|
+
).execute()
|
|
66
|
+
if choice:
|
|
67
|
+
local_list.unlink()
|
|
68
|
+
return MALLocalList.read(user_id)
|
|
69
|
+
else:
|
|
70
|
+
error("could not read your MyAnimeList", fatal=True)
|
|
71
|
+
|
|
72
|
+
return mylist
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MyAnimeListProxy:
|
|
76
|
+
def __init__(self, mal: MyAnimeList):
|
|
77
|
+
self.mal = mal
|
|
78
|
+
self.user_id = mal.get_user().id
|
|
79
|
+
self.local_list = MALLocalList.read(self.user_id)
|
|
80
|
+
|
|
81
|
+
def _cache_list(self, mylist: List[MALAnime]):
|
|
82
|
+
config = Config()
|
|
83
|
+
for e in mylist:
|
|
84
|
+
if self.local_list.mappings.get(e.id, None):
|
|
85
|
+
if (
|
|
86
|
+
e.my_list_status
|
|
87
|
+
and config.tracker_ignore_tag in e.my_list_status.tags
|
|
88
|
+
):
|
|
89
|
+
self.local_list.mappings.pop(e.id)
|
|
90
|
+
else:
|
|
91
|
+
self.local_list.mappings[e.id].mal_anime = e
|
|
92
|
+
else:
|
|
93
|
+
self.local_list.mappings[e.id] = MALProviderMapping(e, {})
|
|
94
|
+
|
|
95
|
+
self.local_list.write(self.user_id)
|
|
96
|
+
|
|
97
|
+
def _write_mapping(self, mal_anime: MALAnime, mapping: Anime):
|
|
98
|
+
self._cache_list([mal_anime])
|
|
99
|
+
|
|
100
|
+
self.local_list.mappings[mal_anime.id].mappings[
|
|
101
|
+
f"{mapping.provider.NAME}:{mapping.identifier}"
|
|
102
|
+
] = ProviderMapping(
|
|
103
|
+
mapping.provider.NAME, mapping.name, mapping.identifier, mapping.languages
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self.local_list.write(self.user_id)
|
|
107
|
+
|
|
108
|
+
def get_list(
|
|
109
|
+
self, status_catagories: Optional[Set[MALMyListStatusEnum]] = None
|
|
110
|
+
) -> List[MALAnime]:
|
|
111
|
+
config = Config()
|
|
112
|
+
mylist: List[MALAnime] = []
|
|
113
|
+
|
|
114
|
+
catagories = (
|
|
115
|
+
status_catagories
|
|
116
|
+
if status_catagories is not None
|
|
117
|
+
else set(
|
|
118
|
+
[
|
|
119
|
+
MALMyListStatusEnum[s.upper()]
|
|
120
|
+
for s in config.tracker_status_categories
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
for c in catagories:
|
|
126
|
+
mylist.extend(
|
|
127
|
+
filter(
|
|
128
|
+
lambda e: (
|
|
129
|
+
config.tracker_ignore_tag not in e.my_list_status.tags
|
|
130
|
+
if e.my_list_status
|
|
131
|
+
else True
|
|
132
|
+
),
|
|
133
|
+
self.mal.get_anime_list(c),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
self._cache_list(mylist)
|
|
138
|
+
filtered_list = filter(
|
|
139
|
+
lambda x: (
|
|
140
|
+
x.my_list_status.status in catagories if x.my_list_status else False
|
|
141
|
+
),
|
|
142
|
+
mylist,
|
|
143
|
+
)
|
|
144
|
+
return list(filtered_list)
|
|
145
|
+
|
|
146
|
+
def update_show(
|
|
147
|
+
self,
|
|
148
|
+
anime: MALAnime,
|
|
149
|
+
status: Optional[MALMyListStatusEnum] = None,
|
|
150
|
+
episode: Optional[int] = None,
|
|
151
|
+
tags: Set[str] = set(),
|
|
152
|
+
) -> MALMyListStatus:
|
|
153
|
+
config = Config()
|
|
154
|
+
tags |= set(config.tracker_tags)
|
|
155
|
+
result = self.mal.update_anime_list(
|
|
156
|
+
anime.id, status=status, watched_episodes=episode, tags=list(tags)
|
|
157
|
+
)
|
|
158
|
+
anime.my_list_status = result
|
|
159
|
+
self._cache_list([anime])
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
def delete_show(self, anime: MALAnime) -> None:
|
|
163
|
+
self.local_list.mappings.pop(anime.id)
|
|
164
|
+
self.local_list.write(self.user_id)
|
|
165
|
+
|
|
166
|
+
self.mal.remove_from_anime_list(anime.id)
|
|
167
|
+
|
|
168
|
+
def map_from_mal(
|
|
169
|
+
self, anime: MALAnime, mapping: Optional[Anime] = None
|
|
170
|
+
) -> Optional[Anime]:
|
|
171
|
+
if mapping is not None:
|
|
172
|
+
self._write_mapping(anime, mapping)
|
|
173
|
+
return mapping
|
|
174
|
+
|
|
175
|
+
if self.local_list.mappings[anime.id].mappings:
|
|
176
|
+
for map in self.local_list.mappings[anime.id].mappings.values():
|
|
177
|
+
provider = next(
|
|
178
|
+
filter(lambda x: x.NAME == map.provider, list_providers()), None
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if provider is None:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
return Anime(provider(), map.name, map.identifier, map.languages)
|
|
185
|
+
|
|
186
|
+
config = Config()
|
|
187
|
+
result = None
|
|
188
|
+
for p in get_prefered_providers("mal"):
|
|
189
|
+
adapter = MyAnimeListAdapter(self.mal, p)
|
|
190
|
+
result = adapter.from_myanimelist(
|
|
191
|
+
anime,
|
|
192
|
+
config.tracker_mapping_min_similarity,
|
|
193
|
+
config.tracker_mapping_use_filters,
|
|
194
|
+
config.tracker_mapping_use_alternatives,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if result is not None:
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
if result:
|
|
201
|
+
self._write_mapping(anime, result)
|
|
202
|
+
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
def map_from_provider(
|
|
206
|
+
self, anime: Anime, mapping: Optional[MALAnime] = None
|
|
207
|
+
) -> Optional[MALAnime]:
|
|
208
|
+
if mapping is not None:
|
|
209
|
+
self._write_mapping(mapping, anime)
|
|
210
|
+
return mapping
|
|
211
|
+
|
|
212
|
+
for _, m in self.local_list.mappings.items():
|
|
213
|
+
existing = m.mappings.get(f"{anime.provider.NAME}:{anime.identifier}", None)
|
|
214
|
+
if existing:
|
|
215
|
+
return m.mal_anime
|
|
216
|
+
|
|
217
|
+
config = Config()
|
|
218
|
+
adapter = MyAnimeListAdapter(self.mal, anime.provider)
|
|
219
|
+
result = adapter.from_provider(
|
|
220
|
+
anime,
|
|
221
|
+
config.tracker_mapping_min_similarity,
|
|
222
|
+
config.tracker_mapping_use_alternatives,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if result:
|
|
226
|
+
self._write_mapping(result, anime)
|
|
227
|
+
|
|
228
|
+
return result
|