Skip to content

Commit 5a68333

Browse files
feat: Add MyAnimeList as primary manga metadata provider (#113)
* feat: Add MyAnimeList as primary manga metadata provider MAL provides better search coverage and metadata (titles, images, synopsis, authors) while MangaDex continues to supply chapter data. This hybrid approach significantly improves manga library scan match rates (previously ~39/100+). Backend: - New MAL API v2 client (myanimelist.py) with search, details, rate limiting - MangaDex cross-reference via find_by_mal_id() using links.mal field - addMangaToDB_MAL() importer fetches MAL metadata + MangaDex chapters - Service layer routes search/add through MAL when enabled, falls back to MangaDex - Image proxy endpoint for MAL CDN covers (CORS/SSRF-safe allowlist) - MangaDexID/MalID columns on comics table for cross-provider detection - Improved fuzzy matching: normalize titles, SequenceMatcher + Jaccard + containment - Fix NoneType crash in find_comic when ComicVine API key missing Frontend: - MAL settings UI (enable toggle + Client ID field) in API settings - "MAL" source label badge on search results - Helpful "Comic Vine API key required" message instead of generic error - Search mode derived from URL params (fixes refresh defaulting to Comics) - Delete series redirects to /series instead of / - Fix horizontal overflow: min-w-0 on main layout, truncated series/author columns - Remove redundant Library column from search results - Column toggle button portaled next to sort controls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: CI failures — ruff format mangasync.py, use callback ref for portal - ruff format on mangasync.py (line length) - Replace useRef with callback ref + state for column toggle portal to satisfy react-hooks/refs lint rule (no ref access during render) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Address code review findings (P1/P2 security, logic, standards) Security: - Image proxy: validate URL scheme (http/https only), reject userinfo in URLs, pin Content-Type to image allowlist, disable redirects - MAL Client ID: remove from safe_keys (use boolean indicator instead), change settings field to password type - Move `import requests` to module level in router.py Logic: - find_manga/add_manga gate on MAL_ENABLED || MANGADEX_ENABLED (not just MangaDex) - MAL empty results no longer silently fall through to MangaDex - Safe int conversion for num_chapters (int(float()) + try/except) - isManga check includes mal- prefix on SeriesDetailPage - mangaEnabled considers mal_enabled in useContentSources - Validate URL type param instead of unsafe cast - MAL settings visible independently of MangaDex toggle Standards: - bare except → except Exception as e with logging - datetime import moved to module level in myanimelist.py - Config type: add mal_enabled, mal_client_id, mal_client_id_set fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0dad74a commit 5a68333

19 files changed

Lines changed: 1093 additions & 110 deletions

File tree

comicarr/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,8 @@ def dbcheck():
13351335
("ReadingDirection", "TEXT DEFAULT 'ltr'"),
13361336
("MetadataSource", "TEXT"),
13371337
("ExternalID", "TEXT"),
1338+
("MangaDexID", "TEXT"),
1339+
("MalID", "TEXT"),
13381340
("not_updated_db", "TEXT"),
13391341
]
13401342
_ensure_columns(engine, "comics", comics_cols)

