feat: add Grimmory/Booklore push integration

- Added "Push to Library" button to issues archive
- Implemented direct API upload to Grimmory/Booklore
- Added support for `.env` files via `python-dotenv`
- Handled 409 Conflict for duplicate files gracefully
- Resolved library name to numeric ID for direct uploads
- Fixed SQLAlchemy and ebooklib warnings in tests
- Added comprehensive tests for push functionality

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-07 02:22:35 -04:00
parent 767285119b
commit 0d1a898caa
9 changed files with 360 additions and 9 deletions

View File

@@ -1,5 +1,6 @@
import os
import json
import requests
from flask import Blueprint, render_template, send_file, redirect, url_for, flash
from app import db
@@ -26,7 +27,7 @@ def index():
@issues_bp.route("/issues/<int:issue_id>/download")
def download(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
if not os.path.exists(issue.epub_path):
flash("ePub file not found.", "error")
return redirect(url_for("issues.index"))
@@ -39,7 +40,7 @@ def download(issue_id):
@issues_bp.route("/issues/<int:issue_id>/epub")
def epub_file(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
if not os.path.exists(issue.epub_path):
flash("ePub file not found.", "error")
return redirect(url_for("issues.index"))
@@ -48,7 +49,7 @@ def epub_file(issue_id):
@issues_bp.route("/issues/<int:issue_id>/cover")
def cover_image(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
if not issue.cover_path or not os.path.exists(issue.cover_path):
flash("Cover image not found.", "error")
return redirect(url_for("issues.index"))
@@ -57,7 +58,7 @@ def cover_image(issue_id):
@issues_bp.route("/issues/<int:issue_id>/read")
def read(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
if not os.path.exists(issue.epub_path):
flash("ePub file not found.", "error")
return redirect(url_for("issues.index"))
@@ -65,7 +66,7 @@ def read(issue_id):
article_count = len(json.loads(issue.article_ids))
if issue.issue_type == "single_article":
article_ids = json.loads(issue.article_ids)
article = Article.query.get(article_ids[0]) if article_ids else None
article = db.session.get(Article, article_ids[0]) if article_ids else None
title = article.title if article else "Single Article"
elif issue.issue_type == "multi_week":
w1 = issue.week_start.isocalendar()[1]
@@ -84,7 +85,7 @@ def read(issue_id):
@issues_bp.route("/issues/<int:issue_id>/delete", methods=["POST"])
def delete(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
if issue.epub_path and os.path.exists(issue.epub_path):
os.remove(issue.epub_path)
@@ -100,7 +101,7 @@ def delete(issue_id):
@issues_bp.route("/issues/<int:issue_id>/regenerate", methods=["POST"])
def regenerate(issue_id):
issue = Issue.query.get_or_404(issue_id)
issue = db.get_or_404(Issue, issue_id)
article_ids = json.loads(issue.article_ids)
articles_for_issue = (
@@ -145,3 +146,102 @@ def regenerate(issue_id):
flash(f"Regeneration failed: {e}", "error")
return redirect(url_for("issues.index"))
@issues_bp.route("/issues/<int:issue_id>/push", methods=["POST"])
def push(issue_id):
issue = db.get_or_404(Issue, issue_id)
if not config.GRIMMORY_URL or not config.GRIMMORY_USERNAME or not config.GRIMMORY_PASSWORD:
flash("Grimmory/Booklore integration is not configured.", "error")
return redirect(url_for("issues.index"))
if not issue.epub_path or not os.path.exists(issue.epub_path):
flash("ePub file not found.", "error")
return redirect(url_for("issues.index"))
try:
base_url = config.GRIMMORY_URL.rstrip('/')
# 1. Authenticate to get a token
login_resp = requests.post(
f"{base_url}/api/v1/auth/login",
json={"username": config.GRIMMORY_USERNAME, "password": config.GRIMMORY_PASSWORD},
timeout=10
)
if login_resp.status_code != 200:
flash("Failed to authenticate with library. Check username/password.", "error")
return redirect(url_for("issues.index"))
login_data = login_resp.json()
token = login_data.get("token") or login_data.get("accessToken") or login_data.get("accessToken_Internal")
if not token and "data" in login_data and isinstance(login_data["data"], dict):
token = login_data["data"].get("token") or login_data["data"].get("accessToken")
if not token:
flash("Failed to extract authentication token from library response.", "error")
return redirect(url_for("issues.index"))
# 2. Resolve Library ID and Path ID if configured
data = {}
if config.GRIMMORY_LIBRARY_ID:
headers = {'Authorization': f'Bearer {token}'}
lib_resp = requests.get(f"{base_url}/api/v1/libraries", headers=headers, timeout=10)
if lib_resp.status_code == 200:
libraries = lib_resp.json()
target_lib = None
# Try to find by ID first, then by name
for lib in libraries:
if str(lib.get('id')) == str(config.GRIMMORY_LIBRARY_ID) or lib.get('name', '').lower() == str(config.GRIMMORY_LIBRARY_ID).lower():
target_lib = lib
break
if target_lib:
data['libraryId'] = target_lib['id']
if target_lib.get('paths') and len(target_lib['paths']) > 0:
data['pathId'] = target_lib['paths'][0]['id']
else:
flash(f"Library '{target_lib['name']}' has no paths configured.", "error")
return redirect(url_for("issues.index"))
else:
flash(f"Could not find library matching '{config.GRIMMORY_LIBRARY_ID}'.", "error")
return redirect(url_for("issues.index"))
else:
flash(f"Failed to fetch libraries from server. Status: {lib_resp.status_code}", "error")
return redirect(url_for("issues.index"))
# 3. Upload the file to the library
files = {
'file': (
os.path.basename(issue.epub_path),
open(issue.epub_path, 'rb'),
'application/epub+zip'
)
}
headers = {'Authorization': f'Bearer {token}'}
response = requests.post(
f"{base_url}/api/v1/files/upload",
files=files,
data=data,
headers=headers,
timeout=30
)
if response.status_code in (200, 201, 204):
flash("Issue successfully pushed to library.")
elif response.status_code == 409:
flash("Issue was already pushed to the library previously.")
else:
flash(f"Failed to push issue. Status: {response.status_code}. {response.text}", "error")
except requests.RequestException as e:
flash(f"Error connecting to library server: {e}", "error")
except Exception as e:
flash(f"An unexpected error occurred: {e}", "error")
return redirect(url_for("issues.index"))