|
11 | 11 | import pytest |
12 | 12 | from curl_cffi.requests.exceptions import RequestException as CurlRequestException |
13 | 13 |
|
14 | | -from alt_ani_cli.errors import AntiBotError, NoStreamError, ShindenError |
| 14 | +from alt_ani_cli.content import CONTENT |
| 15 | +from alt_ani_cli.errors import AntiBotError, DownloadFailedError, NoStreamError, ShindenError |
15 | 16 | from alt_ani_cli.extract.common import Stream |
16 | 17 | from alt_ani_cli.flow.handlers import ( |
17 | 18 | HANDLERS, |
|
22 | 23 | handle_run_action, |
23 | 24 | ) |
24 | 25 | from alt_ani_cli.flow.state import BACK, FlowState, Screen, _BackSentinel |
25 | | -from alt_ani_cli.models import EmbedURL, RelatedSeries, SeriesMetadata |
| 26 | +from alt_ani_cli.models import EmbedURL, PlayerSource, RelatedSeries, SeriesMetadata |
26 | 27 | from alt_ani_cli.player.runner import PlayResult |
27 | 28 | from alt_ani_cli.shinden.models import EpisodeRow, PlayerEntry, SeriesHit, SeriesRef |
28 | 29 |
|
@@ -560,6 +561,8 @@ def _play_state(self, episode_action="play", **overrides): |
560 | 561 | stream=Stream(url="https://cdn.example.com/v.m3u8", headers={}, ext="m3u8"), |
561 | 562 | embed=EmbedURL(url="https://morencius.com/embed/abc", referer="https://shinden.pl/"), |
562 | 563 | episode_action=episode_action, |
| 564 | + players=[_PLAYER], |
| 565 | + chosen_player=_PLAYER, |
563 | 566 | **overrides, |
564 | 567 | ) |
565 | 568 | return state |
@@ -671,6 +674,8 @@ def _play_state(self, episode_action="play", **overrides): |
671 | 674 | stream=Stream(url="https://cdn.example.com/v.m3u8", headers={}, ext="m3u8"), |
672 | 675 | embed=EmbedURL(url="https://morencius.com/embed/abc", referer="https://shinden.pl/"), |
673 | 676 | episode_action=episode_action, |
| 677 | + players=[_PLAYER], |
| 678 | + chosen_player=_PLAYER, |
674 | 679 | **overrides, |
675 | 680 | ) |
676 | 681 | return state |
@@ -758,6 +763,164 @@ def test_ep_idx_advances_regardless_of_confirmation(self): |
758 | 763 | assert state.ep_idx == 1 |
759 | 764 |
|
760 | 765 |
|
| 766 | +_STREAM = Stream(url="https://cdn.example.com/v.m3u8", headers={}, ext="m3u8") |
| 767 | +_EMBED = EmbedURL(url="https://vidara.to/e/abc", referer="https://shinden.pl/") |
| 768 | + |
| 769 | + |
| 770 | +def _download_state(players=(_PLAYER, _PLAYER2), **overrides) -> FlowState: |
| 771 | + state = _make_state( |
| 772 | + ref=_SERIES_REF, |
| 773 | + targets=[_EP1, _EP2], |
| 774 | + ep_idx=0, |
| 775 | + players=list(players), |
| 776 | + chosen_player=players[0], |
| 777 | + stream=_STREAM, |
| 778 | + embed=_EMBED, |
| 779 | + episode_action="download", |
| 780 | + ) |
| 781 | + for k, v in overrides.items(): |
| 782 | + setattr(state, k, v) |
| 783 | + return state |
| 784 | + |
| 785 | + |
| 786 | +@pytest.mark.unit |
| 787 | +class TestHandleRunActionDownloadFailure: |
| 788 | + def test_success_advances_episode_without_history(self): |
| 789 | + state = _download_state() |
| 790 | + with ( |
| 791 | + patch("alt_ani_cli.download.run") as mock_download, |
| 792 | + patch("alt_ani_cli.history.upsert") as mock_upsert, |
| 793 | + patch("alt_ani_cli.diagnostics.download_result") as mock_diag, |
| 794 | + ): |
| 795 | + result = handle_run_action(state) |
| 796 | + mock_download.assert_called_once() |
| 797 | + mock_upsert.assert_not_called() |
| 798 | + mock_diag.assert_called_once_with(None, ok=True, exc=None) |
| 799 | + assert result is Screen.EPISODE_DISPATCH |
| 800 | + assert state.ep_idx == 1 |
| 801 | + assert state.failed_ids == set() |
| 802 | + |
| 803 | + def test_failure_with_other_players_returns_to_player_pick(self): |
| 804 | + state = _download_state() |
| 805 | + with ( |
| 806 | + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), |
| 807 | + patch("alt_ani_cli.history.upsert") as mock_upsert, |
| 808 | + patch("alt_ani_cli.ui.progress.error") as mock_error, |
| 809 | + ): |
| 810 | + result = handle_run_action(state) |
| 811 | + assert result is Screen.PLAYER_PICK |
| 812 | + assert state.ep_idx == 0 |
| 813 | + assert state.failed_ids == {_PLAYER.online_id} |
| 814 | + assert state.stream is None |
| 815 | + assert state.embed is None |
| 816 | + assert state.completed_eps == set() |
| 817 | + assert state.episode_action == "download" |
| 818 | + mock_upsert.assert_not_called() |
| 819 | + assert _PLAYER.player in mock_error.call_args.args[0] |
| 820 | + |
| 821 | + def test_failure_logs_redacted_diagnostics(self): |
| 822 | + state = _download_state() |
| 823 | + state.player_sources = {_PLAYER.online_id: PlayerSource(_PLAYER.online_id, "vidara.to", _EMBED.url)} |
| 824 | + with ( |
| 825 | + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), |
| 826 | + patch("alt_ani_cli.ui.progress.error"), |
| 827 | + patch("alt_ani_cli.diagnostics.download_result") as mock_diag, |
| 828 | + ): |
| 829 | + handle_run_action(state) |
| 830 | + mock_diag.assert_called_once_with("vidara.to", ok=False, exc="DownloadFailedError") |
| 831 | + |
| 832 | + def test_failure_on_last_remaining_player_skips_episode(self): |
| 833 | + state = _download_state(failed_ids={_PLAYER.online_id}, chosen_player=_PLAYER2) |
| 834 | + with ( |
| 835 | + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), |
| 836 | + patch("alt_ani_cli.history.upsert") as mock_upsert, |
| 837 | + patch("alt_ani_cli.ui.progress.error"), |
| 838 | + patch("alt_ani_cli.ui.progress.warn") as mock_warn, |
| 839 | + ): |
| 840 | + result = handle_run_action(state) |
| 841 | + assert result is Screen.EPISODE_DISPATCH |
| 842 | + assert state.ep_idx == 1 |
| 843 | + assert state.completed_eps == set() |
| 844 | + mock_upsert.assert_not_called() |
| 845 | + assert mock_warn.call_args.args[0] == CONTENT["progress"]["no_player_worked"].format(number=_EP1.number) |
| 846 | + |
| 847 | + def test_failure_with_single_player_skips_episode(self): |
| 848 | + state = _download_state(players=(_PLAYER,)) |
| 849 | + with ( |
| 850 | + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), |
| 851 | + patch("alt_ani_cli.ui.progress.error"), |
| 852 | + patch("alt_ani_cli.ui.progress.warn"), |
| 853 | + ): |
| 854 | + result = handle_run_action(state) |
| 855 | + assert result is Screen.EPISODE_DISPATCH |
| 856 | + assert state.ep_idx == 1 |
| 857 | + |
| 858 | + def test_args_download_failure_also_returns_to_player_pick(self): |
| 859 | + state = _download_state(episode_action=None, args=_make_args(download=True)) |
| 860 | + with ( |
| 861 | + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), |
| 862 | + patch("alt_ani_cli.ui.progress.error"), |
| 863 | + ): |
| 864 | + result = handle_run_action(state) |
| 865 | + assert result is Screen.PLAYER_PICK |
| 866 | + assert state.ep_idx == 0 |
| 867 | + |
| 868 | + |
| 869 | +@pytest.mark.unit |
| 870 | +class TestDownloadRetryOnAnotherPlayer: |
| 871 | + """Episode → player A → download failure → PLAYER_PICK → player B → download success → next episode.""" |
| 872 | + |
| 873 | + def _drive(self, state: FlowState, start: Screen) -> list[Screen]: |
| 874 | + screens = [start] |
| 875 | + screen = start |
| 876 | + while not (screen is Screen.EPISODE_DISPATCH and state.ep_idx == 1): |
| 877 | + screen = HANDLERS[screen](state) |
| 878 | + screens.append(screen) |
| 879 | + assert len(screens) < 20, screens |
| 880 | + return screens |
| 881 | + |
| 882 | + def test_failed_download_retries_on_picked_player_without_asking_action(self): |
| 883 | + state = _download_state(stream=None, embed=None) |
| 884 | + stream_b = Stream(url="https://cdn.example.com/b.mp4", ext="mp4") |
| 885 | + streams = {_PLAYER.online_id: _STREAM, _PLAYER2.online_id: stream_b} |
| 886 | + downloaded: list[str] = [] |
| 887 | + |
| 888 | + def fake_resolve(client, players, chosen, **kwargs): |
| 889 | + return streams[chosen.online_id], _EMBED |
| 890 | + |
| 891 | + def fake_download(stream, ep, ref): |
| 892 | + downloaded.append(stream.url) |
| 893 | + if stream is _STREAM: |
| 894 | + raise DownloadFailedError("x") |
| 895 | + |
| 896 | + with ( |
| 897 | + patch("alt_ani_cli.cli._resolve_with_fallback", side_effect=fake_resolve), |
| 898 | + patch("alt_ani_cli.download.run", side_effect=fake_download), |
| 899 | + patch("alt_ani_cli.ui.menus.select_player_once", return_value=("pick", _PLAYER2)) as mock_pick, |
| 900 | + patch("alt_ani_cli.ui.menus.select_action") as mock_action, |
| 901 | + patch("alt_ani_cli.history.upsert") as mock_upsert, |
| 902 | + patch("alt_ani_cli.ui.progress.error"), |
| 903 | + patch("alt_ani_cli.ui.progress.warn"), |
| 904 | + ): |
| 905 | + screens = self._drive(state, Screen.RESOLVE_STREAM) |
| 906 | + |
| 907 | + assert screens == [ |
| 908 | + Screen.RESOLVE_STREAM, |
| 909 | + Screen.ACTION_PICK, |
| 910 | + Screen.RUN_ACTION, |
| 911 | + Screen.PLAYER_PICK, |
| 912 | + Screen.RESOLVE_STREAM, |
| 913 | + Screen.ACTION_PICK, |
| 914 | + Screen.RUN_ACTION, |
| 915 | + Screen.EPISODE_DISPATCH, |
| 916 | + ] |
| 917 | + assert downloaded == [_STREAM.url, stream_b.url] |
| 918 | + assert mock_pick.call_args.kwargs["failed"] == {_PLAYER.online_id} |
| 919 | + mock_action.assert_not_called() |
| 920 | + mock_upsert.assert_not_called() |
| 921 | + assert state.episode_action == "download" |
| 922 | + |
| 923 | + |
761 | 924 | @pytest.mark.unit |
762 | 925 | class TestSortedByDateDesc: |
763 | 926 | def test_sorts_by_year_descending(self): |
|
0 commit comments