diff --git a/app/database.py b/app/database.py index c2fd9e1..43f552a 100644 --- a/app/database.py +++ b/app/database.py @@ -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})" @@ -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"), diff --git a/app/mangabaka.py b/app/mangabaka.py index 6bcac71..67672a4 100644 --- a/app/mangabaka.py +++ b/app/mangabaka.py @@ -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.""" diff --git a/app/mangabaka_sync.py b/app/mangabaka_sync.py index 184ed80..72f9dec 100644 --- a/app/mangabaka_sync.py +++ b/app/mangabaka_sync.py @@ -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 @@ -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", } @@ -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"), @@ -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: @@ -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 diff --git a/app/notifier.py b/app/notifier.py index 931538c..da8c094 100644 --- a/app/notifier.py +++ b/app/notifier.py @@ -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 diff --git a/app/routers/export.py b/app/routers/export.py index f9eeb52..5d04f38 100644 --- a/app/routers/export.py +++ b/app/routers/export.py @@ -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", } @@ -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, diff --git a/app/routers/series.py b/app/routers/series.py index 033f2a0..9226891 100644 --- a/app/routers/series.py +++ b/app/routers/series.py @@ -671,7 +671,7 @@ 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": @@ -679,15 +679,22 @@ def _bg_sync_import_to_mb(series_ids: list[int]): 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() @@ -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"} @@ -954,7 +961,7 @@ 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": @@ -962,12 +969,19 @@ def _bg_sync_to_mb(series_id: int, reading_status: str, current_chapter: str | N 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() diff --git a/app/routers/settings.py b/app/routers/settings.py index 240d8e0..66c1985 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -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 @@ -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 @@ -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") @@ -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), } @@ -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 diff --git a/app/scheduler.py b/app/scheduler.py index 2b4d3a0..0d24bc3 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -69,7 +69,7 @@ _JOB_ID = "poll_updates" _METADATA_JOB_ID = "refresh_metadata" -ACTIVE_STATUSES = {"reading", "on_hold", "rereading"} +ACTIVE_STATUSES = {"reading", "paused", "rereading"} _poll_state: dict = { "running": False, @@ -92,7 +92,8 @@ "last_finished": None, "total": 0, "pushed": 0, - "skipped": 0, # 404 — series not in MB library + "added": 0, # newly added to MB library via POST then pushed + "skipped": 0, # 404 — not in MB library (auto_add off) or Komga no-link "failed": 0, # rate limited or transient error after retries } @@ -204,7 +205,7 @@ def _do_mb_push_all() -> None: rate limited or error after retry). """ import time as _time - from .mangabaka_sync import push_entry, _KOMGA_ID_FLOOR + from .mangabaka_sync import push_entry, add_to_library, _KOMGA_ID_FLOOR state = _mb_push_all_state if state["running"]: @@ -213,6 +214,7 @@ def _do_mb_push_all() -> None: state["running"] = True state["last_started"] = datetime.utcnow() state["pushed"] = 0 + state["added"] = 0 state["skipped"] = 0 state["failed"] = 0 state["total"] = 0 @@ -225,9 +227,10 @@ def _do_mb_push_all() -> None: logger.warning("MB push-all: no PAT configured — aborting") return + auto_add = get_setting(db, "mb_auto_add", "false") == "true" all_series = db.query(TrackedSeries).all() state["total"] = len(all_series) - logger.info(f"▶ MB push-all starting for {len(all_series)} series…") + logger.info(f"▶ MB push-all starting for {len(all_series)} series (auto_add={auto_add})…") for series in all_series: # Synthetic Komga IDs are not real MB series — skip unless the user @@ -266,10 +269,28 @@ def _do_mb_push_all() -> None: user_rating=series.user_rating, ) + if result is False and auto_add: + # Series not in MB library — POST to add with full progress in one call + result = add_to_library( + effective_id, pat, series.reading_status, + series.current_chapter, series.current_volume, + series.date_started, series.date_completed, series.user_rating, + ) + if result is True: + state["added"] += 1 + logger.debug(f"MB push-all: added series {effective_id} to MB library") + elif result is False: + state["skipped"] += 1 + else: + state["failed"] += 1 + logger.warning(f"MB push-all: failed to add series {effective_id}") + _time.sleep(_MB_PUSH_INTERVAL) + continue + if result is True: state["pushed"] += 1 elif result is False: - state["skipped"] += 1 # 404 — not in MB library + state["skipped"] += 1 # 404 — not in MB library (auto_add off or bad ID) else: state["failed"] += 1 # still rate limited or error after retry logger.warning(f"MB push-all: series {series.id} failed after retry") @@ -278,7 +299,7 @@ def _do_mb_push_all() -> None: logger.info( f"✓ MB push-all done — " - f"pushed={state['pushed']}, " + f"pushed={state['pushed']}, added={state['added']}, " f"skipped(not-in-MB)={state['skipped']}, " f"failed={state['failed']}" ) @@ -1760,7 +1781,39 @@ def _refresh_series_metadata(db: Session, series: TrackedSeries, mb_client): try: resp = mb_client.get_series(mb_id) if resp.get("status") == 200 and resp.get("data"): - flat = series_from_api(resp["data"]) + api_data = resp["data"] + + # Handle series lifecycle states before processing metadata + mb_series_state = api_data.get("state", "active") + if mb_series_state == "deleted": + logger.warning( + f"MB: series {mb_id} ('{series.title}') is deleted — skipping metadata update" + ) + return + if mb_series_state == "merged": + merged_into = api_data.get("merged_with") + if merged_into: + logger.info( + f"MB: series {mb_id} ('{series.title}') merged into {merged_into} " + f"— updating link and re-fetching" + ) + series.mb_linked_id = merged_into + # Re-fetch with the new ID + try: + resp2 = mb_client.get_series(merged_into) + if resp2.get("status") == 200 and resp2.get("data"): + api_data = resp2["data"] + else: + return + except Exception: + return + else: + logger.warning( + f"MB: series {mb_id} merged but no merged_with ID — skipping" + ) + return + + flat = series_from_api(api_data) # Cover — always refresh (CDN URLs can rotate) if flat.get("cover_url"): diff --git a/static/css/components.css b/static/css/components.css index 97e2d67..f032b47 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -157,7 +157,7 @@ textarea.input { resize: vertical; } .chip-new { background: var(--green); color: white; } .chip-plan { background: var(--blue); color: white; } .chip-considering { background: var(--pink); color: white; } -.chip-hold { background: var(--yellow); color: #111; } +.chip-paused { background: var(--yellow); color: #111; } .chip-dropped { background: var(--red); color: white; } .chip-done { background: var(--text-muted); color: white; } .chip-right-stack { diff --git a/static/index.html b/static/index.html index 6a12b1d..d630147 100644 --- a/static/index.html +++ b/static/index.html @@ -176,8 +176,9 @@ + - + @@ -304,7 +306,7 @@

