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.
Files changed (67) hide show
  1. anipy_cli/__init__.py +2 -20
  2. anipy_cli/anilist_proxy.py +229 -0
  3. anipy_cli/arg_parser.py +109 -21
  4. anipy_cli/cli.py +98 -0
  5. anipy_cli/clis/__init__.py +17 -0
  6. anipy_cli/clis/anilist_cli.py +62 -0
  7. anipy_cli/clis/base_cli.py +34 -0
  8. anipy_cli/clis/binge_cli.py +96 -0
  9. anipy_cli/clis/default_cli.py +115 -0
  10. anipy_cli/clis/download_cli.py +85 -0
  11. anipy_cli/clis/history_cli.py +96 -0
  12. anipy_cli/clis/mal_cli.py +71 -0
  13. anipy_cli/{cli/clis → clis}/seasonal_cli.py +9 -6
  14. anipy_cli/colors.py +14 -8
  15. anipy_cli/config.py +387 -90
  16. anipy_cli/discord.py +34 -0
  17. anipy_cli/download_component.py +194 -0
  18. anipy_cli/logger.py +200 -0
  19. anipy_cli/mal_proxy.py +228 -0
  20. anipy_cli/menus/__init__.py +6 -0
  21. anipy_cli/menus/anilist_menu.py +671 -0
  22. anipy_cli/{cli/menus → menus}/base_menu.py +9 -14
  23. anipy_cli/menus/mal_menu.py +657 -0
  24. anipy_cli/menus/menu.py +265 -0
  25. anipy_cli/menus/seasonal_menu.py +270 -0
  26. anipy_cli/prompts.py +387 -0
  27. anipy_cli/util.py +268 -0
  28. anipy_cli-3.8.2.dist-info/METADATA +71 -0
  29. anipy_cli-3.8.2.dist-info/RECORD +31 -0
  30. {anipy_cli-2.7.17.dist-info → anipy_cli-3.8.2.dist-info}/WHEEL +1 -2
  31. anipy_cli-3.8.2.dist-info/entry_points.txt +3 -0
  32. anipy_cli/cli/__init__.py +0 -1
  33. anipy_cli/cli/cli.py +0 -37
  34. anipy_cli/cli/clis/__init__.py +0 -6
  35. anipy_cli/cli/clis/base_cli.py +0 -43
  36. anipy_cli/cli/clis/binge_cli.py +0 -54
  37. anipy_cli/cli/clis/default_cli.py +0 -46
  38. anipy_cli/cli/clis/download_cli.py +0 -92
  39. anipy_cli/cli/clis/history_cli.py +0 -64
  40. anipy_cli/cli/clis/mal_cli.py +0 -27
  41. anipy_cli/cli/menus/__init__.py +0 -3
  42. anipy_cli/cli/menus/mal_menu.py +0 -411
  43. anipy_cli/cli/menus/menu.py +0 -102
  44. anipy_cli/cli/menus/seasonal_menu.py +0 -174
  45. anipy_cli/cli/util.py +0 -118
  46. anipy_cli/download.py +0 -454
  47. anipy_cli/history.py +0 -83
  48. anipy_cli/mal.py +0 -645
  49. anipy_cli/misc.py +0 -227
  50. anipy_cli/player/__init__.py +0 -1
  51. anipy_cli/player/player.py +0 -33
  52. anipy_cli/player/players/__init__.py +0 -3
  53. anipy_cli/player/players/base.py +0 -106
  54. anipy_cli/player/players/mpv.py +0 -19
  55. anipy_cli/player/players/mpv_contrl.py +0 -37
  56. anipy_cli/player/players/syncplay.py +0 -19
  57. anipy_cli/player/players/vlc.py +0 -18
  58. anipy_cli/query.py +0 -92
  59. anipy_cli/run_anipy_cli.py +0 -14
  60. anipy_cli/seasonal.py +0 -106
  61. anipy_cli/url_handler.py +0 -442
  62. anipy_cli/version.py +0 -1
  63. anipy_cli-2.7.17.dist-info/LICENSE +0 -674
  64. anipy_cli-2.7.17.dist-info/METADATA +0 -159
  65. anipy_cli-2.7.17.dist-info/RECORD +0 -43
  66. anipy_cli-2.7.17.dist-info/entry_points.txt +0 -2
  67. 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
@@ -0,0 +1,6 @@
1
+ from anipy_cli.menus.menu import Menu
2
+ from anipy_cli.menus.mal_menu import MALMenu
3
+ from anipy_cli.menus.anilist_menu import AniListMenu
4
+ from anipy_cli.menus.seasonal_menu import SeasonalMenu
5
+
6
+ __all__ = ["Menu", "MALMenu", "AniListMenu", "SeasonalMenu"]