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
anipy_cli/config.py CHANGED
@@ -1,165 +1,474 @@
1
- import yaml
2
1
  import functools
2
+ import inspect
3
+ import os
3
4
  from pathlib import Path
4
- from sys import platform
5
- from sys import exit as sys_exit
5
+ from string import Template
6
+ from typing import Any, Dict, List, Optional, Tuple, Type
6
7
 
8
+ import yaml
9
+ from appdirs import user_config_dir, user_data_dir
7
10
 
8
- class SysNotFoundError(Exception):
9
- pass
11
+ from anipy_cli import __appname__, __version__
10
12
 
11
13
 
12
14
  class Config:
13
15
  def __init__(self):
14
16
  self._config_file, self._yaml_conf = Config._read_config()
15
-
17
+
16
18
  if not self._yaml_conf:
17
19
  self._yaml_conf = {}
20
+ self._create_config() # Create config file
18
21
 
19
22
  @property
20
- def _anipy_cli_folder(self):
21
- return Path(Path(__file__).parent)
23
+ def user_files_path(self) -> Path:
24
+ """Path to user files, this includes history, seasonals files and more.
25
+
26
+ You may use `~` or environment vars in your path.
27
+ """
22
28
 
23
- @property
24
- def download_folder_path(self):
25
29
  return self._get_path_value(
26
- "download_folder_path", self._anipy_cli_folder / "download"
30
+ "user_files_path", Path(user_data_dir(__appname__, appauthor=False))
27
31
  )
28
32
 
29
33
  @property
30
- def seasonals_dl_path(self):
34
+ def _history_file_path(self) -> Path:
35
+ return self.user_files_path / "history.json"
36
+
37
+ @property
38
+ def _seasonal_file_path(self) -> Path:
39
+ return self.user_files_path / "seasonals.json"
40
+
41
+ @property
42
+ def _mal_local_user_list_path(self) -> Path:
43
+ return self.user_files_path / "mal_list.json"
44
+
45
+ @property
46
+ def _anilist_local_user_list_path(self) -> Path:
47
+ return self.user_files_path / "anilist_list.json"
48
+
49
+ @property
50
+ def download_folder_path(self) -> Path:
51
+ """Path to your download folder/directory.
52
+
53
+ You may use `~` or environment vars in your path.
54
+ """
31
55
  return self._get_path_value(
32
- "seasonals_dl_path", self.download_folder_path / "seasonals"
56
+ "download_folder_path", self.user_files_path / "download"
33
57
  )
34
58
 
35
59
  @property
36
- def user_files_path(self):
60
+ def seasonals_dl_path(self) -> Path:
61
+ """Path to your seasonal downloads directory.
62
+
63
+ You may use `~` or environment vars in your path.
64
+ """
37
65
  return self._get_path_value(
38
- "user_files_path", self._anipy_cli_folder / "user_files"
66
+ "seasonals_dl_path", self.download_folder_path / "seasonals"
39
67
  )
40
68
 
41
69
  @property
42
- def history_file_path(self):
43
- return self.user_files_path / "history.json"
70
+ def providers(self) -> Dict[str, List[str]]:
71
+ """A list of pairs defining which providers will search for anime
72
+ in different parts of the program. Configurable areas are as follows:
73
+ default (and history), download (-D), seasonal (-S), binge (-B), anilist (-A)
74
+ and mal (-M) The example will show you how it is done! Please note that for seasonal
75
+ search always the first provider that supports it is used.
76
+
77
+ For an updated list of providers look here: https://sdaqo.github.io/anipy-cli/availabilty
78
+
79
+ Supported providers (as of $version): allanime, animekai (animekai is not functional for now), native (filesystem provider, set root in provider_urls config)
80
+
81
+ Examples:
82
+ providers:
83
+ default: ["provider1"] # used in default mode and for the history
84
+ download: ["provider2"]
85
+ seasonal: ["provider3"]
86
+ binge: ["provider4"]
87
+ mal: ["provider2", "provider3"]
88
+ anilist: ["provider1"]
89
+ """
90
+ defaults = {
91
+ "default": ["allanime"],
92
+ "download": ["allanime"],
93
+ "seasonal": ["allanime"],
94
+ "binge": ["allanime"],
95
+ "mal": ["allanime"],
96
+ "anilist": ["allanime"],
97
+ }
98
+
99
+ value = self._get_value("providers", defaults, dict)
100
+
101
+ # Merge Dicts
102
+ defaults.update(value)
103
+ return defaults
44
104
 