💤
PLAN
?
-
HOLD
+
PAUSE
DROP
DONE
@@ -440,7 +442,7 @@

No recent releases

:class="{ 'chip-new': r.reading_status==='reading', 'chip-plan': r.reading_status==='plan_to_read', - 'chip-hold': r.reading_status==='on_hold', + 'chip-paused': r.reading_status==='paused', 'chip-dropped': r.reading_status==='dropped', 'chip-done': r.reading_status==='completed', 'chip-considering': r.reading_status==='considering', @@ -1035,7 +1037,8 @@

All - + +

@@ -1217,6 +1220,17 @@

No ratings yet

✅ Live sync active — changes pushed to MangaBaka automatically. +
+ + +
+
+ ✅ New MangaBaka-linked series will be added to your MB library automatically during sync. +
+
When enabled, Pushover notifications only fire for series you have marked - Reading. Series set to On Hold, Plan to Read, Considering, etc. + Reading. Series set to Paused, Plan to Read, Considering, etc. still create in-app notifications but won't ping your device. Useful if you have a large backlog you haven't started yet.
@@ -1355,7 +1369,7 @@

No ratings yet

When set to "Reading only", the Updates pill and badge on the dashboard only count - series with Reading status. Plan to Read, On Hold, etc. with new chapters + series with Reading status. Plan to Read, Paused, etc. with new chapters still show a NEW chip on their card — they just won't be counted in the update total.
@@ -2009,8 +2023,9 @@

