Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,11 @@ def _migrate_db():
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {col_type}"))
_log.info(f"DB migration: added {table}.{col}")

# Data migration: rename 'on_hold' → 'paused' to align with MangaBaka's state enum
conn.execute(text(
"UPDATE tracked_series SET reading_status = 'paused' WHERE reading_status = 'on_hold'"
))

for idx_name, table, columns in indexes:
conn.execute(text(
f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table} ({columns})"
Expand Down Expand Up @@ -526,6 +531,7 @@ def _seed_settings():
"mangabaka_token": os.getenv("MANGABAKA_TOKEN", ""),
"mangabaka_pat": os.getenv("MANGABAKA_PAT", ""),
"mb_sync_enabled": "false",
"mb_auto_add": "false",
"pushover_user_key": os.getenv("PUSHOVER_USER_KEY", ""),
"pushover_app_token": os.getenv("PUSHOVER_APP_TOKEN", ""),
"poll_interval_hours": os.getenv("POLL_INTERVAL_HOURS", "6"),
Expand Down
4 changes: 2 additions & 2 deletions app/mangabaka.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
logger.error(f"Error fetching {url}: {e}")
raise

def search(self, query: str, page: int = 1) -> dict[str, Any]:
def search(self, query: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""Search for series by title."""
return self._get("/v1/series/search", params={"q": query, "page": page})
return self._get("/v1/series/search", params={"q": query, "page": page, "limit": limit})

def get_series(self, series_id: int) -> dict[str, Any]:
"""Get full details for a single series."""
Expand Down
101 changes: 75 additions & 26 deletions app/mangabaka_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
MangaBaka library sync — push reading progress to MB via PAT.

Uses X-API-Key header (Personal Access Token).
Only PATCH /v1/my/library/{series_id} is available; no API endpoint exists
to add new entries, so sync is update-only for series already in MB.
PATCH /v1/my/library/{series_id} updates progress for existing entries.
POST /v1/my/library/{series_id} adds a new entry (used when mb_auto_add enabled).
"""
import logging
import time
Expand All @@ -20,9 +20,10 @@
"reading": "reading",
"completed": "completed",
"dropped": "dropped",
"on_hold": "on_hold",
"paused": "paused",
"plan_to_read": "plan_to_read",
"rereading": "rereading",
"considering": "considering",
}


Expand Down Expand Up @@ -60,13 +61,13 @@ def push_entry(
False — 404 (series not in MB library; user must add it there first)
None — rate limited (429) or other transient error; caller should retry

MB rating field accepts integers 0–10 only; floats are rejected.
MB rating field accepts integers 0–100. App stores 0–10; multiply by 10.
"""
if series_id >= _KOMGA_ID_FLOOR:
return False # Komga synthetic ID — not a real MB series

# MB rating: integer 0–10, or null to clear. Round from app's 0.5-step scale.
mb_rating = round(user_rating) if user_rating is not None else None
# MB rating: integer 0–100. App stores 0–10 scale; multiply to convert.
mb_rating = round(user_rating * 10) if user_rating is not None else None

payload: dict = {
"state": _STATE_MAP.get(reading_status, "reading"),
Expand Down Expand Up @@ -101,6 +102,65 @@ def push_entry(
return None


def add_to_library(
series_id: int,
pat: str,
state: str | None = None,
current_chapter: str | None = None,
current_volume: str | None = None,
date_started: datetime | None = None,
date_completed: datetime | None = None,
user_rating: float | None = None,
) -> bool | None:
"""
POST /v1/my/library/{series_id} to add a series to the MB library.

Sends all available progress fields in one call so no follow-up PATCH needed.

Returns:
True — added (201)
False — series unknown to MB (404) or already in library (409)
None — rate limited (429) or transient error; caller should retry
"""
if series_id >= _KOMGA_ID_FLOOR:
return False

mb_rating = round(user_rating * 10) if user_rating is not None else None
payload: dict = {}
if state:
payload["state"] = _STATE_MAP.get(state, "reading")
if (ch := _parse_chapter(current_chapter)) is not None:
payload["progress_chapter"] = ch
if (vol := _parse_chapter(current_volume)) is not None:
payload["progress_volume"] = vol
if date_started:
payload["start_date"] = _iso(date_started)
if date_completed:
payload["finish_date"] = _iso(date_completed)
if mb_rating is not None:
payload["rating"] = mb_rating

try:
with httpx.Client(timeout=10.0) as client:
resp = client.post(
f"{BASE_URL}/v1/my/library/{series_id}",
headers={"X-API-Key": pat},
json=payload,
)
if resp.status_code == 201:
return True
if resp.status_code in (404, 409):
logger.debug(f"MB add_to_library: series {series_id} status {resp.status_code}")
return False
if resp.status_code == 429:
return None
resp.raise_for_status()
return True
except Exception as e:
logger.warning(f"MB add_to_library failed for series {series_id}: {e}")
return None


def get_profile(pat: str) -> dict | None:
"""Validate PAT by fetching /v1/my/profile. Returns profile dict or None."""
try:
Expand All @@ -124,26 +184,15 @@ def pull_library(pat: str) -> list[dict]:
entries = []
try:
with httpx.Client(timeout=15.0) as client:
resp = client.get(
f"{BASE_URL}/v1/my/library",
headers={"X-API-Key": pat},
params={"limit": 100, "page": 1},
)
resp.raise_for_status()
data = resp.json()
entries.extend(data.get("data", []))
pagination = data.get("pagination", {})
total = pagination.get("count", 0)
limit = pagination.get("limit", 100)
pages = -((-total) // limit) if limit else 1
for page in range(2, pages + 1):
r = client.get(
f"{BASE_URL}/v1/my/library",
headers={"X-API-Key": pat},
params={"limit": 100, "page": page},
)
r.raise_for_status()
entries.extend(r.json().get("data", []))
next_url: str | None = f"{BASE_URL}/v1/my/library"
params: dict | None = {"limit": 100}
while next_url:
resp = client.get(next_url, headers={"X-API-Key": pat}, params=params)
resp.raise_for_status()
data = resp.json()
entries.extend(data.get("data", []))
next_url = (data.get("pagination") or {}).get("next")
params = None # next_url already carries all query params
except Exception as e:
logger.warning(f"MB pull_library failed: {e}")
return entries
2 changes: 1 addition & 1 deletion app/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
push_chapter_updates — send Pushover when a new chapter is detected (default: true)
push_news — send Pushover for news items (default: false)
push_reading_only — if true, only series with reading_status="reading" push to
Pushover; series on hold, considering, etc. create in-app
Pushover; series paused, considering, etc. create in-app
notifications but stay silent on the phone (default: false)
"""
import ipaddress
Expand Down
7 changes: 4 additions & 3 deletions app/routers/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,17 @@
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/export", tags=["export"])

# MB accepted state values: reading, completed, dropped, on_hold, plan_to_read, rereading
# MB accepted state values: reading, completed, dropped, paused, plan_to_read, rereading, considering
_KOMGA_ID_FLOOR = 2_000_000_000

_STATUS_MAP = {
"reading": "reading",
"completed": "completed",
"dropped": "dropped",
"on_hold": "on_hold",
"paused": "paused",
"plan_to_read": "plan_to_read",
"rereading": "rereading",
"considering": "considering",
}


Expand Down Expand Up @@ -65,7 +66,7 @@ def _series_to_mb_entry(s: TrackedSeries) -> dict:
"entry": {
"note": s.notes or None,
"read_link": None,
"rating": round(s.user_rating) if s.user_rating is not None else None,
"rating": round(s.user_rating * 10) if s.user_rating is not None else None,
"state": _STATUS_MAP.get(s.reading_status or "reading", "reading"),
"priority": 20,
"is_private": False,
Expand Down
26 changes: 20 additions & 6 deletions app/routers/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,23 +671,30 @@ def import_library(req: ImportRequest, background_tasks: BackgroundTasks, db: Se
def _bg_sync_import_to_mb(series_ids: list[int]):
"""Push all imported series to MB if sync is enabled. Runs once after import."""
from ..database import SessionLocal, get_setting
from ..mangabaka_sync import push_entry
from ..mangabaka_sync import push_entry, add_to_library
db = SessionLocal()
try:
if get_setting(db, "mb_sync_enabled", "false") != "true":
return
pat = get_setting(db, "mangabaka_pat", "")
if not pat:
return
auto_add = get_setting(db, "mb_auto_add", "false") == "true"
for sid in series_ids:
series = db.query(TrackedSeries).filter(TrackedSeries.id == sid).first()
if series:
effective_id = series.mb_linked_id if series.mb_linked_id else series.id
push_entry(
result = push_entry(
effective_id, series.reading_status, series.current_chapter,
series.current_volume, series.date_started, series.date_completed,
pat, user_rating=series.user_rating,
)
if result is False and auto_add:
add_to_library(
effective_id, pat, series.reading_status,
series.current_chapter, series.current_volume,
series.date_started, series.date_completed, series.user_rating,
)
finally:
db.close()

Expand Down Expand Up @@ -908,7 +915,7 @@ def get_series_endpoint(series_id: int, db: Session = Depends(get_db)):

# ── Update series ─────────────────────────────────────────────────────────────

_VALID_READING_STATUSES = {"reading", "plan_to_read", "completed", "on_hold", "dropped", "rereading"}
_VALID_READING_STATUSES = {"reading", "plan_to_read", "completed", "paused", "dropped", "rereading", "considering"}
_VALID_TRACK_MODES = {"chapter", "volume"}


Expand Down Expand Up @@ -954,20 +961,27 @@ def _bg_sync_to_mb(series_id: int, reading_status: str, current_chapter: str | N
user_rating: float | None = None):
"""Background task: push updated progress to MB if sync is enabled."""
from ..database import SessionLocal, get_setting, TrackedSeries as _TS
from ..mangabaka_sync import push_entry
from ..mangabaka_sync import push_entry, add_to_library
db = SessionLocal()
try:
if get_setting(db, "mb_sync_enabled", "false") != "true":
return
pat = get_setting(db, "mangabaka_pat", "")
if not pat:
return
auto_add = get_setting(db, "mb_auto_add", "false") == "true"
# Komga-imported series have synthetic IDs (>= 2_000_000_000).
# Use mb_linked_id if the user has linked this series to a real MB entry.
series = db.query(_TS).filter(_TS.id == series_id).first()
effective_id = (series.mb_linked_id if series and series.mb_linked_id else series_id)
push_entry(effective_id, reading_status, current_chapter, current_volume,
date_started, date_completed, pat, user_rating=user_rating)
result = push_entry(effective_id, reading_status, current_chapter, current_volume,
date_started, date_completed, pat, user_rating=user_rating)
if result is False and auto_add:
add_to_library(
effective_id, pat, reading_status,
current_chapter, current_volume,
date_started, date_completed, user_rating,
)
finally:
db.close()

Expand Down
16 changes: 13 additions & 3 deletions app/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"mangabaka_token",
"mangabaka_pat",
"mb_sync_enabled",
"mb_auto_add",
"mu_enabled",
"kmanga_email",
"kmanga_password", # returned masked; full value stored in DB
Expand Down Expand Up @@ -112,6 +113,7 @@ class UpdateSettingsRequest(BaseModel):
mangabaka_token: str | None = None
mangabaka_pat: str | None = None
mb_sync_enabled: str | None = None
mb_auto_add: str | None = None
mu_enabled: str | None = None
kmanga_email: str | None = None
kmanga_password: str | None = None
Expand Down Expand Up @@ -403,7 +405,13 @@ def test_mb_sync(db: Session = Depends(get_db)):
profile = get_profile(pat)
if not profile:
raise HTTPException(status_code=400, detail="PAT is invalid or expired")
return {"success": True, "username": profile.get("preferred_username") or profile.get("nickname")}
scopes = profile.get("scopes") or []
missing_write = "library.write" not in scopes
return {
"success": True,
"username": profile.get("preferred_username") or profile.get("nickname"),
"missing_write_scope": missing_write,
}


@router.get("/mb-push-all/status")
Expand All @@ -416,6 +424,7 @@ def mb_push_all_status():
"last_finished": state["last_finished"].isoformat() if state["last_finished"] else None,
"total": state["total"],
"pushed": state["pushed"],
"added": state.get("added", 0),
"skipped": state["skipped"],
"failed": state.get("failed", 0),
}
Expand Down Expand Up @@ -492,8 +501,9 @@ def mb_pull(db: Session = Depends(get_db)):
if mb_rating is not None and series.user_rating is None:
try:
val = float(mb_rating)
if 0.0 <= val <= 10.0:
series.user_rating = round(val * 2) / 2 # snap to 0.5 increments
if 0.0 <= val <= 100.0:
# MB uses 0–100 scale; convert to internal 0–10, snap to 0.5 steps
series.user_rating = round((val / 10) * 2) / 2
changed = True
except (ValueError, TypeError):
pass
Expand Down
Loading
Loading