45
105
  @property
46
- def seasonal_file_path(self):
47
- return self.user_files_path / "seasonals.json"
106
+ def provider_urls(self) -> Dict[str, str]:
107
+ """A list of pairs to override the default urls that providers use.
48
108
 
49
- @property
50
- def gogoanime_url(self):
51
- return self._get_value("gogoanime_url", "https://gogoanime.gg/", str)
109
+ Note: the native provider accepts a path instead of a url, it defaults to ~/Videos
110
+
111
+ Examples:
112
+ provider_urls:
113
+ gogoanime: "https://gogoanime3.co"
114
+ native: "~/Videos"
115
+ provider_urls: {} # do not override any urls
116
+ """
117
+
118
+ return self._get_value("provider_urls", {}, dict)
52
119
 
53
120
  @property
54
- def player_path(self):
55
- return self._get_value("player_path", "mpv", str)
121
+ def player_path(self) -> Path:
122
+ """
123
+ Path to your video player.
124
+ For a list of supported players look here: https://sdaqo.github.io/anipy-cli/availabilty
125
+
126
+ Supported players (as of $version): mpv, vlc, syncplay, mpvnet, mpv-controlled
127
+
128
+ Info for mpv-controlled:
129
+ Reuse the mpv window instead of closing and reopening.
130
+ This uses python-mpv, which uses libmpv, on linux this is (normally) preinstalled
131
+ with mpv, on windows you have to get the mpv-2.dll file from here:
132
+ https://sourceforge.net/projects/mpv-player-windows/files/libmpv/
133
+
134
+ Examples:
135
+ player_path: /usr/bin/syncplay # full path
136
+ player_path: syncplay # if in PATH this also works
137
+ player_path: C:\\\\Programms\\mpv\\mpv.exe # on windows path with .exe
138
+ player_path: mpv-controlled # recycle your mpv windows!
139
+ """
140
+ return self._get_path_value("player_path", Path("mpv"))
56
141
 
57
142
  @property
58
- def mpv_commandline_options(self):
143
+ def mpv_commandline_options(self) -> List[str]:
144
+ """Extra commandline arguments for mpv and derivative.
145
+
146
+ Examples:
147
+ mpv_commandline_options: ["--keep-open=no", "--fs=yes"]
148
+ """
59
149
  return self._get_value("mpv_commandline_options", ["--keep-open=no"], list)
60
150
 
61
151
  @property
62
- def vlc_commandline_options(self):
152
+ def vlc_commandline_options(self) -> List[str]:
153
+ """Extra commandline arguments for vlc.
154
+
155
+ Examples:
156
+ vlc_commandline_options: ["--fullscreen"]
157
+ """
63
158
  return self._get_value("vlc_commandline_options", [], list)
64
159
 
65
160
  @property
66
- def reuse_mpv_window(self):
161
+ def iina_commandline_options(self) -> List[str]:
162
+ """Extra commandline arguments for iina.
163
+
164
+ Examples:
165
+ iina_commandline_options: ["--mpv-fullscreen"]
166
+ """
167
+ return self._get_value("iina_commandline_options", [], list)
168
+
169
+ @property
170
+ def reuse_mpv_window(self) -> bool:
171
+ """DEPRECATED This option was deprecated in 3.0.0, please use `mpv-
172
+ controlled` in the `player_path` instead!
173
+
174
+ Reuse the mpv window instead of closing and reopening. This uses
175
+ python-mpv, which uses libmpv, on linux this is (normally)
176
+ preinstalled with mpv, on windows you have to get the mpv-2.dll
177
+ file from here:
178
+ https://sourceforge.net/projects/mpv-player-windows/files/libmpv/
179
+ """
67
180
  return self._get_value("reuse_mpv_window", False, bool)
68
181
 
69
182
  @property