No ratings yet

- + + @@ -2764,9 +2779,9 @@

No ratings yet

- - + +
diff --git a/static/js/app.js b/static/js/app.js index 2d01694..c490674 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3,7 +3,7 @@ function app() { page: 'library', loading: false, library: [], - filters: ['all'], // multi-select: ['all'], ['reading','on_hold'], ['updates','reading'], etc. + filters: ['all'], // multi-select: ['all'], ['reading','paused'], ['updates','reading'], etc. viewMode: 'grid', // 'grid' or 'list' // Library toolbar @@ -31,7 +31,7 @@ function app() { activityFilter: '', // Settings form - sf: { pushover_user_key:'', pushover_app_token:'', pushover_enabled:'false', push_chapter_updates:'true', push_news:'false', push_reading_only:'false', rich_notification_chapter_titles:'true', notify_locked_chapters:'false', updates_reading_only:'false', poll_interval_hours:'6', mangabaka_token:'', mangabaka_pat:'', mb_sync_enabled:'false', mu_enabled:'true', kmanga_email:'', kmanga_password:'', kmanga_recaptcha_token:'', komga_url:'', komga_api_key:'', komga_sync_read_progress:'false', idle_detection_enabled:'false', idle_threshold_days:'90', idle_auto_archive:'false', webhook_enabled:'false', webhook_url:'', default_page:'library', grid_density:'normal', + sf: { pushover_user_key:'', pushover_app_token:'', pushover_enabled:'false', push_chapter_updates:'true', push_news:'false', push_reading_only:'false', rich_notification_chapter_titles:'true', notify_locked_chapters:'false', updates_reading_only:'false', poll_interval_hours:'6', mangabaka_token:'', mangabaka_pat:'', mb_sync_enabled:'false', mb_auto_add:'false', mu_enabled:'true', kmanga_email:'', kmanga_password:'', kmanga_recaptcha_token:'', komga_url:'', komga_api_key:'', komga_sync_read_progress:'false', idle_detection_enabled:'false', idle_threshold_days:'90', idle_auto_archive:'false', webhook_enabled:'false', webhook_url:'', default_page:'library', grid_density:'normal', // ── Display preferences ──────────────────────────────────────────── show_source_badges: 'true', // platform banner (MangaPlus, K Manga, etc.) on cards show_ratings_on_cards: 'true', // ★ score overlay on cover image @@ -763,7 +763,11 @@ function app() { async testMbSync() { try { const d = await this.api('/api/settings/test-mb-sync', 'POST'); - this.toast(`Connected as ${d.username}`, 'success'); + if (d.missing_write_scope) { + this.toast(`Connected as ${d.username} — warning: PAT missing library.write scope, sync will fail`, 'warning'); + } else { + this.toast(`Connected as ${d.username}`, 'success'); + } } catch(e) { this.toast(e.detail || 'PAT invalid or connection failed', 'error'); } }, @@ -788,7 +792,10 @@ function app() { if (!s.running) { clearInterval(poll); this.mbPushingAll = false; - const parts = [`${s.pushed} pushed`, `${s.skipped} not in MB`]; + const parts = []; + if (s.added > 0) parts.push(`${s.added} added to MB`); + parts.push(`${s.pushed} updated`); + if (s.skipped > 0) parts.push(`${s.skipped} not in MB`); if (s.failed > 0) parts.push(`${s.failed} failed (rate limited)`); this.toast(`MB push done — ${parts.join(', ')}`, s.failed > 0 ? 'warning' : 'success'); }