mmas 2.5.6__tar.gz
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.
- mmas-2.5.6/API/Hianime/__init__.py +0 -0
- mmas-2.5.6/API/Hianime/anicli.py +204 -0
- mmas-2.5.6/API/MD/__init__.py +0 -0
- mmas-2.5.6/API/MD/requests.py +137 -0
- mmas-2.5.6/API/Mal/__init__.py +0 -0
- mmas-2.5.6/API/Mal/auth.py +195 -0
- mmas-2.5.6/API/Mal/manage.py +308 -0
- mmas-2.5.6/API/Mal/requests.py +102 -0
- mmas-2.5.6/API/__init__.py +0 -0
- mmas-2.5.6/LICENSE +21 -0
- mmas-2.5.6/PKG-INFO +12 -0
- mmas-2.5.6/browserSetup.py +15 -0
- mmas-2.5.6/config.py +72 -0
- mmas-2.5.6/main.py +54 -0
- mmas-2.5.6/mmas.egg-info/PKG-INFO +12 -0
- mmas-2.5.6/mmas.egg-info/SOURCES.txt +20 -0
- mmas-2.5.6/mmas.egg-info/dependency_links.txt +1 -0
- mmas-2.5.6/mmas.egg-info/entry_points.txt +3 -0
- mmas-2.5.6/mmas.egg-info/requires.txt +6 -0
- mmas-2.5.6/mmas.egg-info/top_level.txt +4 -0
- mmas-2.5.6/setup.cfg +4 -0
- mmas-2.5.6/setup.py +23 -0
|
File without changes
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import re
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from playwright.sync_api import sync_playwright as spw
|
|
7
|
+
from bs4 import BeautifulSoup
|
|
8
|
+
from urllib.parse import quote, urljoin, unquote
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
OBF_KEY = b'otaku-embed-v1'
|
|
12
|
+
|
|
13
|
+
def _xor_bytes(data: bytes) -> bytes:
|
|
14
|
+
return bytes(b ^ OBF_KEY[i % len(OBF_KEY)] for i, b in enumerate(data))
|
|
15
|
+
|
|
16
|
+
def deobfuscate(blob: str) -> dict:
|
|
17
|
+
raw = base64.b64decode(unquote(blob))
|
|
18
|
+
return json.loads(_xor_bytes(raw).decode('utf-8'))
|
|
19
|
+
|
|
20
|
+
class EpisodeUnavailableError(ValueError):
|
|
21
|
+
"""MAL asked for an episode hianime doesn't have."""
|
|
22
|
+
BASE = "https://hianime.at"
|
|
23
|
+
SEARCH_URL = BASE + "/search?keyword={}"
|
|
24
|
+
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:155.0) "
|
|
25
|
+
"Gecko/20100101 Firefox/155.0")
|
|
26
|
+
|
|
27
|
+
session = requests.Session()
|
|
28
|
+
session.headers.update({
|
|
29
|
+
"User-Agent": UA,
|
|
30
|
+
"Accept": "application/json, text/plain, */*",
|
|
31
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
32
|
+
"Referer": "https://hianime.at/",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
def searchAnime(query: str) -> list:
|
|
36
|
+
url = SEARCH_URL.format(quote(query))
|
|
37
|
+
response = session.get(url, timeout=20)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
|
|
40
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
41
|
+
results = []
|
|
42
|
+
|
|
43
|
+
for item in soup.select(".film_list-wrap .flw-item, .film_list-wrap .film-poster-ahref"):
|
|
44
|
+
link = item.select_one("a[href]")
|
|
45
|
+
title = item.select_one(".film-name, .dynamic-name, [title]")
|
|
46
|
+
if not link:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
href = urljoin(BASE, link.get("href"))
|
|
50
|
+
name = (
|
|
51
|
+
title.get("title")
|
|
52
|
+
if title and title.get("title")
|
|
53
|
+
else title.get_text(" ", strip=True) if title else href
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
results.append({
|
|
57
|
+
"title": name,
|
|
58
|
+
"url": href
|
|
59
|
+
})
|
|
60
|
+
return results
|
|
61
|
+
|
|
62
|
+
def chooseAnime(query:str) -> str:
|
|
63
|
+
|
|
64
|
+
results = searchAnime(query)
|
|
65
|
+
|
|
66
|
+
if not results:
|
|
67
|
+
raise RuntimeError(f"no results for {query!r}")
|
|
68
|
+
|
|
69
|
+
for index, anime in enumerate(results, start=1):
|
|
70
|
+
print(f"{index}. {anime['title']}")
|
|
71
|
+
|
|
72
|
+
while True:
|
|
73
|
+
raw = input(f"Pick a number (1-{len(results)}): ").strip()
|
|
74
|
+
if not raw.isdigit():
|
|
75
|
+
print("Not a number, try again")
|
|
76
|
+
continue
|
|
77
|
+
choice = int(raw)
|
|
78
|
+
if 1 <= choice <= len(results):
|
|
79
|
+
return results[choice - 1]["url"]
|
|
80
|
+
print(f"Out of range, pick between 1 and {len(results)}.")
|
|
81
|
+
|
|
82
|
+
def getAnimeId(watchUrl):
|
|
83
|
+
|
|
84
|
+
m = re.search(r"-(\d+)(?:\?|$)", watchUrl)
|
|
85
|
+
if not m:
|
|
86
|
+
raise ValueError(f"no anime id in {watchUrl!r}")
|
|
87
|
+
|
|
88
|
+
return int(m.group(1))
|
|
89
|
+
|
|
90
|
+
def getEpisodes(watchUrl: str) -> list:
|
|
91
|
+
aId = getAnimeId(watchUrl)
|
|
92
|
+
r = session.get(f"{BASE}/api/theme/episode/list/{aId}",
|
|
93
|
+
headers={"Referer": BASE + "/"})
|
|
94
|
+
r.raise_for_status()
|
|
95
|
+
|
|
96
|
+
data = r.json()
|
|
97
|
+
|
|
98
|
+
if not data.get("status"):
|
|
99
|
+
raise RuntimeError(f"episode list returned status=false: {data}")
|
|
100
|
+
|
|
101
|
+
soup = BeautifulSoup(data["html"], "html.parser")
|
|
102
|
+
episodes =[]
|
|
103
|
+
for a in soup.select("a.ssl-item.ep-item"):
|
|
104
|
+
episodes.append({
|
|
105
|
+
"number": int(a["data-number"]),
|
|
106
|
+
"id": int(a["data-id"]),
|
|
107
|
+
"url": urljoin(BASE,a["href"]),
|
|
108
|
+
})
|
|
109
|
+
return episodes
|
|
110
|
+
|
|
111
|
+
def pickServer(html: str, serverName: str = "ZokoAnime", kind: str = "sub") -> str:
|
|
112
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
113
|
+
for item in soup.select(".server-item"):
|
|
114
|
+
if item.get("data-type") != kind:
|
|
115
|
+
continue
|
|
116
|
+
if item.get("data-server-name", "").strip() != serverName:
|
|
117
|
+
continue
|
|
118
|
+
raw = item.get("data-hash")
|
|
119
|
+
if not raw:
|
|
120
|
+
continue
|
|
121
|
+
return base64.b64decode(raw).decode("utf-8")
|
|
122
|
+
raise ValueError(f"server {serverName!r} ({kind}) not found")
|
|
123
|
+
|
|
124
|
+
def getServer(eId:int) -> str:
|
|
125
|
+
|
|
126
|
+
r = session.get(
|
|
127
|
+
"https://hianime.at/api/theme/episode/servers",
|
|
128
|
+
params={"episodeId": eId},
|
|
129
|
+
headers={"Referer": "https://hianime.at/"},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
r.raise_for_status()
|
|
133
|
+
data = r.json()
|
|
134
|
+
if not data.get("status"):
|
|
135
|
+
raise RuntimeError(f"servers endpoint returned status = false: {data}")
|
|
136
|
+
return data["html"]
|
|
137
|
+
|
|
138
|
+
def getEnglishSub(embedUrl: str) -> str | None:
|
|
139
|
+
r = session.get(embedUrl, headers={"Referer": BASE + "/"}, timeout=20)
|
|
140
|
+
m = re.search(r'window\.__P\s*=\s*"([^"]+)"', r.text)
|
|
141
|
+
if not m:
|
|
142
|
+
return None
|
|
143
|
+
config = deobfuscate(m.group(1))
|
|
144
|
+
for s in config.get("subtitles", []):
|
|
145
|
+
if s.get("label", "").strip().lower() == "english":
|
|
146
|
+
return s["src"]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def getM3u8(embedUrl: str, tout:int = 30000) ->tuple :
|
|
150
|
+
master= {}
|
|
151
|
+
referer = {}
|
|
152
|
+
subUrl = getEnglishSub(embedUrl)
|
|
153
|
+
|
|
154
|
+
with spw() as p:
|
|
155
|
+
browser = p.chromium.launch(headless=True)
|
|
156
|
+
ctx = browser.new_context(user_agent=UA)
|
|
157
|
+
page = ctx.new_page()
|
|
158
|
+
|
|
159
|
+
def onResp(resp):
|
|
160
|
+
url = resp.url
|
|
161
|
+
if "master.m3u8" in url and "master" not in master:
|
|
162
|
+
master["master" ] = url
|
|
163
|
+
try:
|
|
164
|
+
headers= resp.request.headers
|
|
165
|
+
if "referer" in headers:
|
|
166
|
+
referer["referer"] = headers["referer"]
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
page.on("response", onResp)
|
|
171
|
+
page.goto(embedUrl, wait_until="networkidle", timeout=tout)
|
|
172
|
+
page.wait_for_timeout(3000)
|
|
173
|
+
browser.close()
|
|
174
|
+
|
|
175
|
+
if "master" not in master:
|
|
176
|
+
raise RuntimeError("master.m3u8 not captured. Video player embed may have changed")
|
|
177
|
+
return (master["master"], referer.get("referer", "https://megacloud.tv/"), subUrl)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def primeSession(query: str, ep: int, type: str = "sub", watchUrl: str = None) -> tuple :
|
|
185
|
+
if watchUrl is None:
|
|
186
|
+
watchUrl = chooseAnime(query)
|
|
187
|
+
|
|
188
|
+
episodes = getEpisodes(watchUrl)
|
|
189
|
+
firstEp = episodes[0]['id']
|
|
190
|
+
lastEp = episodes[-1]['number']
|
|
191
|
+
if ep > lastEp:
|
|
192
|
+
raise EpisodeUnavailableError(
|
|
193
|
+
f"{ep} does not exist in Hianime. This is the case if:\n"
|
|
194
|
+
" 1. You have finished the Anime — check the MAL episode counter.\n"
|
|
195
|
+
" 2. The Anime is still airing and this episode hasn't released yet.\n"
|
|
196
|
+
" 3. The episode exists but hianime hasn't uploaded it yet."
|
|
197
|
+
)
|
|
198
|
+
ep = ep-1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
embed = pickServer(getServer(firstEp+ep), "ZokoAnime", type)
|
|
202
|
+
master, referer, subUrl = getM3u8(embed)
|
|
203
|
+
|
|
204
|
+
return master, referer, subUrl
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import requests
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import subprocess
|
|
6
|
+
import re
|
|
7
|
+
from config import manga_folder, mpv_path, JsonIO
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MangaDexAPI:
|
|
12
|
+
def __init__(self, lang="en"):
|
|
13
|
+
self.base_url = "https://api.mangadex.org"
|
|
14
|
+
self.lang = lang
|
|
15
|
+
|
|
16
|
+
def search_manga(self, title, limit):
|
|
17
|
+
|
|
18
|
+
order = {
|
|
19
|
+
"relevance": "desc",
|
|
20
|
+
}
|
|
21
|
+
final_order_query = {}
|
|
22
|
+
|
|
23
|
+
for key,value in order.items():
|
|
24
|
+
final_order_query[f"order[{key}]"] = value
|
|
25
|
+
|
|
26
|
+
r = requests.get(
|
|
27
|
+
f"{self.base_url}/manga",
|
|
28
|
+
params={
|
|
29
|
+
**{
|
|
30
|
+
"title": title,
|
|
31
|
+
"limit": limit
|
|
32
|
+
},
|
|
33
|
+
**final_order_query,
|
|
34
|
+
})
|
|
35
|
+
return r
|
|
36
|
+
def get_chapters(self, offset):
|
|
37
|
+
with open(JsonIO / "filteredList.json", "r") as f:
|
|
38
|
+
data = json.load(f)
|
|
39
|
+
ID = data[0]["id"]
|
|
40
|
+
r = requests.get(
|
|
41
|
+
f"{self.base_url}/manga/{ID}/feed",
|
|
42
|
+
params={
|
|
43
|
+
"translatedLanguage[]": ["en"],
|
|
44
|
+
"order[chapter]": "asc",
|
|
45
|
+
"limit": 1,
|
|
46
|
+
"offset": offset
|
|
47
|
+
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
chapters = r.json()
|
|
51
|
+
if chapters:
|
|
52
|
+
|
|
53
|
+
chapter_id = chapters ["data"][0]["id"]
|
|
54
|
+
else: print("x")
|
|
55
|
+
|
|
56
|
+
rd = requests.get(
|
|
57
|
+
f"{self.base_url}/at-home/server/{chapter_id}"
|
|
58
|
+
)
|
|
59
|
+
rd.raise_for_status
|
|
60
|
+
|
|
61
|
+
data= rd.json()
|
|
62
|
+
baseUrl = data["baseUrl"]
|
|
63
|
+
hash = data["chapter"]["hash"]
|
|
64
|
+
file_names = data["chapter"]["data"]
|
|
65
|
+
|
|
66
|
+
folder = os.path.join(manga_folder, f'chapter_{chapter_id[:8]}')
|
|
67
|
+
os.makedirs(folder, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
start_time = time.time()
|
|
70
|
+
|
|
71
|
+
for i, file_name in enumerate(file_names, 1):
|
|
72
|
+
url = f"{baseUrl}/data/{hash}/{file_name}"
|
|
73
|
+
response = requests.get(url)
|
|
74
|
+
response.raise_for_status()
|
|
75
|
+
|
|
76
|
+
file_path = os.path.join(folder, f"page_{i}.jpg")
|
|
77
|
+
with open(file_path, "wb") as f:
|
|
78
|
+
f.write(response.content)
|
|
79
|
+
|
|
80
|
+
elapsed = time.time() - start_time
|
|
81
|
+
print(f"Downloaded pages in {elapsed:.2f} seconds")
|
|
82
|
+
|
|
83
|
+
print(folder)
|
|
84
|
+
return folder
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def simple_maga_id(self, title, rp):
|
|
88
|
+
data = rp
|
|
89
|
+
|
|
90
|
+
filtered = []
|
|
91
|
+
for manga in data['data']:
|
|
92
|
+
attrs = manga['attributes']
|
|
93
|
+
has_english = (
|
|
94
|
+
attrs.get('title', {}).get('en') or
|
|
95
|
+
any(alt.get('en') for alt in attrs.get('altTitles', [])) or
|
|
96
|
+
attrs.get('description', {}).get('en')
|
|
97
|
+
)
|
|
98
|
+
if has_english:
|
|
99
|
+
simplified = {
|
|
100
|
+
"id": manga['id'],
|
|
101
|
+
"title": attrs.get('title', {}),
|
|
102
|
+
"altTitles": attrs.get('altTitles', []),
|
|
103
|
+
"lastChapter": attrs.get('lastChapter'),
|
|
104
|
+
"status": attrs.get('status'),
|
|
105
|
+
"availableTranslatedLanguages": attrs.get('availableTranslatedLanguages', [])
|
|
106
|
+
}
|
|
107
|
+
filtered.append(simplified)
|
|
108
|
+
|
|
109
|
+
if filtered:
|
|
110
|
+
etitles = [filtered[0]['title'].get('en')] + [alt.get('en') for alt in filtered[0]['altTitles'] if alt.get('en')]
|
|
111
|
+
|
|
112
|
+
if title in etitles:
|
|
113
|
+
filtered = [filtered[0]]
|
|
114
|
+
|
|
115
|
+
with open(JsonIO / "filteredList.json", "w") as f:
|
|
116
|
+
json.dump(filtered, f, indent=2)
|
|
117
|
+
|
|
118
|
+
def load_manga(self, title, chapter):
|
|
119
|
+
response = self.search_manga(title, 5)
|
|
120
|
+
print(self.simple_maga_id(title, response.json()))
|
|
121
|
+
|
|
122
|
+
manga_path = Path(self.get_chapters(chapter))
|
|
123
|
+
image_files = [f for f in Path(manga_path).iterdir() if f.suffix.lower() in ('.jpg', '.jpeg', '.png')]
|
|
124
|
+
if not image_files:
|
|
125
|
+
print(f"No Manga downloaded. Check if Chapter {chapter} has an english translated chapter on Mangadex")
|
|
126
|
+
return None
|
|
127
|
+
else:
|
|
128
|
+
images = sorted(image_files, key=lambda x: int(re.search(r'\d+', x.stem).group()))
|
|
129
|
+
mpv_process=subprocess.Popen([
|
|
130
|
+
mpv_path,
|
|
131
|
+
"--fs",
|
|
132
|
+
"--keep-open=no",
|
|
133
|
+
"--image-display-duration=inf",
|
|
134
|
+
*[str(img) for img in images]
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
return manga_path, mpv_process
|
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
|
+
from urllib.parse import urlencode, urlparse, parse_qs
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from config import EKEY, TOKEN_PATH, get_client_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
AUTH_URL = "https://myanimelist.net/v1/oauth2/authorize"
|
|
18
|
+
TOKEN_URL = "https://myanimelist.net/v1/oauth2/token"
|
|
19
|
+
REDIRECT_URI = "http://localhost:8080/callback"
|
|
20
|
+
PORT = 8080
|
|
21
|
+
REFRESH_WARN_SECONDS = 24 * 3600 # pretty much the time when you should refresh your tokens.
|
|
22
|
+
|
|
23
|
+
def _load_tokens() -> dict | None:
|
|
24
|
+
if not TOKEN_PATH.exists():
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
blob = TOKEN_PATH.read_bytes()
|
|
28
|
+
return json.loads(EKEY.decrypt(blob).decode())
|
|
29
|
+
except Exception:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
def _save_tokens(data: dict) -> None:
|
|
33
|
+
blob = EKEY.encrypt(json.dumps(data).encode())
|
|
34
|
+
TOKEN_PATH.write_bytes(blob)
|
|
35
|
+
try:
|
|
36
|
+
os.chmod(TOKEN_PATH, 0o600)
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _b64_url(data: bytes) -> str:
|
|
43
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
44
|
+
|
|
45
|
+
def _new_pkce_pair() -> tuple[str, str]:
|
|
46
|
+
verifier = _b64_url(secrets.token_bytes(64))
|
|
47
|
+
return verifier, verifier
|
|
48
|
+
|
|
49
|
+
class _CallBackHandler(BaseHTTPRequestHandler):
|
|
50
|
+
captured:dict = {}
|
|
51
|
+
|
|
52
|
+
def do_GET(self):
|
|
53
|
+
parsed = urlparse(self.path)
|
|
54
|
+
if parsed.path != urlparse(REDIRECT_URI).path:
|
|
55
|
+
self.send_response(404); self.end_headers(); return
|
|
56
|
+
|
|
57
|
+
params = parse_qs(parsed.query)
|
|
58
|
+
_CallBackHandler.captured = {
|
|
59
|
+
"code" : params.get("code" , [None])[0],
|
|
60
|
+
"state": params.get("state", [None])[0],
|
|
61
|
+
"error": params.get("error", [None])[0],
|
|
62
|
+
}
|
|
63
|
+
self.send_response(200)
|
|
64
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
65
|
+
self.end_headers()
|
|
66
|
+
self.wfile.write(b"<h2>You can close this tab and return to the terminal.</h2>")
|
|
67
|
+
|
|
68
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
69
|
+
|
|
70
|
+
def log_message(self, *a, **k):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def _wait_for_callback(timeout: int = 300) -> dict:
|
|
74
|
+
_CallBackHandler.captured = {}
|
|
75
|
+
server = ThreadingHTTPServer(("127.0.0.1", PORT), _CallBackHandler)
|
|
76
|
+
server.timeout = timeout
|
|
77
|
+
|
|
78
|
+
t = threading.Thread(target=server.serve_forever, daemon=True)
|
|
79
|
+
t.start()
|
|
80
|
+
t.join(timeout)
|
|
81
|
+
server.server_close()
|
|
82
|
+
return _CallBackHandler.captured
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_oauth_flow(no_browser:bool = False) -> dict:
|
|
86
|
+
verifier, challenge = _new_pkce_pair()
|
|
87
|
+
state = _b64_url(secrets.token_bytes(16))
|
|
88
|
+
client_id = get_client_id()
|
|
89
|
+
|
|
90
|
+
params = {
|
|
91
|
+
"response_type": "code",
|
|
92
|
+
"client_id": client_id,
|
|
93
|
+
"redirect_uri": REDIRECT_URI,
|
|
94
|
+
"code_challenge": challenge,
|
|
95
|
+
"code_challenge_method": "plain",
|
|
96
|
+
"state": state,
|
|
97
|
+
}
|
|
98
|
+
url = f"{AUTH_URL}?{urlencode(params)}"
|
|
99
|
+
|
|
100
|
+
if no_browser:
|
|
101
|
+
print(f"Open the following URL in your browser (on any machine):\n {url} \nAfter authorizing, MAL will redirect to {REDIRECT_URI} \n",
|
|
102
|
+
"which won't load. Copy the 'code=' value out of the URL bar and paste it here.")
|
|
103
|
+
code = input("code: ").strip()
|
|
104
|
+
else:
|
|
105
|
+
print("Opening browser for MAL authorization...")
|
|
106
|
+
webbrowser.open(url)
|
|
107
|
+
captured = _wait_for_callback()
|
|
108
|
+
if captured.get("error"):
|
|
109
|
+
raise RuntimeError(f"OAuth error: {captured['error']}")
|
|
110
|
+
if captured.get("state") != state:
|
|
111
|
+
raise RuntimeError("state mismatch, possible CSRF, aborting")
|
|
112
|
+
code = captured.get("code")
|
|
113
|
+
|
|
114
|
+
if not code:
|
|
115
|
+
raise RuntimeError("No authorization code received")
|
|
116
|
+
|
|
117
|
+
r = requests.post(
|
|
118
|
+
TOKEN_URL,
|
|
119
|
+
auth=(client_id, ""),
|
|
120
|
+
data={
|
|
121
|
+
"grant_type": "authorization_code",
|
|
122
|
+
"code": code,
|
|
123
|
+
"redirect_uri": REDIRECT_URI,
|
|
124
|
+
"code_verifier": verifier,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
r.raise_for_status()
|
|
128
|
+
tok = r.json()
|
|
129
|
+
tok["verifier"] = verifier
|
|
130
|
+
tok["obtained_at"] = time.time()
|
|
131
|
+
tok["refresh_obtained_at"]= time.time()
|
|
132
|
+
tok["access_expires_at"] = time.time() + tok["expires_in"]
|
|
133
|
+
tok["refresh_expires_at"] = time.time() + tok.get("refresh_token_expires_in", 30*24*3600)
|
|
134
|
+
_save_tokens(tok)
|
|
135
|
+
return tok
|
|
136
|
+
|
|
137
|
+
def _refresh_tokens(tok: dict) -> dict:
|
|
138
|
+
client_id = get_client_id()
|
|
139
|
+
r = requests.post(
|
|
140
|
+
TOKEN_URL,
|
|
141
|
+
auth=(client_id, ""),
|
|
142
|
+
data={
|
|
143
|
+
"grant_type": "refresh_token",
|
|
144
|
+
"refresh_token": tok["refresh_token"],
|
|
145
|
+
})
|
|
146
|
+
if r.status_code == 400 and "invalid_grant" in r.text:
|
|
147
|
+
raise RuntimeError("refresh token expired. Reauthorize yourself")
|
|
148
|
+
|
|
149
|
+
r.raise_for_status()
|
|
150
|
+
new = r.json()
|
|
151
|
+
|
|
152
|
+
new["verifier"] =tok.get("verifier", "")
|
|
153
|
+
new["obtained_at"] = time.time()
|
|
154
|
+
new["refresh_obtained_at"]= time.time()
|
|
155
|
+
new["access_expires_at"] = time.time() + new["expires_in"]
|
|
156
|
+
new["refresh_expires_at"] = time.time() + new.get("refresh_token_expires_in", 30*24*3600)
|
|
157
|
+
_save_tokens(new)
|
|
158
|
+
return new
|
|
159
|
+
|
|
160
|
+
def get_access_token(interactive: bool = True) -> str:
|
|
161
|
+
tok= _load_tokens()
|
|
162
|
+
|
|
163
|
+
if tok is None:
|
|
164
|
+
if not interactive:
|
|
165
|
+
raise RuntimeError("No tokens and not allowed to run OAuth flow")
|
|
166
|
+
tok = run_oauth_flow()
|
|
167
|
+
|
|
168
|
+
if time.time() > tok["access_expires_at"] - 300:
|
|
169
|
+
try:
|
|
170
|
+
tok = _refresh_tokens(tok)
|
|
171
|
+
except RuntimeError:
|
|
172
|
+
if not interactive:
|
|
173
|
+
raise
|
|
174
|
+
print("Refresh token expired. Re-authorizing...")
|
|
175
|
+
tok = run_oauth_flow()
|
|
176
|
+
|
|
177
|
+
elif time.time() > tok["refresh_expires_at"] - REFRESH_WARN_SECONDS:
|
|
178
|
+
try:
|
|
179
|
+
tok = _refresh_tokens(tok)
|
|
180
|
+
except RuntimeError:
|
|
181
|
+
if not interactive:
|
|
182
|
+
raise
|
|
183
|
+
print("Refresh token expired. Re-authorizing...")
|
|
184
|
+
tok = run_oauth_flow()
|
|
185
|
+
return tok["access_token"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import time
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from API.Mal.requests import reqMal
|
|
10
|
+
from API.MD.requests import MangaDexAPI as reqMd
|
|
11
|
+
from API.Hianime.anicli import primeSession, searchAnime as hianimeSearch
|
|
12
|
+
from config import mpv_path, JsonIO, hianime_urls
|
|
13
|
+
|
|
14
|
+
class ManageMal:
|
|
15
|
+
"""This handles all the shared Logic"""
|
|
16
|
+
|
|
17
|
+
def __repr__(self):
|
|
18
|
+
media = getattr(self, "media", None)
|
|
19
|
+
return f"{type(self).__name__}(media={media!r})"
|
|
20
|
+
|
|
21
|
+
MEDIA: str
|
|
22
|
+
COUNT_FIELD:str
|
|
23
|
+
UNIT: str
|
|
24
|
+
VERB: str
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
if self.MEDIA is None:
|
|
28
|
+
raise TypeError(f"{type(self).__name__} mnust define MEDIA")
|
|
29
|
+
self.media = self.MEDIA
|
|
30
|
+
self.mal = reqMal(self.MEDIA)
|
|
31
|
+
self.list_path = JsonIO / f"{self.media}List.json"
|
|
32
|
+
self.titles_path = JsonIO / f"{self.media}EnTitles.json"
|
|
33
|
+
|
|
34
|
+
def get_all_en_titles(self):
|
|
35
|
+
if not self.list_path.exists():
|
|
36
|
+
self.mal.get_list()
|
|
37
|
+
if not self.list_path.exists():
|
|
38
|
+
raise RuntimeError(f"{self.list_path} was not created.")
|
|
39
|
+
|
|
40
|
+
with open(self.list_path, "r") as f:
|
|
41
|
+
data = json.load(f)
|
|
42
|
+
|
|
43
|
+
new_data = {
|
|
44
|
+
"media": self.media,
|
|
45
|
+
"fetched_at": time.time(),
|
|
46
|
+
"entries": {},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for entry in data["data"]:
|
|
50
|
+
node = entry["node"]
|
|
51
|
+
data_id = node["id"]
|
|
52
|
+
titles = node["alternative_titles"]
|
|
53
|
+
status_dict = node["my_list_status"]
|
|
54
|
+
|
|
55
|
+
all_titles = []
|
|
56
|
+
en = titles.get("en")
|
|
57
|
+
synonyms= titles.get("synonyms")
|
|
58
|
+
|
|
59
|
+
if en:
|
|
60
|
+
all_titles.append(en)
|
|
61
|
+
if synonyms:
|
|
62
|
+
all_titles.extend(synonyms)
|
|
63
|
+
|
|
64
|
+
count = status_dict.get(self.COUNT_FIELD, 0)
|
|
65
|
+
status = status_dict.get("status")
|
|
66
|
+
|
|
67
|
+
new_data["entries"][data_id] = {
|
|
68
|
+
"type": self.media, #purely for readability
|
|
69
|
+
"id": data_id,
|
|
70
|
+
"titles": all_titles,
|
|
71
|
+
"main_title": node["title"],
|
|
72
|
+
self.COUNT_FIELD: count,
|
|
73
|
+
"status": status,
|
|
74
|
+
}
|
|
75
|
+
with open(self.titles_path, "w") as f:
|
|
76
|
+
json.dump(new_data, f, indent=2)
|
|
77
|
+
|
|
78
|
+
def search_data(self, query: str):
|
|
79
|
+
if not self.titles_path.exists():
|
|
80
|
+
self.get_all_en_titles()
|
|
81
|
+
if not self.titles_path.exists():
|
|
82
|
+
raise RuntimeError("English titles file missing and/or not created.")
|
|
83
|
+
|
|
84
|
+
with open(self.titles_path,"r") as f:
|
|
85
|
+
root = json.load(f)
|
|
86
|
+
|
|
87
|
+
if root:
|
|
88
|
+
time_stamp = root["fetched_at"]
|
|
89
|
+
if time.time() - time_stamp > 86400:
|
|
90
|
+
print("data out of date, getting new")
|
|
91
|
+
self.get_all_en_titles()
|
|
92
|
+
with open(self.titles_path, "r") as f:
|
|
93
|
+
root = json.load(f)
|
|
94
|
+
|
|
95
|
+
entries = root["entries"]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
results = []
|
|
99
|
+
|
|
100
|
+
for info in entries.values():
|
|
101
|
+
all_titles = info["titles"] + [info["main_title"]]
|
|
102
|
+
if any(query.lower() in t.lower() for t in all_titles):
|
|
103
|
+
results.append(info)
|
|
104
|
+
if results:
|
|
105
|
+
return results
|
|
106
|
+
|
|
107
|
+
print(f"{query} not found in your list.")
|
|
108
|
+
answer = input("Continue search outside of your list? [y/n]: ").strip().lower()
|
|
109
|
+
|
|
110
|
+
if answer == "y":
|
|
111
|
+
remote = self.mal.look_up_entry(query)
|
|
112
|
+
for info in remote.values():
|
|
113
|
+
all_titles = info["titles"] + [info["main_title"]]
|
|
114
|
+
if any(query.lower() in t.lower() for t in all_titles):
|
|
115
|
+
results.append(info)
|
|
116
|
+
if results:
|
|
117
|
+
return results
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
if answer == "n":
|
|
121
|
+
print("Okay, exiting")
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
print("Unknown answer, exiting")
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
def search_match(self, query, mode: str = ""):
|
|
128
|
+
result = self.choose_mal(query)
|
|
129
|
+
if result is None:
|
|
130
|
+
print(f"Check if '{query}' is in your MAL list.")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
mal_id = result["id"]
|
|
134
|
+
mal_title = result["main_title"]
|
|
135
|
+
number = result[self.COUNT_FIELD] + 1
|
|
136
|
+
|
|
137
|
+
while True:
|
|
138
|
+
played = self._play(mal_title, mal_id, number, mode)
|
|
139
|
+
if played is None:
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
response = input(
|
|
143
|
+
f"Did you finish {self.UNIT.lower()} {number}? [y/n]: "
|
|
144
|
+
).strip().lower()
|
|
145
|
+
|
|
146
|
+
if response == "y":
|
|
147
|
+
print("Updating MAL status...")
|
|
148
|
+
time.sleep(1)
|
|
149
|
+
if self.mal.update_mal(mal_id, number):
|
|
150
|
+
print("Updated list.")
|
|
151
|
+
else:
|
|
152
|
+
print("MAL rejected the update")
|
|
153
|
+
|
|
154
|
+
ask_next = input(
|
|
155
|
+
f"Continue with {self.UNIT} {number + 1}? [y/n] "
|
|
156
|
+
).strip().lower()
|
|
157
|
+
if ask_next == "y":
|
|
158
|
+
number += 1
|
|
159
|
+
continue
|
|
160
|
+
print("Goodbye.")
|
|
161
|
+
break
|
|
162
|
+
if response == "n":
|
|
163
|
+
print(f"{self.UNIT} not finished. No changes saved.")
|
|
164
|
+
break
|
|
165
|
+
print("Invalid input.")
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def choose_mal(self, query):
|
|
170
|
+
results = self.search_data(query)
|
|
171
|
+
if not results:
|
|
172
|
+
print("No results")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
for i, info in enumerate(results, start=1):
|
|
176
|
+
print(f"{i}. {info['main_title']} ({self.VERB}: {info[self.COUNT_FIELD]})")
|
|
177
|
+
|
|
178
|
+
while True:
|
|
179
|
+
raw = input(f"Pick a number (1-{len(results)}): ").strip()
|
|
180
|
+
if not raw.isdigit():
|
|
181
|
+
print("Not a number, try again.")
|
|
182
|
+
continue
|
|
183
|
+
choice = int(raw)
|
|
184
|
+
|
|
185
|
+
if 1 <= choice <= len(results):
|
|
186
|
+
return results[choice-1]
|
|
187
|
+
print(f"Out of range. Pick between 1 and {len(results)}!")
|
|
188
|
+
|
|
189
|
+
class ManageAnime(ManageMal):
|
|
190
|
+
"""Subclass to handle any Anime related call"""
|
|
191
|
+
|
|
192
|
+
MEDIA = "anime"
|
|
193
|
+
COUNT_FIELD = "num_episodes_watched"
|
|
194
|
+
UNIT = "Episode"
|
|
195
|
+
VERB = "Watched"
|
|
196
|
+
|
|
197
|
+
def _play(self, mal_title:str, mal_id:int, number:int, mode:str = ""):
|
|
198
|
+
watch_url = self.resolve_hianime_watch_url(mal_title, mal_id)
|
|
199
|
+
print(f"Loading {mal_title}, Episode {number} in {mode}")
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
master, referer, sub_url = primeSession(
|
|
203
|
+
mal_title, number, mode, watchUrl=watch_url
|
|
204
|
+
)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
print(f"Failed to prime session: {e}")
|
|
207
|
+
return None
|
|
208
|
+
mpv_args = [
|
|
209
|
+
mpv_path,
|
|
210
|
+
"--fs",
|
|
211
|
+
"--keep-open=no",
|
|
212
|
+
f"--http-header-fields=Referer: {referer}",
|
|
213
|
+
"--sid=auto",
|
|
214
|
+
"--slang=en,eng,english",
|
|
215
|
+
"--sub-file-paths=",
|
|
216
|
+
"--cache=yes",
|
|
217
|
+
"--force-window=yes",
|
|
218
|
+
"--demuxer-max-bytes=100MiB",
|
|
219
|
+
master,
|
|
220
|
+
]
|
|
221
|
+
if sub_url:
|
|
222
|
+
mpv_args.append(f"--sub-file={sub_url}")
|
|
223
|
+
mpv_args.append(f"--sid=1")
|
|
224
|
+
|
|
225
|
+
mpv_process = subprocess.Popen(mpv_args)
|
|
226
|
+
mpv_process.wait()
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def resolve_hianime_watch_url(self, mal_title:str, mal_id):
|
|
231
|
+
"""Search Hianime by the MAL title"""
|
|
232
|
+
cache_path = hianime_urls
|
|
233
|
+
key = str(mal_id)
|
|
234
|
+
|
|
235
|
+
cache = {}
|
|
236
|
+
|
|
237
|
+
if cache_path.exists():
|
|
238
|
+
try:
|
|
239
|
+
with open(cache_path) as f:
|
|
240
|
+
cache = json.load(f)
|
|
241
|
+
except (json.JSONDecodeError, OSError):
|
|
242
|
+
cache = {}
|
|
243
|
+
if key in cache:
|
|
244
|
+
return cache[key]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
hits = hianimeSearch(mal_title)
|
|
248
|
+
if not hits:
|
|
249
|
+
raise RuntimeError(f"Hianime has no results for {mal_title!r}")
|
|
250
|
+
|
|
251
|
+
def _norm(s):
|
|
252
|
+
return re.sub(r"[^a-z0-9]+", "", s.lower())
|
|
253
|
+
|
|
254
|
+
target = _norm(mal_title)
|
|
255
|
+
exact = [h for h in hits if _norm(h["title"]) == target]
|
|
256
|
+
if len(exact) == 1:
|
|
257
|
+
print(f"Using Hianime match: {exact[0]['title']}")
|
|
258
|
+
url = exact[0]['url']
|
|
259
|
+
|
|
260
|
+
elif len(hits) == 1:
|
|
261
|
+
print(f"Using Hianime match: {hits[0]['title']}")
|
|
262
|
+
url = hits[0]['url']
|
|
263
|
+
else:
|
|
264
|
+
for i, h in enumerate(hits, start=1):
|
|
265
|
+
print(f"{i}. {h['title']}")
|
|
266
|
+
|
|
267
|
+
while True:
|
|
268
|
+
raw = input(f"Pick a Hianime result (1-{len(hits)})").strip()
|
|
269
|
+
if not raw.isdigit():
|
|
270
|
+
print("Not a number, try again.")
|
|
271
|
+
continue
|
|
272
|
+
choice = int(raw)
|
|
273
|
+
if 1 <= choice <= len(hits):
|
|
274
|
+
url = hits[choice - 1]["url"]
|
|
275
|
+
break
|
|
276
|
+
print(f"Out of range, pick between 1 and {len(hits)}.")
|
|
277
|
+
cache[key] = url
|
|
278
|
+
with open(cache_path, "w") as f:
|
|
279
|
+
json.dump(cache, f, indent=2)
|
|
280
|
+
|
|
281
|
+
return url
|
|
282
|
+
|
|
283
|
+
class ManageManga(ManageMal):
|
|
284
|
+
"""Subclass to handle any MAnga related call"""
|
|
285
|
+
|
|
286
|
+
MEDIA = "manga"
|
|
287
|
+
COUNT_FIELD = "num_chapters_read"
|
|
288
|
+
UNIT = "Chapter"
|
|
289
|
+
VERB = "Read"
|
|
290
|
+
|
|
291
|
+
def __init__(self):
|
|
292
|
+
super().__init__()
|
|
293
|
+
self.md = reqMd()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _play(self, mal_title, mal_id, number, mode):
|
|
298
|
+
print(f"Loading {mal_title}, Chapter {number}")
|
|
299
|
+
folder, proc = self.md.load_manga(mal_title, number)
|
|
300
|
+
|
|
301
|
+
if proc is None:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
proc.wait()
|
|
306
|
+
finally:
|
|
307
|
+
shutil.rmtree(folder, ignore_erros = True)
|
|
308
|
+
return True
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from API.Mal.auth import get_access_token
|
|
6
|
+
from config import JsonIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class reqMal():
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __init__(self, media_type:str):
|
|
14
|
+
if media_type not in ("anime", "manga"):
|
|
15
|
+
raise ValueError(
|
|
16
|
+
f"I made a mistake in the code. Please report it. "
|
|
17
|
+
f"Unknown mediaType: {media_type!r}"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if media_type == "manga":
|
|
21
|
+
self.ce_unit, self.rw_unit = "chapters", "read"
|
|
22
|
+
else: self.ce_unit, self.rw_unit = "episodes", "watched"
|
|
23
|
+
|
|
24
|
+
self.media = media_type
|
|
25
|
+
self.url = "https://api.myanimelist.net/v2"
|
|
26
|
+
|
|
27
|
+
def __repr__(self):
|
|
28
|
+
return f"reqMal(media={self.media!r})"
|
|
29
|
+
|
|
30
|
+
def _auth_headers(self):
|
|
31
|
+
return {"Authorization": f"Bearer {get_access_token()}"}
|
|
32
|
+
|
|
33
|
+
def get_list(self):
|
|
34
|
+
r=requests.get(f"{self.url}/users/@me/{self.media}list",
|
|
35
|
+
headers=self._auth_headers(),
|
|
36
|
+
params={
|
|
37
|
+
"sort":f"{self.media}_title",
|
|
38
|
+
"fields": "alternative_titles, my_list_status",
|
|
39
|
+
"limit": 200,
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
r.raise_for_status()
|
|
43
|
+
|
|
44
|
+
with open(JsonIO / f"{self.media}List.json", "w") as f:
|
|
45
|
+
json.dump(r.json(),f,indent=2)
|
|
46
|
+
print(f"Successfully fetched {len(r.json()['data'])} {self.media} entries")
|
|
47
|
+
return r.json()
|
|
48
|
+
|
|
49
|
+
def look_up_entry(self, query):
|
|
50
|
+
"""Search up entries not found in User's active list """
|
|
51
|
+
r = requests.get(
|
|
52
|
+
f"{self.url}/{self.media}",
|
|
53
|
+
headers=self._auth_headers(),
|
|
54
|
+
params={
|
|
55
|
+
"q": query,
|
|
56
|
+
"limit": 10,
|
|
57
|
+
"fields": "alternative_titles,my_list_status"
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
r.raise_for_status()
|
|
61
|
+
data = {}
|
|
62
|
+
for entry in r.json()["data"]:
|
|
63
|
+
node = entry["node"]
|
|
64
|
+
alt = node.get("alternative_titles", {})
|
|
65
|
+
status = node.get("my_list_status") or {}
|
|
66
|
+
data_id = node["id"]
|
|
67
|
+
|
|
68
|
+
all_titles = []
|
|
69
|
+
if alt.get("en"):
|
|
70
|
+
all_titles.append(alt["en"])
|
|
71
|
+
all_titles.extend(alt.get("synonyms", []))
|
|
72
|
+
|
|
73
|
+
count_field = f"num_{self.ce_unit}_{self.rw_unit}"
|
|
74
|
+
count = status.get(count_field, 0)
|
|
75
|
+
kind = ""
|
|
76
|
+
if self.media == "anime":
|
|
77
|
+
kind = "episodesWatched"
|
|
78
|
+
else: kind ="chaptersRead"
|
|
79
|
+
|
|
80
|
+
data[data_id] = {
|
|
81
|
+
"id": data_id,
|
|
82
|
+
"titles": all_titles,
|
|
83
|
+
"mainTitle":entry["node"]["title"],
|
|
84
|
+
kind: count,
|
|
85
|
+
"status": status.get("status"),
|
|
86
|
+
"fetchedAt":time.time()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
def update_mal(self, mal_id, progress):
|
|
93
|
+
r = requests.patch(
|
|
94
|
+
f"{self.url}/{self.media}/{mal_id}/my_list_status",
|
|
95
|
+
headers=self._auth_headers(),
|
|
96
|
+
data={
|
|
97
|
+
f"num_{self.ce_unit}_{self.rw_unit}": progress,
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
r.raise_for_status()
|
|
102
|
+
return r.ok
|
|
File without changes
|
mmas-2.5.6/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luca Barthel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mmas-2.5.6/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mmas
|
|
3
|
+
Version: 2.5.6
|
|
4
|
+
License-File: LICENSE
|
|
5
|
+
Requires-Dist: click>=8.0.0
|
|
6
|
+
Requires-Dist: requests>=2.28.0
|
|
7
|
+
Requires-Dist: cryptography>=39.0.0
|
|
8
|
+
Requires-Dist: platformdirs>=4.11.0
|
|
9
|
+
Requires-Dist: playwright>=1.63.0
|
|
10
|
+
Requires-Dist: beautifulsoup4>=4.15.0
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
Dynamic: requires-dist
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
"""Download Playwright browsers for the mmas tool."""
|
|
8
|
+
print("Downloading Playwright browsers (this may take a few minutes)...")
|
|
9
|
+
try:
|
|
10
|
+
subprocess.check_call([sys.executable, "-m", "playwright", "install"])
|
|
11
|
+
print("Playwright browsers installed successfully.")
|
|
12
|
+
except subprocess.CalledProcessError as e:
|
|
13
|
+
print(f"Failed to install Playwright browsers: {e}")
|
|
14
|
+
print("This tool needs Playwright browsers. You can retry manually with: playwright install")
|
|
15
|
+
sys.exit(1)
|
mmas-2.5.6/config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from cryptography.fernet import Fernet
|
|
6
|
+
from platformdirs import user_config_dir, user_data_dir
|
|
7
|
+
|
|
8
|
+
APP_NAME = "mmas" #MangaMalAniSearch
|
|
9
|
+
APP_AUTHOR = "Lucman00"
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path(user_config_dir(APP_NAME,APP_AUTHOR))
|
|
12
|
+
DATA_DIR = Path(user_data_dir(APP_NAME, APP_AUTHOR))
|
|
13
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
EKEY_PATH = CONFIG_DIR / "fernet.key"
|
|
17
|
+
CID_PATH = CONFIG_DIR / "clientID" #might not even need this. who knows
|
|
18
|
+
TOKEN_PATH = CONFIG_DIR / "token.json"
|
|
19
|
+
|
|
20
|
+
def _load_or_create_ekey() -> Fernet:
|
|
21
|
+
if EKEY_PATH.exists():
|
|
22
|
+
key = EKEY_PATH.read_bytes().strip()
|
|
23
|
+
else:
|
|
24
|
+
key=Fernet.generate_key()
|
|
25
|
+
EKEY_PATH.write_bytes(key)
|
|
26
|
+
try:
|
|
27
|
+
os.chmod(EKEY_PATH, 0o600)
|
|
28
|
+
except OSError:
|
|
29
|
+
pass #pov non unix system
|
|
30
|
+
return Fernet(key)
|
|
31
|
+
|
|
32
|
+
EKEY = _load_or_create_ekey() #runs at config time. startup.
|
|
33
|
+
|
|
34
|
+
bakedCliId = "1edac3e5b806a5eae0321f45ef7951e9"
|
|
35
|
+
|
|
36
|
+
def get_client_id() -> str:
|
|
37
|
+
if CID_PATH.exists():
|
|
38
|
+
return CID_PATH.read_text().strip()
|
|
39
|
+
return bakedCliId
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
manga_folder = str(DATA_DIR / "manga")
|
|
43
|
+
Path(manga_folder).mkdir(parents=True, exist_ok=True)
|
|
44
|
+
JsonIO = Path(DATA_DIR) / "JsonIO"
|
|
45
|
+
Path(JsonIO).mkdir(parents=True, exist_ok=True)
|
|
46
|
+
hianime_urls = Path(DATA_DIR) / "JsonIO" / "hianime_urls.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_mpv():
|
|
51
|
+
path = shutil.which("mpv")
|
|
52
|
+
if path:
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
if os.name == "nt":
|
|
56
|
+
winget_base = Path(os.environ.get("LOCALAPPDATA", "")) / "Microsoft" / "WinGet" / "Links"
|
|
57
|
+
print(winget_base)
|
|
58
|
+
if winget_base.exists():
|
|
59
|
+
for mpv_exe in winget_base.rglob("mpv.exe"):
|
|
60
|
+
print(mpv_exe)
|
|
61
|
+
return Path(mpv_exe)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
mpv_path = find_mpv()
|
|
67
|
+
|
|
68
|
+
if mpv_path == None:
|
|
69
|
+
raise RuntimeError("mpv not found on PATH.\n"
|
|
70
|
+
" Windows: winget install mpv, then make sure its folder is on PATH\n"
|
|
71
|
+
" macOS: brew install mpv\n"
|
|
72
|
+
" Linux: sudo apt install mpv (or your package manager)")
|
mmas-2.5.6/main.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from API.Mal.manage import ManageAnime as MA
|
|
8
|
+
from API.Mal.manage import ManageManga as MM
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def cli():
|
|
12
|
+
"""Manga/Anime searcher and Cli tool integration"""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@cli.command(help="Search anime or manga by Title. Use --type (-t) to decide the Audio track. This only applies to Anime")
|
|
16
|
+
@click.argument('title')
|
|
17
|
+
@click.argument ('mediatype',
|
|
18
|
+
type =click.Choice(['manga','anime'],
|
|
19
|
+
case_sensitive=False))
|
|
20
|
+
@click.option('--type', '-t',
|
|
21
|
+
'aType',
|
|
22
|
+
type=click.Choice(['sub','dub'], case_sensitive=False),
|
|
23
|
+
default='sub',
|
|
24
|
+
show_default=True,
|
|
25
|
+
help="Audio track for anime (ignored for manga). ")
|
|
26
|
+
|
|
27
|
+
# @click.option('--jp', '-japanese',
|
|
28
|
+
# type=click)
|
|
29
|
+
# Consideration is adding japanese manga. haven't found any source yet.
|
|
30
|
+
|
|
31
|
+
def search(title, mediatype, aType):
|
|
32
|
+
"""Search anime or manga by Title"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if mediatype.lower() == "manga":
|
|
36
|
+
print(f"Searching for Manga titled {title}")
|
|
37
|
+
MM().search_match(title)
|
|
38
|
+
elif mediatype.lower() == "anime":
|
|
39
|
+
print(f"Searching for Anime titled {title} in {aType}")
|
|
40
|
+
MA().search_match(title, aType)
|
|
41
|
+
else :
|
|
42
|
+
print("How did we get here? https://c.tenor.com/omyuVB-fnjMAAAAd/tenor.gif")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cli.command()
|
|
46
|
+
@click.option("--nobrowser", "-N", is_flag=True, help="Print URL and paste code manually")
|
|
47
|
+
def login(nobrowser):
|
|
48
|
+
"""Authenticate with MAL (runs automatically on first use)"""
|
|
49
|
+
from API.Mal.auth import run_oauth_flow
|
|
50
|
+
run_oauth_flow(nobrowser)
|
|
51
|
+
click.echo("Authenticated")
|
|
52
|
+
|
|
53
|
+
if __name__ == '__main__':
|
|
54
|
+
cli()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mmas
|
|
3
|
+
Version: 2.5.6
|
|
4
|
+
License-File: LICENSE
|
|
5
|
+
Requires-Dist: click>=8.0.0
|
|
6
|
+
Requires-Dist: requests>=2.28.0
|
|
7
|
+
Requires-Dist: cryptography>=39.0.0
|
|
8
|
+
Requires-Dist: platformdirs>=4.11.0
|
|
9
|
+
Requires-Dist: playwright>=1.63.0
|
|
10
|
+
Requires-Dist: beautifulsoup4>=4.15.0
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
Dynamic: requires-dist
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
browserSetup.py
|
|
3
|
+
config.py
|
|
4
|
+
main.py
|
|
5
|
+
setup.py
|
|
6
|
+
API/__init__.py
|
|
7
|
+
API/Hianime/__init__.py
|
|
8
|
+
API/Hianime/anicli.py
|
|
9
|
+
API/MD/__init__.py
|
|
10
|
+
API/MD/requests.py
|
|
11
|
+
API/Mal/__init__.py
|
|
12
|
+
API/Mal/auth.py
|
|
13
|
+
API/Mal/manage.py
|
|
14
|
+
API/Mal/requests.py
|
|
15
|
+
mmas.egg-info/PKG-INFO
|
|
16
|
+
mmas.egg-info/SOURCES.txt
|
|
17
|
+
mmas.egg-info/dependency_links.txt
|
|
18
|
+
mmas.egg-info/entry_points.txt
|
|
19
|
+
mmas.egg-info/requires.txt
|
|
20
|
+
mmas.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
mmas-2.5.6/setup.cfg
ADDED
mmas-2.5.6/setup.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name='mmas',
|
|
5
|
+
version='2.5.6',
|
|
6
|
+
packages=find_packages(),
|
|
7
|
+
py_modules=['main', 'config', 'browserSetup'],
|
|
8
|
+
install_requires=[
|
|
9
|
+
'click>=8.0.0',
|
|
10
|
+
'requests>=2.28.0',
|
|
11
|
+
'cryptography>=39.0.0',
|
|
12
|
+
'platformdirs>=4.11.0',
|
|
13
|
+
'playwright>=1.63.0',
|
|
14
|
+
'beautifulsoup4>=4.15.0',
|
|
15
|
+
],
|
|
16
|
+
entry_points={
|
|
17
|
+
'console_scripts': [
|
|
18
|
+
'mmas=main:cli',
|
|
19
|
+
'mmas-setup=browserSetup:main'
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
include_package_data=True,
|
|
23
|
+
)
|