anipy-cli 3.6.0__py3-none-any.whl → 3.8.0__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.

Potentially problematic release.


This version of anipy-cli might be problematic. Click here for more details.

@@ -0,0 +1,648 @@
1
+ import sys
2
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
+ from typing import Dict, List, Tuple
4
+
5
+ from anipy_cli.download_component import DownloadComponent
6
+
7
+ from anipy_api.anime import Anime
8
+ from anipy_api.anilist import AniListAnime, AniListMyListStatusEnum, AniList
9
+ from anipy_api.provider import LanguageTypeEnum
10
+ from anipy_api.provider.base import Episode
11
+
12
+ from anipy_api.locallist import LocalList, LocalListEntry
13
+ from InquirerPy import inquirer
14
+ from InquirerPy.base.control import Choice
15
+ from InquirerPy.utils import get_style
16
+
17
+ from anipy_cli.arg_parser import CliArgs
18
+ from anipy_cli.colors import colors, cprint
19
+ from anipy_cli.config import Config
20
+ from anipy_cli.anilist_proxy import AniListProxy
21
+ from anipy_cli.menus.base_menu import MenuBase, MenuOption
22
+ from anipy_cli.util import (
23
+ DotSpinner,
24
+ error,
25
+ find_closest,
26
+ get_configured_player,
27
+ migrate_locallist,
28
+ )
29
+ from anipy_cli.prompts import search_show_prompt
30
+
31
+
32
+ class AniListMenu(MenuBase):
33
+ def __init__(self, anilist: AniList, options: CliArgs):
34
+ self.anilist = anilist
35
+ self.anilist_proxy = AniListProxy(self.anilist)
36
+ with DotSpinner("Fetching your AniList..."):
37
+ self.anilist_proxy.get_list()
38
+
39
+ self.options = options
40
+ self.player = get_configured_player(self.options.optional_player)
41
+ self.seasonals_list = LocalList(
42
+ Config()._seasonal_file_path, migrate_cb=migrate_locallist
43
+ )
44
+
45
+ self.dl_path = Config().seasonals_dl_path
46
+ if options.location:
47
+ self.dl_path = options.location
48
+
49
+ def print_header(self):
50
+ pass
51
+
52
+ @property
53
+ def menu_options(self) -> List[MenuOption]:
54
+ return [
55
+ MenuOption("Add Anime", self.add_anime, "a"),
56
+ MenuOption("Delete one anime from AniList", self.del_anime, "e"),
57
+ MenuOption("List anime in AniList", self.list_anime, "l"),
58
+ MenuOption("Tag anime in AniList (dub/ignore)", self.tag_anime, "t"),
59
+ MenuOption("Map AniList anime to providers", self.manual_maps, "m"),
60
+ MenuOption("Sync AniList into seasonals", self.sync_anilist_seasonls, "s"),
61
+ MenuOption("Sync seasonals into AniList", self.sync_seasonals_anilist, "b"),
62
+ MenuOption(
63
+ "Download newest episodes", lambda: self.download(all=False), "d"
64
+ ),
65
+ MenuOption("Download all episodes", lambda: self.download(all=True), "x"),
66
+ MenuOption("Binge watch newest episodes", self.binge_latest, "w"),
67
+ MenuOption("Quit", sys.exit, "q"),
68
+ ]
69
+
70
+ def add_anime(self):
71
+ self.print_options()
72
+
73
+ query = inquirer.text( # type: ignore
74
+ "Search Anime:",
75
+ long_instruction="To cancel this prompt press ctrl+z",
76
+ mandatory=False,
77
+ ).execute()
78
+
79
+ if query is None:
80
+ return
81
+
82
+ with DotSpinner("Searching for ", colors.BLUE, query, "..."):
83
+ results = self.anilist.get_search(query)
84
+
85
+ anime = inquirer.fuzzy( # type: ignore
86
+ message="Select Show:",
87
+ choices=[Choice(value=r, name=self._format_anilist_anime(r)) for r in results],
88
+ transformer=lambda x: x.split("|")[-1].strip(),
89
+ long_instruction="To skip this prompt press crtl+z",
90
+ mandatory=False,
91
+ ).execute()
92
+
93
+ if anime is None:
94
+ return
95
+
96
+ anime = AniListAnime.from_dict(anime)
97
+ with DotSpinner("Adding ", colors.BLUE, anime.title.user_preferred, " to your AniList...") as s:
98
+ self.anilist_proxy.update_show(anime, AniListMyListStatusEnum.WATCHING)
99
+ s.ok("✔")
100
+
101
+ def del_anime(self):
102
+ self.print_options()
103
+ with DotSpinner("Fetching your AniList..."):
104
+ mylist = self.anilist_proxy.get_list()
105
+
106
+ entries = (
107
+ inquirer.fuzzy( # type: ignore
108
+ message="Select Seasonals to delete:",
109
+ choices=[
110
+ Choice(value=e, name=self._format_anilist_anime(e)) for e in mylist
111
+ ],
112
+ multiselect=True,
113
+ transformer=lambda x: [e.split("|")[-1].strip() for e in x],
114
+ long_instruction="| skip prompt: ctrl+z | toggle: ctrl+space | toggle all: ctrl+a | continue: enter |",
115
+ mandatory=False,
116
+ keybindings={"toggle": [{"key": "c-space"}]},
117
+ style=get_style(
118
+ {"long_instruction": "fg:#5FAFFF bg:#222"}, style_override=False
119
+ ),
120
+ ).execute()
121
+ or []
122
+ )
123
+
124
+ with DotSpinner("Deleting anime from your AniList...") as s:
125
+ for e in entries:
126
+ self.anilist_proxy.delete_show(AniListAnime.from_dict(e))
127
+ s.ok("✔")
128
+
129
+ def list_anime(self):
130
+ with DotSpinner("Fetching your AniList..."):
131
+ mylist = [
132
+ self._format_anilist_anime(e)
133
+ for e in self.anilist_proxy.get_list(
134
+ status_catagories=set(
135
+ [
136
+ AniListMyListStatusEnum.WATCHING,
137
+ AniListMyListStatusEnum.COMPLETED,
138
+ AniListMyListStatusEnum.ON_HOLD,
139
+ AniListMyListStatusEnum.PLAN_TO_WATCH,
140
+ AniListMyListStatusEnum.DROPPED,
141
+ ]
142
+ )
143
+ )
144
+ if e.my_list_status
145
+ ]
146
+
147
+ if not mylist:
148
+ error("your list is empty")
149
+ return
150
+
151
+ inquirer.fuzzy( # type: ignore
152
+ message="View your List",
153
+ choices=mylist,
154
+ mandatory=False,
155
+ transformer=lambda x: x.split("|")[-1].strip(),
156
+ long_instruction="To skip this prompt press ctrl+z",
157
+ ).execute()
158
+
159
+ self.print_options()
160
+
161
+ def tag_anime(self):
162
+ with DotSpinner("Fetching your AniList..."):
163
+ mylist = [
164
+ Choice(value=e, name=self._format_anilist_anime(e))
165
+ for e in self.anilist_proxy.get_list()
166
+ ]
167
+
168
+ entries = (
169
+ inquirer.fuzzy( # type: ignore
170
+ message="Select Anime to change tags of:",
171
+ choices=mylist,
172
+ multiselect=True,
173
+ long_instruction="| skip prompt: ctrl+z | toggle: ctrl+space | toggle all: ctrl+a | continue: enter |",
174
+ transformer=lambda x: [e.split("|")[-1].strip() for e in x],
175
+ mandatory=False,
176
+ keybindings={"toggle": [{"key": "c-space"}]},
177
+ style=get_style(
178
+ {"long_instruction": "fg:#5FAFFF bg:#222"}, style_override=False
179
+ ),
180
+ ).execute()
181
+ or []
182
+ )
183
+ entries = [AniListAnime.from_dict(e) for e in entries]
184
+
185
+ if not entries:
186
+ return
187
+
188
+ config = Config()
189
+
190
+ choices = []
191
+ if config.tracker_dub_tag:
192
+ choices.append(
193
+ Choice(
194
+ value=config.tracker_dub_tag,
195
+ name=f"{config.tracker_dub_tag} (sets wheter you prefer to watch a particular anime in dub)",
196
+ )
197
+ )
198
+
199
+ if config.tracker_ignore_tag:
200
+ choices.append(
201
+ Choice(
202
+ value=config.tracker_ignore_tag,
203
+ name=f"{config.tracker_ignore_tag} (sets wheter anipy-cli will ignore a particular anime)",
204
+ )
205
+ )
206
+
207
+ if not choices:
208
+ error("no tags to configure, check your config")
209
+ return
210
+
211
+ tags: List[str] = inquirer.select( # type: ignore
212
+ message="Select tags to add/remove:",
213
+ choices=choices,
214
+ multiselect=True,
215
+ long_instruction="| skip prompt: ctrl+z | toggle: ctrl+space | toggle all: ctrl+a | continue: enter |",
216
+ mandatory=False,
217
+ keybindings={"toggle": [{"key": "c-space"}]},
218
+ style=get_style(
219
+ {"long_instruction": "fg:#5FAFFF bg:#222"}, style_override=False
220
+ ),
221
+ ).execute()
222
+
223
+ if not tags:
224
+ return
225
+
226
+ action: str = inquirer.select( # type: ignore
227
+ message="Choose which Action to apply:",
228
+ choices=["Add", "Remove"],
229
+ long_instruction="To skip this prompt press ctrl+z",
230
+ mandatory=False,
231
+ ).execute()
232
+
233
+ if not action:
234
+ return
235
+
236
+ for e in entries:
237
+ if action == "Add":
238
+ self.anilist_proxy.update_show(e, tags=set(tags))
239
+ else:
240
+ current_tags = e.my_list_status.tags if e.my_list_status else []
241
+ for t in tags:
242
+ try:
243
+ current_tags.remove(t)
244
+ except ValueError:
245
+ continue
246
+
247
+ self.anilist_proxy.update_show(e, tags=set(current_tags))
248
+
249
+ def download(self, all: bool = False):
250
+ picked = self._choose_latest(all=all)
251
+ total_eps = sum([len(e) for a, m, lang, e in picked])
252
+ if total_eps == 0:
253
+ print("Nothing to download, returning...")
254
+ return
255
+ else:
256
+ print(f"Downloading a total of {total_eps} episode(s)")
257
+
258
+ convert: Dict[Anime, AniListAnime] = {d[0]: d[1] for d in picked}
259
+ new_picked = [
260
+ (anime_info[0], anime_info[2], anime_info[3]) for anime_info in picked
261
+ ]
262
+
263
+ def on_successful_download(anime: Anime, ep: Episode, lang: LanguageTypeEnum):
264
+ if all:
265
+ return
266
+ self.anilist_proxy.update_show(
267
+ convert[anime],
268
+ status=AniListMyListStatusEnum.WATCHING,
269
+ episode=int(ep),
270
+ )
271
+
272
+ errors = DownloadComponent(self.options, self.dl_path, "anilist").download_anime(
273
+ new_picked, on_successful_download
274
+ )
275
+ DownloadComponent.serve_download_errors(errors)
276
+
277
+ self.print_options(should_clear_screen=len(errors) == 0)
278
+
279
+ def binge_latest(self):
280
+ picked = self._choose_latest()
281
+ total_eps = sum([len(e) for a, m, lang, e in picked])
282
+ if total_eps == 0:
283
+ print("Nothing to watch, returning...")
284
+ return
285
+ else:
286
+ print(f"Playing a total of {total_eps} episode(s)")
287
+
288
+ for anime, anilist_anime, lang, eps in picked:
289
+ for ep in eps:
290
+ with DotSpinner(
291
+ "Extracting streams for ",
292
+ colors.BLUE,
293
+ f"{anime.name} ({lang})",
294
+ colors.END,
295
+ " Episode ",
296
+ ep,
297
+ "...",
298
+ ) as s:
299
+ stream = anime.get_video(
300
+ ep, lang, preferred_quality=self.options.quality
301
+ )
302
+ s.ok("✔")
303
+
304
+ self.player.play_title(anime, stream)
305
+ self.player.wait()
306
+
307
+ self.anilist_proxy.update_show(
308
+ anilist_anime, status=AniListMyListStatusEnum.WATCHING, episode=int(ep)
309
+ )
310
+
311
+ def manual_maps(self):
312
+ mylist = self.anilist_proxy.get_list()
313
+ self._create_maps_anilist(mylist)
314
+
315
+ def sync_seasonals_anilist(self):
316
+ config = Config()
317
+ seasonals = self.seasonals_list.get_all()
318
+ mappings = self._create_maps_provider(seasonals)
319
+ with DotSpinner("Syncing Seasonals into AniList") as s:
320
+ for k, v in mappings.items():
321
+ tags = set()
322
+ if config.tracker_dub_tag:
323
+ if k.language == LanguageTypeEnum.DUB:
324
+ tags.add(config.tracker_dub_tag)
325
+
326
+ if v.my_list_status:
327
+ if config.tracker_ignore_tag in v.my_list_status.tags:
328
+ continue
329
+ tags |= set(v.my_list_status.tags)
330
+
331
+ self.anilist_proxy.update_show(
332
+ v,
333
+ status=AniListMyListStatusEnum.WATCHING,
334
+ episode=int(k.episode),
335
+ tags=tags,
336
+ )
337
+ s.ok("✔")
338
+
339
+ def sync_anilist_seasonls(self):
340
+ config = Config()
341
+ mylist = self.anilist_proxy.get_list()
342
+ mappings = self._create_maps_anilist(mylist)
343
+ with DotSpinner("Syncing AniList into Seasonals") as s:
344
+ for k, v in mappings.items():
345
+ if config.tracker_dub_tag:
346
+ if k.my_list_status and config.tracker_dub_tag in k.my_list_status.tags:
347
+ pref_lang = LanguageTypeEnum.DUB
348
+ else:
349
+ pref_lang = LanguageTypeEnum.SUB
350
+ else:
351
+ pref_lang = (
352
+ LanguageTypeEnum.DUB
353
+ if config.preferred_type == "dub"
354
+ else LanguageTypeEnum.SUB
355
+ )
356
+
357
+ if pref_lang in v.languages:
358
+ lang = pref_lang
359
+ else:
360
+ lang = next(iter(v.languages))
361
+
362
+ provider_episodes = v.get_episodes(lang)
363
+ episode = (
364
+ k.my_list_status.num_episodes_watched if k.my_list_status else 0
365
+ )
366
+
367
+ if episode == 0:
368
+ episode = provider_episodes[0]
369
+ else:
370
+ episode = find_closest(provider_episodes, episode)
371
+
372
+ self.seasonals_list.update(v, episode=episode, language=lang)
373
+ s.ok("✔")
374
+
375
+ def _choose_latest(
376
+ self, all: bool = False
377
+ ) -> List[Tuple[Anime, AniListAnime, LanguageTypeEnum, List[Episode]]]:
378
+ cprint(
379
+ colors.GREEN,
380
+ "Hint: ",
381
+ colors.END,
382
+ "you can fine-tune mapping behaviour in your settings!",
383
+ )
384
+ with DotSpinner("Fetching your AniList..."):
385
+ mylist = self.anilist_proxy.get_list()
386
+
387
+ for i, e in enumerate(mylist):
388
+ if e.my_list_status is None:
389
+ mylist.pop(i)
390
+ continue
391
+
392
+ if (e.my_list_status.num_episodes_watched == e.num_episodes) and (
393
+ e.my_list_status.num_episodes_watched != 0
394
+ ):
395
+ mylist.pop(i)
396
+
397
+ if not mylist:
398
+ error("AniList is empty", False)
399
+ return []
400
+
401
+ if not (all or self.options.auto_update):
402
+ choices = inquirer.fuzzy( # type: ignore
403
+ message="Select shows to catch up to:",
404
+ choices=[
405
+ Choice(value=e, name=self._format_anilist_anime(e)) for e in mylist
406
+ ],
407
+ multiselect=True,
408
+ transformer=lambda x: [e.split("|")[-1].strip() for e in x],
409
+ long_instruction="| skip prompt: ctrl+z | toggle: ctrl+space | toggle all: ctrl+a | continue: enter |",
410
+ mandatory=False,
411
+ keybindings={"toggle": [{"key": "c-space"}]},
412
+ style=get_style(
413
+ {"long_instruction": "fg:#5FAFFF bg:#222"}, style_override=False
414
+ ),
415
+ ).execute()
416
+
417
+ if choices is None:
418
+ return []
419
+
420
+ mylist = [AniListAnime.from_dict(c) for c in choices]
421
+
422
+ config = Config()
423
+ to_watch: List[Tuple[Anime, AniListAnime, LanguageTypeEnum, List[Episode]]] = []
424
+
425
+ with DotSpinner("Fetching episodes...") as s:
426
+ for e in mylist:
427
+ s.write(f"> Checking out episodes of {e.title.user_preferred}")
428
+
429
+ if e.num_episodes != 0:
430
+ episodes_to_watch = list(
431
+ range(e.my_list_status.num_episodes_watched + 1, e.num_episodes + 1) # type: ignore
432
+ )
433
+ elif e.num_episodes == 0:
434
+ episodes_to_watch = list(
435
+ range(e.my_list_status.num_episodes_watched + 1, 10000) # type: ignore
436
+ )
437
+
438
+ result = self.anilist_proxy.map_from_anilist(e)
439
+
440
+ if result is None:
441
+ s.write(
442
+ f"> No mapping found for {e.title.user_preferred} please use the `m` option to map it"
443
+ )
444
+ continue
445
+
446
+ if config.tracker_dub_tag:
447
+ if e.my_list_status and config.tracker_dub_tag in e.my_list_status.tags:
448
+ pref_lang = LanguageTypeEnum.DUB
449
+ else:
450
+ pref_lang = LanguageTypeEnum.SUB
451
+ else:
452
+ pref_lang = (
453
+ LanguageTypeEnum.DUB
454
+ if config.preferred_type == "dub"
455
+ else LanguageTypeEnum.SUB
456
+ )
457
+
458
+ if pref_lang in result.languages:
459
+ lang = pref_lang
460
+ s.write(
461
+ f"> Looking for {lang} episodes because of config/tag preference"
462
+ )
463
+ else:
464
+ lang = next(iter(result.languages))
465
+ s.write(
466
+ f"> Looking for {lang} episodes because your preferred type is not available"
467
+ )
468
+
469
+ episodes = result.get_episodes(lang)
470
+
471
+ will_watch = []
472
+ if all:
473
+ will_watch.extend(episodes)
474
+ else:
475
+ for ep in episodes_to_watch:
476
+ try:
477
+ idx = episodes.index(ep)
478
+ will_watch.append(episodes[idx])
479
+ except ValueError:
480
+ s.write(
481
+ f"> Episode {ep} not found in provider, skipping..."
482
+ )
483
+ if e.num_episodes == 0:
484
+ break
485
+
486
+ to_watch.append((result, e, lang, will_watch))
487
+
488
+ s.ok("✔")
489
+
490
+ return to_watch
491
+
492
+ def _create_maps_anilist(self, to_map: List[AniListAnime]) -> Dict[AniListAnime, Anime]:
493
+ cprint(
494
+ colors.GREEN,
495
+ "Hint: ",
496
+ colors.END,
497
+ "you can fine-tune mapping behaviour in your settings!",
498
+ )
499
+ with DotSpinner("Starting Automapping...") as s:
500
+ failed: List[AniListAnime] = []
501
+ mappings: Dict[AniListAnime, Anime] = {}
502
+ counter = 0
503
+
504
+ def do_map(anime: AniListAnime, to_map_length: int):
505
+ nonlocal failed, counter
506
+ try:
507
+ result = self.anilist_proxy.map_from_anilist(anime)
508
+ if result is None:
509
+ failed.append(anime)
510
+ s.write(f"> Failed to map {anime.id} ({anime.title.user_preferred})")
511
+ else:
512
+ mappings.update({anime: result})
513
+ s.write(
514
+ f"> Successfully mapped {anime.id} to {result.identifier}"
515
+ )
516
+
517
+ counter += 1
518
+ s.set_text(f"Progress: {counter / to_map_length * 100:.1f}%")
519
+ except: # noqa: E722
520
+ failed.append(anime)
521
+
522
+ with ThreadPoolExecutor(max_workers=5) as pool:
523
+ futures = [pool.submit(do_map, a, len(to_map)) for a in to_map]
524
+ try:
525
+ for future in as_completed(futures):
526
+ future.result()
527
+ except KeyboardInterrupt:
528
+ pool.shutdown(wait=False, cancel_futures=True)
529
+ raise
530
+
531
+ if not failed or self.options.auto_update:
532
+ self.print_options()
533
+ print("Everything is mapped")
534
+ return mappings
535
+
536
+ for f in failed:
537
+ cprint("Manually mapping ", colors.BLUE, f.title)
538
+ anime = search_show_prompt("anilist", skip_season_search=True)
539
+ if not anime:
540
+ continue
541
+
542
+ map = self.anilist_proxy.map_from_anilist(f, anime)
543
+ if map is not None:
544
+ mappings.update({f: map})
545
+
546
+ self.print_options()
547
+ return mappings
548
+
549
+ def _create_maps_provider(
550
+ self, to_map: List[LocalListEntry]
551
+ ) -> Dict[LocalListEntry, AniListAnime]:
552
+ with DotSpinner("Starting Automapping...") as s:
553
+ failed: List[LocalListEntry] = []
554
+ mappings: Dict[LocalListEntry, AniListAnime] = {}
555
+ counter = 0
556
+
557
+ def do_map(entry: LocalListEntry, to_map_length: int):
558
+ nonlocal failed, counter
559
+ try:
560
+ anime = Anime.from_local_list_entry(entry)
561
+ result = self.anilist_proxy.map_from_provider(anime)
562
+ if result is None:
563
+ failed.append(entry)
564
+ s.write(f"> Failed to map {anime.identifier} ({anime.name})")
565
+ else:
566
+ mappings.update({entry: result})
567
+ s.write(
568
+ f"> Successfully mapped {anime.identifier} to {result.id}"
569
+ )
570
+
571
+ counter += 1
572
+ s.set_text(f"Progress: {counter / to_map_length * 100}%")
573
+ except: # noqa: E722
574
+ failed.append(entry)
575
+
576
+ with ThreadPoolExecutor(max_workers=5) as pool:
577
+ futures = [pool.submit(do_map, a, len(to_map)) for a in to_map]
578
+ try:
579
+ for future in as_completed(futures):
580
+ future.result()
581
+ except KeyboardInterrupt:
582
+ pool.shutdown(wait=False, cancel_futures=True)
583
+ raise
584
+
585
+ if not failed or self.options.auto_update or self.options.anilist_sync_seasonals:
586
+ self.print_options()
587
+ print("Everything is mapped")
588
+ return mappings
589
+
590
+ for f in failed:
591
+ cprint("Manually mapping ", colors.BLUE, f.name)
592
+ query = inquirer.text( # type: ignore
593
+ "Search Anime:",
594
+ long_instruction="To skip this prompt press ctrl+z",
595
+ mandatory=False,
596
+ ).execute()
597
+
598
+ if query is None:
599
+ continue
600
+
601
+ with DotSpinner("Searching for ", colors.BLUE, query, "..."):
602
+ results = self.anilist.get_search(query)
603
+
604
+ anime = inquirer.fuzzy( # type: ignore
605
+ message="Select Show:",
606
+ choices=[
607
+ Choice(value=r, name=self._format_anilist_anime(r)) for r in results
608
+ ],
609
+ transformer=lambda x: x.split("|")[-1].strip(),
610
+ long_instruction="To skip this prompt press crtl+z",
611
+ mandatory=False,
612
+ ).execute()
613
+
614
+ if not anime:
615
+ continue
616
+
617
+ anime = AniListAnime.from_dict(anime)
618
+ map = self.anilist_proxy.map_from_provider(
619
+ Anime.from_local_list_entry(f), anime
620
+ )
621
+ if map is not None:
622
+ mappings.update({f: map})
623
+
624
+ self.print_options()
625
+ return mappings
626
+
627
+ @staticmethod
628
+ def _format_anilist_anime(anime: AniListAnime) -> str:
629
+ config = Config()
630
+ dub = (
631
+ config.tracker_dub_tag in anime.my_list_status.tags
632
+ if anime.my_list_status
633
+ else False
634
+ )
635
+
636
+ return "{:<9} | {:<7} | {}".format(
637
+ (
638
+ (
639
+ anime.my_list_status.status.value.capitalize().replace("_", "")
640
+ if anime.my_list_status.status != AniListMyListStatusEnum.PLAN_TO_WATCH
641
+ else "Planning"
642
+ )
643
+ if anime.my_list_status
644
+ else "Not Added"
645
+ ),
646
+ f"{anime.my_list_status.num_episodes_watched if anime.my_list_status else 0}/{anime.num_episodes}",
647
+ f"{anime.title.user_preferred} {'(dub)' if dub else ''}",
648
+ )