comicarr/app/metadata/router.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@
1313
Low cross-domain dependency, well-bounded. Validates the domain pattern.
1414
"""
1515

16-
from fastapi import APIRouter, Depends
17-
from fastapi.responses import FileResponse, JSONResponse
16+
from urllib.parse import urlparse
1817

18+
import requests
19+
from fastapi import APIRouter, Depends, Query
20+
from fastapi.responses import FileResponse, JSONResponse, Response
21+
22+
from comicarr import logger
1923
from comicarr.app.core.context import AppContext, get_context
2024
from comicarr.app.core.exceptions import NotFoundError
2125
from comicarr.app.core.security import require_session
@@ -120,6 +124,50 @@ def get_artwork(comic_id: str, ctx: AppContext = Depends(get_context)):
120124
return FileResponse(image_path, media_type="image/jpeg")
121125

122126

127+
# Allowed domains for image proxy to prevent SSRF
128+
_ALLOWED_IMAGE_DOMAINS = {
129+
"myanimelist.net",
130+
"cdn.myanimelist.net",
131+
"api-cdn.myanimelist.net",
132+
"uploads.mangadex.org",
133+
}
134+
135+
136+
_ALLOWED_IMAGE_CONTENT_TYPES = {
137+
"image/jpeg",
138+
"image/png",
139+
"image/webp",
140+
"image/gif",
141+
"image/avif",
142+
}
143+
144+
145+
@router.get("/image-proxy", dependencies=[Depends(require_session)])
146+
def image_proxy(url: str = Query(..., description="External image URL to proxy")):
147+
"""Proxy external cover images to avoid CORS issues.
148+
149+
Only allows requests to known manga metadata CDNs.
150+
"""
151+
parsed = urlparse(url)
152+
if parsed.scheme not in ("http", "https"):
153+
return JSONResponse({"error": "Invalid URL scheme"}, status_code=403)
154+
if parsed.hostname not in _ALLOWED_IMAGE_DOMAINS:
155+
return JSONResponse({"error": "Domain not allowed"}, status_code=403)
156+
if parsed.username or parsed.password:
157+
return JSONResponse({"error": "Credentials in URL not allowed"}, status_code=403)
158+
159+
try:
160+
resp = requests.get(url, timeout=(5, 10), headers={"User-Agent": "Comicarr/1.0"}, allow_redirects=False)
161+
resp.raise_for_status()
162+
content_type = resp.headers.get("Content-Type", "image/jpeg").split(";")[0].strip()
163+
if content_type not in _ALLOWED_IMAGE_CONTENT_TYPES:
164+
return JSONResponse({"error": "Invalid content type"}, status_code=502)
165+
return Response(content=resp.content, media_type=content_type)
166+
except Exception as e:
167+
logger.error("[IMAGE-PROXY] Failed to fetch %s: %s" % (url, e))
168+
return JSONResponse({"error": "Failed to fetch image"}, status_code=502)
169+
170+
123171
@router.get("/series-image/{series_id}", dependencies=[Depends(require_session)])
124172
def get_series_image(series_id: str, ctx: AppContext = Depends(get_context)):
125173
"""Get cover image URL for a Metron series (lazy loading)."""

comicarr/app/search/service.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,31 +83,51 @@ def add_in_library(comic):
8383
if isinstance(searchresults, dict) and "results" in searchresults:
8484
searchresults["results"] = [add_in_library(c) for c in searchresults["results"]]
8585
return searchresults
86-
else:
86+
elif searchresults:
8787
searchresults = sorted(searchresults, key=itemgetter("comicyear", "issues"), reverse=True)
8888
searchresults = [add_in_library(c) for c in searchresults]
8989
return {"results": searchresults}
90+
else:
91+
return {"error": "Search returned no results"}
9092

9193

9294
def find_manga(ctx, name, limit=None, offset=None, sort=None):
93-
"""Search for manga via MangaDex API."""
94-
if not ctx.config or not getattr(ctx.config, "MANGADEX_ENABLED", False):
95-
return {"error": "MangaDex integration is not enabled"}
96-
97-
from comicarr import mangadex
95+
"""Search for manga via MAL (primary) or MangaDex (fallback)."""
96+
mal_ok = getattr(ctx.config, "MAL_ENABLED", False) and getattr(ctx.config, "MAL_CLIENT_ID", None)
97+
mdex_ok = getattr(ctx.config, "MANGADEX_ENABLED", False)
98+
if not ctx.config or not (mal_ok or mdex_ok):
99+
return {"error": "Manga integration is not enabled"}
98100

99101
try:
100102
parsed_limit = int(limit) if limit else None
101103
parsed_offset = int(offset) if offset else None
102104
except (ValueError, TypeError):
103105
return {"error": "Invalid pagination parameters"}
104106

105-
searchresults = mangadex.search_manga(name, limit=parsed_limit, offset=parsed_offset, sort=sort)
106-
107107
def add_in_library(manga):
108108
manga["in_library"] = manga.get("haveit") != "No"
109109
return manga
110110

111+
# Try MAL first if configured
112+
mal_enabled = getattr(ctx.config, "MAL_ENABLED", False)
113+
mal_client_id = getattr(ctx.config, "MAL_CLIENT_ID", None)
114+
115+
if mal_enabled and mal_client_id:
116+
from comicarr import myanimelist
117+
118+
try:
119+
searchresults = myanimelist.search_manga(name, limit=parsed_limit, offset=parsed_offset, sort=sort)
120+
if isinstance(searchresults, dict) and "results" in searchresults:
121+
searchresults["results"] = [add_in_library(m) for m in searchresults["results"]]
122+
return searchresults
123+
except Exception as e:
124+
logger.error("[SEARCH] MAL search failed, falling back to MangaDex: %s" % e)
125+
126+
# Fall back to MangaDex
127+
from comicarr import mangadex
128+
129+
searchresults = mangadex.search_manga(name, limit=parsed_limit, offset=parsed_offset, sort=sort)
130+
111131
if isinstance(searchresults, dict) and "results" in searchresults:
112132
searchresults["results"] = [add_in_library(m) for m in searchresults["results"]]
113133
return searchresults
@@ -128,23 +148,29 @@ def add_comic(ctx, comic_id):
128148

129149

130150
def add_manga(ctx, manga_id):
131-
"""Add a manga by MangaDex ID."""
132-
if not str(manga_id).startswith("md-"):
133-
manga_id = "md-" + manga_id
134-
135-
if not ctx.config or not getattr(ctx.config, "MANGADEX_ENABLED", False):
136-
return {"success": False, "error": "MangaDex integration is not enabled"}
151+
"""Add a manga by MAL ID or MangaDex ID."""
152+
mal_ok = getattr(ctx.config, "MAL_ENABLED", False) and getattr(ctx.config, "MAL_CLIENT_ID", None)
153+
mdex_ok = getattr(ctx.config, "MANGADEX_ENABLED", False)
154+
if not ctx.config or not (mal_ok or mdex_ok):
155+
return {"success": False, "error": "Manga integration is not enabled"}
137156

138157
try:
139158
from comicarr import importer
140159

141-
result = importer.addMangaToDB(manga_id)
160+
if str(manga_id).startswith("mal-"):
161+
# MAL-sourced manga: fetch metadata from MAL, chapters from MangaDex
162+
result = importer.addMangaToDB_MAL(manga_id)
163+
else:
164+
# MangaDex-sourced manga (existing flow)
165+
if not str(manga_id).startswith("md-"):
166+
manga_id = "md-" + manga_id
167+
result = importer.addMangaToDB(manga_id)
142168

143169
if result and result.get("status") == "complete":
144170
return {
145171
"success": True,
146172
"message": "Successfully added manga: %s" % result.get("comicname", manga_id),
147-
"comicid": manga_id,
173+
"comicid": result.get("comicid", manga_id),
148174
"content_type": "manga",
149175
}
150176
return {"success": False, "error": "Failed to add manga: %s" % manga_id}

comicarr/app/series/service.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -765,14 +765,21 @@ def listLibrary(comicid=None):
765765
comics.c.Status,
766766
comics.c.ComicName,
767767
comics.c.ComicYear,
768+
comics.c.MalID,
769+
comics.c.MangaDexID,
768770
)
769771
.outerjoin(annuals, comics.c.ComicID == annuals.c.ComicID)
770772
.group_by(comics.c.ComicID)
771773
)
772774
else:
773-
stmt = select(comics.c.ComicID, comics.c.Status, comics.c.ComicName, comics.c.ComicYear).group_by(
774-
comics.c.ComicID
775-
)
775+
stmt = select(
776+
comics.c.ComicID,
777+
comics.c.Status,
778+
comics.c.ComicName,
779+
comics.c.ComicYear,
780+
comics.c.MalID,
781+
comics.c.MangaDexID,
782+
).group_by(comics.c.ComicID)
776783
else:
777784
cleaned_id = re.sub("4050-", "", comicid).strip()
778785
if comicarr.CONFIG.ANNUALS_ON is True:
@@ -783,14 +790,23 @@ def listLibrary(comicid=None):
783790
comics.c.Status,
784791
comics.c.ComicName,
785792
comics.c.ComicYear,
793+
comics.c.MalID,
794+
comics.c.MangaDexID,
786795
)
787796
.outerjoin(annuals, comics.c.ComicID == annuals.c.ComicID)
788797
.where(comics.c.ComicID == cleaned_id)
789798
.group_by(comics.c.ComicID)
790799
)
791800
else:
792801
stmt = (
793-
select(comics.c.ComicID, comics.c.Status, comics.c.ComicName, comics.c.ComicYear)
802+
select(
803+
comics.c.ComicID,
804+
comics.c.Status,
805+
comics.c.ComicName,
806+
comics.c.ComicYear,
807+
comics.c.MalID,
808+
comics.c.MangaDexID,
809+
)
794810
.where(comics.c.ComicID == cleaned_id)
795811
.group_by(comics.c.ComicID)
796812
)
@@ -811,6 +827,16 @@ def listLibrary(comicid=None):
811827
library[name_key] = {"comicid": row["ComicID"], "status": row["Status"]}
812828
except Exception:
813829
pass
830+
# Cross-index by MAL and MangaDex IDs for cross-provider haveit detection
831+
try:
832+
mal_id = row.get("MalID")
833+
if mal_id:
834+
library["mal-" + str(mal_id)] = {"comicid": row["ComicID"], "status": row["Status"]}
835+
mangadex_id = row.get("MangaDexID")
836+
if mangadex_id:
837+
library["md-" + str(mangadex_id)] = {"comicid": row["ComicID"], "status": row["Status"]}
838+
except Exception as e:
839+
logger.fdebug("[SERIES] Cross-index by MAL/MangaDex ID failed for %s: %s" % (row.get("ComicID"), e))
814840

815841
return library
816842

comicarr/app/system/service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ def get_safe_config(ctx):
186186
"METRON_USERNAME",
187187
"MANGADEX_LANGUAGES",
188188
"MANGADEX_CONTENT_RATING",
189+
"MAL_ENABLED",
189190
"PREFERRED_QUALITY",
190191
"USE_MINSIZE",
191192
"MINSIZE",
@@ -234,6 +235,10 @@ def get_safe_config(ctx):
234235
metron_pw = getattr(ctx.config, "METRON_PASSWORD", None)
235236
result["metron_password_set"] = bool(metron_pw)
236237

238+
# Add boolean indicator for MAL_CLIENT_ID (not sent as plaintext)
239+
mal_key = getattr(ctx.config, "MAL_CLIENT_ID", None)
240+
result["MAL_CLIENT_ID_SET"] = bool(mal_key and mal_key != "None")
241+
237242
# Lowercase all keys for frontend convention
238243
result = {k.lower(): v for k, v in result.items()}
239244
version = ctx.current_version
@@ -296,6 +301,8 @@ def get_safe_config(ctx):
296301
"METRON_USERNAME",
297302
"MANGADEX_LANGUAGES",
298303
"MANGADEX_CONTENT_RATING",
304+
"MAL_ENABLED",
305+
"MAL_CLIENT_ID",
299306
"PREFERRED_QUALITY",
300307
"USE_MINSIZE",
301308
"MINSIZE",

comicarr/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@
187187
"MANGADEX_ENABLED": (bool, "MangaDex", True),
188188
"MANGADEX_LANGUAGES": (str, "MangaDex", "en"),
189189
"MANGADEX_CONTENT_RATING": (str, "MangaDex", "safe,suggestive"),
190+
"MAL_ENABLED": (bool, "MAL", False),
191+
"MAL_CLIENT_ID": (str, "MAL", None),
190192
"LOG_DIR": (str, "Logs", None),
191193
"MAX_LOGSIZE": (int, "Logs", 10000000),
192194
"MAX_LOGFILES": (int, "Logs", 5),

0 commit comments

Comments
 (0)