Skip to content

Commit 77abfac

Browse files
authored
Merged pull request #1 from Anas-Tou/main.
✅Download: Added download support using aria2c (fast) and IDM integration for Windows. ✅MAL Integration: Added 'Relevant' (R) option to fetch currently airing anime via Jikan API with SFW filtering. ✅History: Implemented watch history; watched episodes are now marked with an eye icon. ✅Navigation: Added Next/Previous/Replay menu after watching or downloading. ✅UI: Added 'L' key to jump to the last watched episode in the menu.
2 parents 265cc1a + e88fd90 commit 77abfac

6 files changed

Lines changed: 430 additions & 44 deletions

File tree

database/history.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"xOnePiece": {
3+
"episode": "1152",
4+
"title": "One Piece",
5+
"last_updated": "2025-12-23T16:29:53.714824"
6+
}
7+
}

src/api.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import sqlite3
44
import base64
55
import hashlib
6+
from datetime import datetime
67
from typing import List, Optional, Dict
78
from cryptography.fernet import Fernet
89
from pathlib import Path
@@ -12,6 +13,57 @@
1213
# Using it in other projects without permission is prohibited.
1314

1415
class AnimeAPI:
16+
def get_mal_season_now(self) -> List[AnimeResult]:
17+
"""Fetches the currently airing anime from Jikan (MAL Public API)."""
18+
url = "https://api.jikan.moe/v4/seasons/now"
19+
try:
20+
# Jikan API is public and free
21+
# params={'sfw': 'true'} filters out 18+ (Rx) content
22+
response = requests.get(url, params={'sfw': 'true'}, timeout=10)
23+
response.raise_for_status()
24+
data = response.json().get('data', [])
25+
26+
results = []
27+
for item in data:
28+
# Extra safety check: Skip if rating contains 'Rx' (Hentai)
29+
rating_str = item.get('rating', '')
30+
if rating_str and 'Rx' in rating_str:
31+
continue
32+
33+
# Handle English title fallback
34+
title = item.get('title_english') or item.get('title')
35+
36+
# Get high-res image if available
37+
images = item.get('images', {}).get('jpg', {})
38+
thumbnail_url = images.get('large_image_url') or images.get('image_url', '')
39+
40+
# Extract genres and studios
41+
genres = ", ".join([g['name'] for g in item.get('genres', [])])
42+
studios = ", ".join([s['name'] for s in item.get('studios', [])])
43+
44+
results.append(AnimeResult(
45+
id="", # EMPTY ID: Signals app.py to search for this title on selection
46+
title_en=title,
47+
title_jp=item.get('title_japanese', ''),
48+
type=item.get('type', 'TV'),
49+
episodes=str(item.get('episodes') or '?'),
50+
status=item.get('status', 'N/A'),
51+
genres=genres,
52+
mal_id=str(item.get('mal_id', '')),
53+
relation_id='',
54+
score=str(item.get('score', 'N/A')),
55+
rank=str(item.get('rank', 'N/A')),
56+
popularity=str(item.get('popularity', 'N/A')),
57+
rating=item.get('rating', 'N/A'),
58+
premiered=f"{item.get('season', '')} {item.get('year', '')}",
59+
creators=studios,
60+
duration=item.get('duration', 'N/A'),
61+
thumbnail=thumbnail_url
62+
))
63+
return results
64+
except Exception:
65+
return []
66+
1567
def search_anime(self, query: str) -> List[AnimeResult]:
1668
endpoint = ANI_CLI_AR_API_BASE + "anime/load_anime_list_v2.php"
1769
payload = {
@@ -138,7 +190,6 @@ def _get_db_path(self) -> Path:
138190

139191
def _ensure_db_exists(self):
140192
if not self.db_path.exists():
141-
# Fallback creation logic removed. Database file is now required.
142193
raise FileNotFoundError(
143194
f"Required credentials database not found at: {self.db_path}. "
144195
"Please reinstall the application or restore the database file."

src/app.py

Lines changed: 114 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212
from .player import PlayerManager
1313
from .discord_rpc import DiscordRPCManager
1414
from .models import QualityOption
15+
from .utils import download_file
16+
from .history import HistoryManager
1517

1618
class AniCliArApp:
1719
def __init__(self):
1820
self.ui = UIManager()
1921
self.api = AnimeAPI()
2022
self.rpc = DiscordRPCManager()
2123
self.player = PlayerManager(rpc_manager=self.rpc, console=self.ui.console)
24+
self.history = HistoryManager()
2225

2326
def run(self):
2427
atexit.register(self.cleanup)
@@ -48,7 +51,7 @@ def main_loop(self):
4851
self.ui.print()
4952

5053
search_prompt = Panel(
51-
Text("Search Anime (or 'q' to quit)", style="info", justify="center"),
54+
Text("S Search | R Relevant/Featured (MAL) | Q Quit", style="info", justify="center"),
5255
box=HEAVY,
5356
padding=(0, 4),
5457
border_style=COLOR_BORDER
@@ -67,19 +70,28 @@ def main_loop(self):
6770
if query.lower() in ['q', 'quit', 'exit']:
6871
break
6972

70-
if not query:
71-
continue
72-
73-
self.rpc.update_searching()
73+
results = []
7474

75-
results = self.ui.run_with_loading(
76-
"Searching anime...",
77-
self.api.search_anime,
78-
query
79-
)
75+
if query.lower() == 'r':
76+
self.rpc.update_searching()
77+
# Fetching from Jikan (MAL) with auto-SFW filtering
78+
results = self.ui.run_with_loading(
79+
"Fetching currently airing anime from MAL...",
80+
self.api.get_mal_season_now
81+
)
82+
elif query.lower() == 's':
83+
term = Prompt.ask(f"{padding} Enter Search Term: ", console=self.ui.console).strip()
84+
if term:
85+
self.rpc.update_searching()
86+
results = self.ui.run_with_loading("Searching...", self.api.search_anime, term)
87+
elif query:
88+
self.rpc.update_searching()
89+
results = self.ui.run_with_loading("Searching...", self.api.search_anime, query)
90+
else:
91+
continue
8092

8193
if not results:
82-
self.ui.render_message("✗ No Results", f"No results found for '{query}'", "error")
94+
self.ui.render_message("✗ No Results", f"No results found.", "error")
8395
continue
8496

8597
self.handle_anime_selection(results)
@@ -94,6 +106,22 @@ def handle_anime_selection(self, results):
94106
return
95107

96108
selected_anime = results[anime_idx]
109+
110+
# --- BRIDGE LOGIC: MAL to Internal API ---
111+
if not selected_anime.id:
112+
internal_results = self.ui.run_with_loading(
113+
f"Syncing '{selected_anime.title_en}'...",
114+
self.api.search_anime,
115+
selected_anime.title_en
116+
)
117+
118+
if not internal_results:
119+
self.ui.render_message("✗ Not Found", f"Sorry, '{selected_anime.title_en}' hasn't been uploaded to the server yet.", "error")
120+
continue
121+
122+
selected_anime = internal_results[0]
123+
# -----------------------------------------
124+
97125
self.rpc.update_viewing_anime(selected_anime.title_en, selected_anime.thumbnail)
98126

99127
episodes = self.ui.run_with_loading(
@@ -115,33 +143,72 @@ def handle_anime_selection(self, results):
115143
break
116144

117145
def handle_episode_selection(self, selected_anime, episodes):
146+
current_idx = 0
147+
118148
while True:
119-
ep_idx = self.ui.episode_selection_menu(selected_anime.title_en, episodes, self.rpc, selected_anime.thumbnail)
149+
last_watched = self.history.get_last_watched(selected_anime.id)
150+
151+
ep_idx = self.ui.episode_selection_menu(
152+
selected_anime.title_en,
153+
episodes,
154+
self.rpc,
155+
selected_anime.thumbnail,
156+
last_watched_ep=last_watched
157+
)
120158

121159
if ep_idx == -1:
122160
sys.exit(0)
123161
elif ep_idx is None:
124162
self.rpc.update_browsing()
125163
return True
126164

127-
selected_ep = episodes[ep_idx]
165+
current_idx = ep_idx
128166

129-
server_data = self.ui.run_with_loading(
130-
"Loading servers...",
131-
self.api.get_streaming_servers,
132-
selected_anime.id,
133-
selected_ep.number
134-
)
135-
136-
if not server_data:
137-
self.ui.render_message(
138-
"✗ No Servers",
139-
"No servers available for this episode.",
140-
"error"
167+
while True:
168+
selected_ep = episodes[current_idx]
169+
170+
server_data = self.ui.run_with_loading(
171+
"Loading servers...",
172+
self.api.get_streaming_servers,
173+
selected_anime.id,
174+
selected_ep.number
141175
)
142-
continue
143-
144-
self.handle_quality_selection(selected_anime, selected_ep, server_data)
176+
177+
if not server_data:
178+
self.ui.render_message(
179+
"✗ No Servers",
180+
"No servers available for this episode.",
181+
"error"
182+
)
183+
break
184+
185+
action_taken = self.handle_quality_selection(selected_anime, selected_ep, server_data)
186+
187+
# --- UPDATED LOGIC HERE ---
188+
# Check for both "watch" AND "download"
189+
if action_taken == "watch" or action_taken == "download":
190+
next_action = self.ui.post_watch_menu()
191+
192+
if next_action == "Next Episode":
193+
if current_idx + 1 < len(episodes):
194+
current_idx += 1
195+
continue
196+
else:
197+
self.ui.render_message("Info", "No more episodes!", "info")
198+
break
199+
elif next_action == "Previous Episode":
200+
if current_idx > 0:
201+
current_idx -= 1
202+
continue
203+
else:
204+
self.ui.render_message("Info", "This is the first episode.", "info")
205+
break
206+
elif next_action == "Replay":
207+
continue
208+
else:
209+
break
210+
else:
211+
break
145212

146213
def handle_quality_selection(self, selected_anime, selected_ep, server_data):
147214
current_ep_data = server_data.get('CurrentEpisode', {})
@@ -159,21 +226,22 @@ def handle_quality_selection(self, selected_anime, selected_ep, server_data):
159226
"No MediaFire servers found for this episode.",
160227
"error"
161228
)
162-
return
229+
return None
163230

164-
idx = self.ui.quality_selection_menu(
231+
result = self.ui.quality_selection_menu(
165232
selected_anime.title_en,
166233
selected_ep.display_num,
167234
available,
168235
self.rpc,
169236
selected_anime.thumbnail
170237
)
171238

172-
if idx == -1:
239+
if result == -1:
173240
sys.exit(0)
174-
if idx is None:
175-
return
241+
if result is None:
242+
return None
176243

244+
idx, action = result
177245
quality = available[idx]
178246
server_id = current_ep_data.get(quality.server_key)
179247

@@ -184,14 +252,25 @@ def handle_quality_selection(self, selected_anime, selected_ep, server_data):
184252
)
185253

186254
if direct_url:
187-
self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num} ({quality.name})")
188-
self.rpc.update_selecting_episode(selected_anime.title_en, selected_anime.thumbnail)
255+
filename = f"{selected_anime.title_en} - Ep {selected_ep.display_num} [{quality.name.split()[1]}].mp4"
256+
257+
if action == 'download':
258+
success = download_file(direct_url, filename, self.ui.console)
259+
# Save download as "watched" in history so you can jump to it next time
260+
self.history.mark_watched(selected_anime.id, selected_ep.display_num, selected_anime.title_en)
261+
return "download"
262+
else:
263+
self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num} ({quality.name})")
264+
self.history.mark_watched(selected_anime.id, selected_ep.display_num, selected_anime.title_en)
265+
self.rpc.update_selecting_episode(selected_anime.title_en, selected_anime.thumbnail)
266+
return "watch"
189267
else:
190268
self.ui.render_message(
191269
"✗ Error",
192270
"Failed to extract direct link from MediaFire.",
193271
"error"
194272
)
273+
return None
195274

196275
def handle_exit(self):
197276
self.ui.clear()

src/history.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import json
2+
import os
3+
from pathlib import Path
4+
from datetime import datetime
5+
6+
class HistoryManager:
7+
def __init__(self):
8+
self.history_file = self._get_history_path()
9+
self.history = self._load_history()
10+
11+
def _get_history_path(self) -> Path:
12+
# Save in the database directory
13+
base_dir = Path(__file__).parent.parent
14+
db_dir = base_dir / "database"
15+
db_dir.mkdir(exist_ok=True)
16+
return db_dir / "history.json"
17+
18+
def _load_history(self) -> dict:
19+
if not self.history_file.exists():
20+
return {}
21+
try:
22+
with open(self.history_file, 'r', encoding='utf-8') as f:
23+
return json.load(f)
24+
except Exception:
25+
return {}
26+
27+
def save_history(self):
28+
try:
29+
with open(self.history_file, 'w', encoding='utf-8') as f:
30+
json.dump(self.history, f, indent=4, ensure_ascii=False)
31+
except Exception:
32+
pass
33+
34+
def mark_watched(self, anime_id, episode_num, anime_title):
35+
"""Saves the last watched episode for a specific anime."""
36+
self.history[str(anime_id)] = {
37+
'episode': str(episode_num),
38+
'title': anime_title,
39+
'last_updated': datetime.now().isoformat()
40+
}
41+
self.save_history()
42+
43+
def get_last_watched(self, anime_id):
44+
"""Returns the episode number of the last watched episode."""
45+
data = self.history.get(str(anime_id))
46+
if data:
47+
return data.get('episode')
48+
return None

0 commit comments

Comments
 (0)