70
- def ffmpeg_hls(self):
183
+ def ffmpeg_hls(self) -> bool:
184
+ """Always use ffmpeg to download m3u8 playlists instead of the internal
185
+ downloader.
186
+
187
+ To temporarily enable this use the `--ffmpeg` command line flag.
188
+ """
71
189
  return self._get_value("ffmpeg_hls", False, bool)
72
190
 
73
191
  @property
74
- def ffmpeg_log_path(self):
75
- return self.user_files_path / "ffmpeg_log"
192
+ def remux_to(self) -> Optional[str]:
193
+ """
194
+ Remux resulting download to a specific container using ffmpeg.
195
+ You can use about any conatainer supported by ffmpeg: `.your-container`.
196
+
197
+ Examples:
198
+ remux_to: .mkv # remux all downloads to .mkv container
199
+ remux_to .mp4 # downloads with ffmpeg default to a .mp4 container,
200
+ with this option the internal downloader's downloads also get remuxed
201
+ remux_to: null or remux_to: "" # do not remux
202
+ """
203
+ return self._get_value("remux_to", None, str)
76
204
 
77
205
  @property
78
- def download_name_format(self):
79
- return self._get_value(
80
- "download_name_format", "{show_name}_{episode_number}.mp4", str
206
+ def download_name_format(self) -> str:
207
+ """
208
+ Specify the name format of a download, available fields are:
209
+ show_name: name of the show/anime
210
+ episode_number: number of the episode
211
+ quality: quality/resolution of the video
212
+ provider: provider used to download
213
+ type: this field is populated with `dub` if the episode is in dub format or `sub` otherwise
214
+
215
+ The fields should be set in curly braces i.e. `{field_name}`.
216
+ Do not add a suffix (e.g. '.mp4') here, if you want to change this
217
+ look at the `remux_to` config option.
218
+
219
+ You have to at least use episode_number in the format or else, while downloading,
220
+ perceding episodes of the same series will be skipped because the file name will be the same.
221
+
222
+ Examples:
223
+ download_name_format: "[{provider}] {show_name} E{episode_number} [{type}][{quality}p]"
224
+ download_name_format: "{show_name}_{episode_number}"
225
+
226
+ """
227
+
228
+ # Remove suffix for past 3.0.0 versions
229
+ value = self._get_value(
230
+ "download_name_format", "{show_name}_{episode_number}", str
81
231
  )
232
+ return str(Path(value).with_suffix(""))
82
233
 
83
234
  @property
84
- def download_remove_dub_from_folder_name(self):
85
- return self._get_value("download_remove_dub_from_folder_name", False, bool)
235
+ def post_download_scripts(self) -> Dict[str, List[str]]:
236
+ """With this option you can define scripts that run after a file
237
+ has been downloaded. As with the 'providers' option, you can configure
238
+ different behaviour, depending on which part of anipy-cli the download occurs.
239
+ Configurable areas are as follows: default (and history), download (-D), seasonal (-S),
240
+ anilist (-A) and mal (-M). The example will show you how it is done! Please note that
241
+ if you define several scripts for one area, they will run in the order you put them in the list.
242
+ You can also define a timeout (in seconds), after which a script will be terminated,
243
+ if set to null there will be no timeout and any script will run forever.
244
+
245
+ A "script" is a path to an executable file which accepts following parameters (in this order):
246
+ 1. Path to the file
247
+ 2. Name of series
248
+ 3. Episode
249
+ 4. Provider
250
+ 5. Quality
251
+ 6. Language profile
252
+
253
+ Examples:
254
+ post_download_scripts:
255
+ default: [] # used in default mode and for the history
256
+ download: ["/scripts/send_notification.sh", "/scripts/move_and_rename.sh"]
257
+ seasonal: ["link_to_jellyfin.bat", "jellyfin_library_update.exe"] # All executable files should work, including windows specific
258
+ mal: ["hard_link_to_shoko"]
259
+ timeout: 60 # terminate any script after running for 60 seconds
260
+ """
261
+ defaults = {
262
+ "default": [],
263
+ "download": [],
264
+ "seasonal": [],
265
+ "mal": [],
266
+ "anilist": [],
267
+ "timeout": None,
268
+ }
269
+
270
+ value = self._get_value("post_download_scripts", defaults, dict)
271
+
272
+ # Merge Dicts
273
+ defaults.update(value)
274
+ return defaults
86
275
 
87
276
  @property
88
- def dc_presence(self):
277
+ def dc_presence(self) -> bool:
278
+ """Activate discord presence, only works with discord open."""
89
279
  return self._get_value("dc_presence", False, bool)
90
280
 
91
281
  @property
92
- def auto_open_dl_defaultcli(self):
93
- return self._get_value("auto_open_dl_defaultcli", False, bool)
282
+ def auto_open_dl_defaultcli(self) -> bool:
283
+ """This automatically opens the downloaded file if downloaded through
284
+ the `d` option in the default cli."""
285
+ return self._get_value("auto_open_dl_defaultcli", True, bool)
94
286
 
95
287
  @property
96
- def mal_local_user_list_path(self):
97
- return self.user_files_path / "mal_list.json"
288
+ def mal_user(self) -> str:
289
+ """Your MyAnimeList username for MAL mode."""
290
+ return self._get_value("mal_user", "", str)
98
291
 
99
292
  @property
100
- def mal_user(self):
101
- return self._get_value("mal_user", "", str)
293
+ def anilist_token(self) -> str:
294
+ """Your AniList access token for AniList mode."""
295
+ return self._get_value("anilist_token", "", str)
102
296
 
103
297
  @property
104
- def mal_password(self):
298
+ def mal_password(self) -> str:
299
+ """Your MyAnimeList password for MAL mode.
300
+
301
+ The password may also be passed via the `--mal-password <pwd>`
302
+ commandline option.
303
+ """
105
304
  return self._get_value("mal_password", "", str)
106
305
 
107
306
  @property
108
- def auto_sync_mal_to_seasonals(self):
307
+ def tracker_ignore_tag(self) -> str:
308
+ """All anime in your MyAnimeList with this tag will be ignored by
309
+ anipy-cli.
310
+
311
+ Examples:
312
+ tracker_ignore_tag: ignore # all anime with ignore tag will be ignored
313
+ tracker_ignore_tag: "" # no anime will be ignored
314
+ """
315
+ return self._get_value("tracker_ignore_tag", "ignore", str)
316
+
317
+ @property
318
+ def tracker_dub_tag(self) -> str:
319
+ """All anime in your Anime Tracker with this tag will be switched over to
320
+ dub in tracker mode, if the dub is available. If you do not specify a tag,
321
+ anipy-cli will use `preferred_type` to choose dub or sub in tracker mode.
322
+
323
+ Examples:
324
+ tracker_dub_tag: dub # all anime with this tag will be switched to dub
325
+ tracker_dub_tag: "" # no anime will be switched to dub, except you have preferred_type on dub
326
+ """
327
+ return self._get_value("tracker_dub_tag", "dub", str)
328
+
329
+ @property
330
+ def tracker_tags(self) -> List[str]:
331
+ """Custom tags to tag all anime in your Anime Tracker that are
332
+ altered/added by anipy-cli.
333
+
334
+ Examples:
335
+ tracker_tags: ["anipy-cli"] # tag all anime with anipy-cli
336
+ tracker_tags: ["anipy-cli", "important"] # tag all anime with anipy-cli and important
337
+ tracker_tags: null or tracker_tags: [] # Do not tag the anime
338
+ """
339
+ return self._get_value("tracker_tags", [], list)
340
+
341
+ @property
342
+ def tracker_status_categories(self) -> List[str]:
343
+ """Status categories of your Anime Tracker that anipy-cli uses for
344
+ downloading/watching new episodes listing anime in your list and stuff
345
+ like that. Normally the watching catagory should be enough as you would
346
+ normally put anime you currently watch in the watching catagory.
347
+
348
+ Valid values are: watching, completed, on_hold, dropped, plan_to_watch
349
+ """
350
+ return self._get_value("tracker_status_categories", ["watching"], list)
351
+
352
+ @property
353
+ def tracker_mapping_min_similarity(self) -> float:
354
+ """
355
+ The minumum similarity between titles when mapping anime in tracker mode.
356
+ This is a decimal number from 0 - 1, 1 meaning 100% match and 0 meaning all characters are different.
357
+ If the similarity of a map is below the threshold you will be prompted for a manual map.
358
+
359
+ So in summary:
360
+ higher number: more exact matching, but more manual mapping
361
+ lower number: less exact matching, but less manual mapping
362
+
363
+ If you are interested, the algorithm being used here is this: https://en.wikipedia.org/wiki/Levenshtein_distance
364
+ """
365
+ return self._get_value("tracker_mapping_min_similarity", 0.8, float)
366
+
367
+ @property
368
+ def tracker_mapping_use_alternatives(self) -> bool:
369
+ """Check alternative names when mapping anime.
370
+
371
+ If turned on this will slow down mapping but provide better
372
+ chances of finding a match.
373
+ """
374
+ return self._get_value("tracker_mapping_use_alternatives", True, bool)
375
+
376
+ @property
377
+ def tracker_mapping_use_filters(self) -> bool:
378
+ """Use filters (e.g. year, season etc.) of providers to narrow down the
379
+ results, this will lead to more accurate mapping, but provide wrong
380
+ results if the filters of the provider do not work properly or if anime
381
+ are not correctly marked with the correct data."""
382
+ return self._get_value("tracker_mapping_use_filters", True, bool)
383
+
384
+ @property
385
+ def auto_sync_mal_to_seasonals(self) -> bool:
386
+ """DEPRECATED This option was deprecated in 3.0.0, please consider
387
+ using the `--mal-sync-seasonals` cli option in compination with `-M`
388
+ instead.
389
+
390
+ Automatically sync MyAnimeList to Seasonals list.
391
+ """
109
392
  return self._get_value("auto_sync_mal_to_seasonals", False, bool)
110
393
 
111
394
  @property
112
- def auto_map_mal_to_gogo(self):
395
+ def auto_map_mal_to_gogo(self) -> bool:
113
396
  return self._get_value("auto_map_mal_to_gogo", False, bool)
114
397
 
115
398
  @property
116
- def mal_status_categories(self):
117
- return self._get_value("mal_status_categories", list(["watching"]), list)
399
+ def preferred_type(self) -> Optional[str]:
400
+ """Specify which anime types (dub or sub) you prefer. If this is
401
+ specified, you will not be asked to switch to dub anymore. You can
402
+ however always switch to either in the menu.
403
+
404
+ Examples:
405
+ preferred_type: sub
406
+ preferred_type: dub
407
+ preferred_type: null or preferred_type: "" # always ask
408
+ """
409
+ return self._get_value("preferred_type", None, str)
410
+
411
+ @property
412
+ def skip_season_search(self) -> bool:
413
+ """If this is set to true you will not be prompted to search in season."""
414
+ return self._get_value("skip_season_search", False, bool)
118
415
 
119
416
  @property
120
- def anime_types(self):
121
- return self._get_value("anime_types", list(["sub", "dub"]), list)
417
+ def assume_season_search(self) -> bool:
418
+ """If this is set to true, the system will assume you want to search in season.
419
+ If skip_season_search is true, this will be ignored)"""
420
+ return self._get_value("assume_season_search", False, bool)
122
421
 
123
422
  def _get_path_value(self, key: str, fallback: Path) -> Path:
124
423
  path = self._get_value(key, fallback, str)
125
424
  try:
126
- return Path(path).expanduser()
127
- except:
425
+ # os.path.expanduser is equivalent to Path().expanduser()
426
+ # But because pathlib doesn't have expandvars(), we resort
427
+ # to using the os module inside the Path constructor
428
+ return Path(os.path.expandvars(path)).expanduser()
429
+ except RuntimeError:
128
430
  return fallback
129
431
 
130
- def _get_value(self, key: str, fallback, typ: object):
432
+ def _get_value(self, key: str, fallback: Any, _type: Type) -> Any:
131
433
  value = self._yaml_conf.get(key, fallback)
132
- if isinstance(value, typ):
434
+ if isinstance(value, _type):
133
435
  return value
134
436
 
135
437
  return fallback
136
438
 
137
439
  def _create_config(self):
138
- try:
139
- self._get_config_path().mkdir(exist_ok=True, parents=True)
140
- config_options = {}
141
- # generate config based on attrs and default values of config class
142
- for attribute, value in Config.__dict__.items():
143
- if attribute.startswith("_"):
144
- continue
145
-
146
- if isinstance(value, property):
147
- val = self.__getattribute__(attribute)
148
- config_options[attribute] = (
149
- str(val) if isinstance(val, Path) else val
150
- )
151
- self._config_file.touch()
152
- with open(self._config_file, "w") as file:
153
- yaml.dump(
154
- yaml.dump(config_options, file, indent=4, default_flow_style=False)
155
- )
156
- except PermissionError as e:
157
- print(f"Failed to create config file: {repr(e)}")
158
- sys_exit(1)
159
-
440
+ self._get_config_path().mkdir(exist_ok=True, parents=True)
441
+ self._config_file.touch()
442
+
443
+ dump = ""
444
+ # generate config based on attrs and default values of config class
445
+ for attribute, value in Config.__dict__.items():
446
+ if attribute.startswith("_"):
447
+ continue
448
+
449
+ if not isinstance(value, property):
450
+ continue
451
+
452
+ doc = inspect.getdoc(value)
453
+ if doc:
454
+ # Add docstrings
455
+ doc = Template(doc).safe_substitute(version=__version__)
456
+ doc = "\n".join([f"# {line}" for line in doc.split("\n")])
457
+ dump = dump + doc + "\n"
458
+
459
+ val = self.__getattribute__(attribute)
460
+ val = str(val) if isinstance(val, Path) else val
461
+ dump = (
462
+ dump
463
+ + yaml.dump({attribute: val}, indent=4, default_flow_style=False)
464
+ + "\n"
465
+ )
466
+
467
+ self._config_file.write_text(dump)
468
+
160
469
  @staticmethod
161
470
  @functools.lru_cache
162
- def _read_config():
471
+ def _read_config() -> Tuple[Path, dict[str, Any]]:
163
472
  config_file = Config._get_config_path() / "config.yaml"
164
473
  try:
165
474
  with config_file.open("r") as conf:
@@ -167,21 +476,9 @@ class Config:
167
476
  except FileNotFoundError:
168
477
  # There is no config file, create one
169
478
  yaml_conf = {}
170
-
171
- return config_file, yaml_conf
172
479
 
480
+ return config_file, yaml_conf
173
481
 
174
482
  @staticmethod
175
483
  def _get_config_path() -> Path:
176
- linux_path = Path().home() / ".config" / "anipy-cli"
177
- windows_path = Path().home() / "AppData" / "Local" / "anipy-cli"
178
- macos_path = Path().home() / ".config" / "anipy-cli"
179
-
180
- if platform == "linux":
181
- return linux_path
182
- elif platform == "darwin":
183
- return macos_path
184
- elif platform == "win32":
185
- return windows_path
186
- else:
187
- raise SysNotFoundError(platform)
484
+ return Path(user_config_dir(__appname__, appauthor=False))
anipy_cli/discord.py ADDED
@@ -0,0 +1,34 @@
1
+ import time
2
+ import functools
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pypresence import Presence
6
+
7
+ if TYPE_CHECKING:
8
+ from anipy_api.provider import ProviderStream
9
+ from anipy_api.anime import Anime
10
+
11
+
12
+ @functools.lru_cache(maxsize=None)
13
+ class DiscordPresence(object):
14
+ def __init__(self):
15
+ self.rpc_client = Presence(966365883691855942)
16
+ self.rpc_client.connect()
17
+
18
+ def dc_presence_callback(self, anime: "Anime", stream: "ProviderStream"):
19
+ anime_info = anime.get_info()
20
+ self.rpc_client.update(
21
+ details=f"Watching {anime.name} via anipy-cli",
22
+ state=f"Episode {stream.episode}/{anime.get_episodes(stream.language)[-1]}",
23
+ large_image=anime_info.image or "",
24
+ small_image="https://raw.githubusercontent.com/sdaqo/anipy-cli/master/docs/assets/anipy-logo-dark-compact.png",
25
+ large_text=anime.name,
26
+ small_text="anipy-cli",
27
+ start=int(time.time()),
28
+ buttons=[
29
+ {
30
+ "label": "Check out anipy-cli",
31
+ "url": "https://github.com/sdaqo/anipy-cli",
32
+ }
33
+ ],
34
+ )