update files, add release workflow

This commit is contained in:
2025-05-23 15:45:10 +02:00
parent 1d0f293e19
commit 10b47d2f13
9 changed files with 372 additions and 77 deletions

21
.bumpversion.toml Normal file
View File

@@ -0,0 +1,21 @@
[tool.bumpversion]
current_version = "0.1.0"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
replace = "{new_version}"
regex = false
ignore_missing_version = false
ignore_missing_files = false
tag = false
sign_tags = false
tag_name = "v{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
allow_dirty = false
commit = false
message = "Bump version: {current_version} → {new_version}"
moveable_tags = []
commit_args = ""
setup_hooks = []
pre_commit_hooks = []
post_commit_hooks = []

View File

@@ -0,0 +1,120 @@
on:
workflow_dispatch:
inputs:
release_notes:
description: Release notes (use \n for newlines)
type: string
required: false
github_release:
description: 'Create Gitea Release'
default: true
type: boolean
docker_release:
description: 'Push Docker images'
default: true
type: boolean
bump:
description: 'Bump type'
required: true
default: 'patch'
type: choice
options:
- 'major'
- 'minor'
- 'patch'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.TOKEN }}
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python
run: uv python install
with:
python-version-file: "pyproject.toml"
- name: Install the project
run: uv sync --locked --all-extras --dev
- name: Set Git identity
run: |
git config user.name "Gitea CI"
git config user.email "ci@git.theprivateserver.de"
- name: Bump version
id: bump
run: |
uv tool install bump-my-version
uv tool run bump-my-version bump ${{ github.event.inputs.bump }}
# echo the version to github env, the version is shown by using uv tool run bump-my-version show current_version
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$(uv tool run bump-my-version show current_version)" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Check version
run: echo ${{ env.VERSION }}
- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
- name: Add release notes to environment
id: add_release_notes
run: |
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
echo "${{ github.event.inputs.release_notes }}" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Build and store Docker image
if: ${{ github.event.inputs.docker_release == 'true' }}
env:
TAG: ${{ github.sha }}
run: |
REPO_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
docker buildx build \
--platform linux/amd64 \
--tag ${{ secrets.REGISTRY }}/${REPO_NAME}:latest \
--tag ${{ secrets.REGISTRY }}/${REPO_NAME}:${TAG} \
--push .
- name: Generate changelog
id: changelog
uses: metcalfc/changelog-generator@v4.6.2
with:
token: ${{ secrets.TOKEN }}
- name: Get the changelog
run: |
cat << "EOF"
${{ steps.changelog.outputs.changelog }}
EOF
- name: Create release
id: create_release
if: ${{ github.event.inputs.github_release == 'true' }}
uses: softprops/action-gh-release@master
with:
tag_name: ${{ env.VERSION }}
release_name: Release ${{ env.VERSION }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
make_latest: true
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}

1
.gitignore vendored
View File

@@ -227,7 +227,6 @@ docs/
config.yaml config.yaml
**/tempCodeRunnerFile.py **/tempCodeRunnerFile.py
uv.lock
.history .history
.venv .venv
venv venv

View File

@@ -5,16 +5,22 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"flask>=3.1.0", "anilistapi",
"flask>=3.1.1",
"httpx>=0.28.1", "httpx>=0.28.1",
"httpx-retries>=0.3.2",
"jinja2>=3.1.6", "jinja2>=3.1.6",
"komcache",
"komconfig",
"komgapi", "komgapi",
"komsuite-nyaapy",
"limit>=0.2.3", "limit>=0.2.3",
"quart>=0.20.0", "quart>=0.20.0",
] ]
[tool.uv.sources] [tool.uv.sources]
anilistapi = { workspace = true } komsuite-nyaapy = { workspace = true }
komconfig = { workspace = true }
komcache = { workspace = true }
komgapi = { workspace = true } komgapi = { workspace = true }
komcache = { workspace = true }
komconfig = { workspace = true }
anilistapi = { workspace = true }

88
requirements.txt Normal file
View File

@@ -0,0 +1,88 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml -o requirements.txt
-e file:///home/alexander/GitHub/KomSuite/packages/KomConfig
# via komgapi
-e file:///home/alexander/GitHub/KomSuite/packages/komgAPI
# via kompage (pyproject.toml)
aiofiles==24.1.0
# via quart
antlr4-python3-runtime==4.9.3
# via omegaconf
anyio==4.9.0
# via httpx
blinker==1.9.0
# via
# flask
# quart
certifi==2025.4.26
# via
# httpcore
# httpx
click==8.2.0
# via
# flask
# quart
flask==3.1.1
# via
# kompage (pyproject.toml)
# quart
h11==0.16.0
# via
# httpcore
# hypercorn
# wsproto
h2==4.2.0
# via hypercorn
hpack==4.1.0
# via h2
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via
# kompage (pyproject.toml)
# komgapi
hypercorn==0.17.3
# via quart
hyperframe==6.1.0
# via h2
idna==3.10
# via
# anyio
# httpx
itsdangerous==2.2.0
# via
# flask
# quart
jinja2==3.1.6
# via
# kompage (pyproject.toml)
# flask
# quart
limit==0.2.3
# via kompage (pyproject.toml)
loguru==0.7.3
# via komgapi
markupsafe==3.0.2
# via
# flask
# jinja2
# quart
# werkzeug
omegaconf==2.3.0
# via komconfig
priority==2.0.0
# via hypercorn
pyyaml==6.0.2
# via omegaconf
quart==0.20.0
# via kompage (pyproject.toml)
sniffio==1.3.1
# via anyio
typing-extensions==4.13.2
# via komgapi
werkzeug==3.1.3
# via
# flask
# quart
wsproto==1.2.0
# via hypercorn

View File

@@ -12,7 +12,14 @@ from komcache import KomCache
from komgapi import komgapi as KOMGAPI from komgapi import komgapi as KOMGAPI
from typing import Any, Dict, List from typing import Any, Dict, List
from limit import limit from limit import limit
import loguru
import sys
from komsuite_nyaapy import Nyaa
log = loguru.logger
log.remove()
log.add("application.log", rotation="1 week", retention="1 month")
log.add(sys.stdout)
app = Quart(__name__) app = Quart(__name__)
cache = KomCache() cache = KomCache()
@@ -32,19 +39,28 @@ komga_series = komga.seriesList()
@limit(90, 60) @limit(90, 60)
async def fetch_data( async def fetch_data(
data: Dict[str, Any], query: str = REQUESTS_QUERY inputdata: Dict[str, Any], check_downloads: bool = False
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
log.error(f"fetch_data called with data: {inputdata}")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
variables: Dict[str, Any] = {"search": data["query"]} # Log the incoming data for debugging
if data.get("genres"):
variables["genres"] = data["genres"] # Ensure 'query' key exists in the data dictionary
if data.get("tags"): if "query" not in inputdata or not inputdata["query"]:
variables["tags"] = data["tags"] raise ValueError("Missing or empty 'query' key in data")
if data.get("type"):
if data["type"] in ["MANGA", "NOVEL"]: variables: Dict[str, Any] = {"search": inputdata["query"]}
variables["format"] = data["type"] if inputdata.get("genres"):
print(data["query"], variables) variables["genres"] = inputdata["genres"]
if inputdata.get("tags"):
variables["tags"] = inputdata["tags"]
if inputdata.get("type"):
if inputdata["type"] in ["MANGA", "NOVEL"]:
variables["format"] = inputdata["type"]
log.debug(f"GraphQL variables: {variables}")
response = await client.post( response = await client.post(
"https://graphql.anilist.co", "https://graphql.anilist.co",
json={ json={
@@ -55,6 +71,7 @@ async def fetch_data(
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
log.debug(f"GraphQL response: {data}")
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
for item in data.get("data", {}).get("Page", {}).get("media", []): for item in data.get("data", {}).get("Page", {}).get("media", []):
@@ -67,7 +84,6 @@ async def fetch_data(
args=(manga.id,), args=(manga.id,),
) )
komga_request = bool(requested) komga_request = bool(requested)
results.append( results.append(
{ {
"id": manga.id, "id": manga.id,
@@ -87,18 +103,30 @@ async def fetch_data(
"isAdult": manga.isAdult, "isAdult": manga.isAdult,
"in_komga": in_komga, "in_komga": in_komga,
"requested": komga_request, "requested": komga_request,
"siteUrl": manga.siteUrl,
} }
) )
if check_downloads:
for result in results:
downloads = Nyaa().search(result.get("title"), 3, 1)
else:
downloads = []
result["download"] = len(downloads) if downloads else 0
log.debug(f"Fetched {len(results)} results for query: {inputdata['query']}")
log.error(f"Results: {results}")
return results return results
except ValueError as ve:
log.error(f"ValueError in fetch_data: {ve}")
return []
except httpx.RequestError as e: except httpx.RequestError as e:
print(f"An error occurred while requesting data: {e}") log.error(f"HTTP request error in fetch_data: {e}")
return [] return []
except Exception as e: except Exception as e:
print(f"Unexpected error: {e}") log.error(f"Unexpected error in fetch_data: {e}")
return [] return []
@limit(90, 60)
async def fetch_requested_data(data: list[int]) -> List[Dict[str, Any]]: async def fetch_requested_data(data: list[int]) -> List[Dict[str, Any]]:
requested_data: list[dict[str, Any]] = [] requested_data: list[dict[str, Any]] = []
for manga_id in data: for manga_id in data:
@@ -130,7 +158,7 @@ async def fetch_requested_data(data: list[int]) -> List[Dict[str, Any]]:
else manga.title.romaji else manga.title.romaji
) )
requested = cache.fetch_one( requested = cache.fetch_one(
query="SELECT manga_id, grabbed FROM manga_requests WHERE manga_id = ?", query="SELECT grabbed FROM manga_requests WHERE manga_id = ?",
args=(manga.id,), args=(manga.id,),
) )
komga_request = bool(requested) komga_request = bool(requested)
@@ -143,19 +171,22 @@ async def fetch_requested_data(data: list[int]) -> List[Dict[str, Any]]:
else manga.title.romaji, else manga.title.romaji,
"image": manga.coverImage.get("large") "image": manga.coverImage.get("large")
if manga.coverImage if manga.coverImage
else "https://demofree.sirv.com/nope-not-here.jpg", else None,
"status": manga.status, "status": manga.status,
"type": manga.format, "type": manga.format,
"genres": manga.genres or [], "genres": manga.genres or [],
"tags": [tag.name for tag in (manga.tags or [])], "tags": [tag.name for tag in (manga.tags or [])],
"description": manga.description.replace("<br>", "\n") "description": manga.description.replace("<br>", "\n")
if manga.description if manga.description
else "No description available", else None,
"isAdult": manga.isAdult, "isAdult": manga.isAdult,
"in_komga": in_komga, "in_komga": bool(in_komga),
"requested": komga_request, "requested": bool(komga_request),
} }
) )
log.debug(
requested_data,
)
except httpx.RequestError as e: except httpx.RequestError as e:
print(f"An error occurred while requesting data: {e}") print(f"An error occurred while requesting data: {e}")
@@ -211,6 +242,43 @@ async def search():
return jsonify(results) return jsonify(results)
@app.route("/api/search", methods=["GET"])
async def api_search():
"""
API endpoint for searching manga.
Parameters:
- q: Query string to search for
Returns:
- JSON array of matching manga entries
"""
try:
query = request.args.get("q")
if not query:
return jsonify({"error": "Missing required parameter: q"}), 400
# Build search parameters
search_data: Dict[str, Any] = {"query": query}
# Add optional filters if provided
log.info(f"API search request: {search_data}")
# Fetch results using existing fetch_data function
results = await fetch_data(search_data, check_downloads=False)
if not results:
return jsonify({"results": [], "count": 0}), 404
return jsonify({"results": results, "count": len(results)})
except Exception as e:
log.error(f"Error in API search: {e}")
return jsonify({"error": "An unexpected error occurred"}), 500
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
async def index(): async def index():
return await render_template("index.html", komga_series=komga_series) return await render_template("index.html", komga_series=komga_series)
@@ -241,9 +309,11 @@ async def requests_page():
entries = await fetch_requested_data(req_ids) entries = await fetch_requested_data(req_ids)
else: else:
entries = [] entries = []
print(entries) data = []
for entry in entries:
data.append(dict(entry))
return await render_template("requests.html", requests=entries) return await render_template("requests.html", requests=data)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -194,14 +194,7 @@ body.nsfw-disabled .image-container.nsfw:hover img {
flex-wrap: wrap; flex-wrap: wrap;
} }
.info {
/* Implement design here */
background-color: #4CAF50;
}
.request {
/* Implement design here */
}
/* ========== KOMGA SPECIFIC STYLES ========== */ /* ========== KOMGA SPECIFIC STYLES ========== */

View File

@@ -46,6 +46,8 @@
<input type="checkbox" id="toggleNSFW" /> <input type="checkbox" id="toggleNSFW" />
Unblur NSFW images Unblur NSFW images
</label> </label>
<!-- if typeselect is manga or all, show, else hide -->
<label style="margin-left: 1em;"> <label style="margin-left: 1em;">
<input type="checkbox" id="toggleNovels" checked /> <input type="checkbox" id="toggleNovels" checked />
Show Novels Show Novels

View File

@@ -4,7 +4,6 @@
<head> <head>
<title>Requested Manga</title> <title>Requested Manga</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
@@ -15,40 +14,12 @@
<button class="index" onclick="window.location.href='/'">Back to Index</button> <button class="index" onclick="window.location.href='/'">Back to Index</button>
</div> </div>
<div class="results">
{% if results %}
{% for result in results %}
<div class="card {{ result.type | lower }} {% if result.in_komga %}komga{% else %}requested{% endif %}">
<div class="image-container {{ 'nsfw' if result.isAdult else '' }}">
<img src="{{ result.image }}" alt="Cover">
{% if result.isAdult %}
<div class="adult-badge">18+</div>
{% endif %}
</div>
<p>{{ result.title }}</p>
<div class="actions">
<button onclick="showInfo({{ result | tojson | safe }})" class="info">Info</button>
<!-- if entry has in_komga == true, do not show the request button, else show it -->
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% if requests %}
<div class="results"> <div class="results">
{% if requests %}
{% for request in requests %} {% for request in requests %}
<div class="card {{ request.type | lower }} {% if request.in_komga %}komga{% else %}requested{% endif %}"
<div class="card {{ request.type | lower }} {% if request.in_komga %}komga{% else %}requested{% endif %}"> data-info="{{ request | tojson }}">
<div class="image-container {{ 'nsfw' if request.isAdult else '' }}"> <div class="image-container {{ 'nsfw' if request.isAdult else '' }}">
<img src="{{ request.image }}" alt="Cover"> <img src="{{ request.image }}" alt="Cover">
{% if request.isAdult %} {% if request.isAdult %}
<div class="adult-badge">18+</div> <div class="adult-badge">18+</div>
@@ -57,16 +28,17 @@
<p>{{ request.title }}</p> <p>{{ request.title }}</p>
<div class="actions"> <div class="actions">
<button onclick="showInfo({{ request | tojson | safe }})" class="info">Info</button> <button class="info">Info</button>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% endif %}
</div> </div>
{% else %} {% if not requests %}
<p>No requests found.</p> <p>No requests found.</p>
{% endif %} {% endif %}
<!-- Info Modal -->
<div id="infoModal" class="modal" style="display:none;"> <div id="infoModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<span class="close" onclick="closeModal()">&times;</span> <span class="close" onclick="closeModal()">&times;</span>
@@ -82,16 +54,40 @@
</div> </div>
<script> <script>
document.addEventListener("DOMContentLoaded", () => {
// Add click event listeners to all info buttons
document.querySelectorAll('.card .info').forEach(button => {
button.addEventListener('click', (e) => {
// Get the parent card element
const card = e.target.closest('.card');
// Get the data from the data-info attribute
try {
const data = JSON.parse(card.dataset.info);
console.log("Info button clicked, data:", data);
showInfo(data);
} catch (error) {
console.error("Error parsing data:", error);
console.error("Raw data:", card.dataset.info);
}
});
});
});
function showInfo(data) { function showInfo(data) {
const modal = document.getElementById("infoModal"); console.log("showInfo data:", data);
document.getElementById("modalTitle").textContent = data.title; try {
document.getElementById("modalStatus").textContent = data.status || "Unknown"; const modal = document.getElementById("infoModal");
document.getElementById("modalType").textContent = data.type || "Unknown"; document.getElementById("modalTitle").textContent = data.title || "Unknown";
document.getElementById("modalGenres").textContent = (data.genres || []).join(", "); document.getElementById("modalStatus").textContent = data.status || "Unknown";
document.getElementById("modalTags").textContent = (data.tags || []).join(", "); document.getElementById("modalType").textContent = data.type || "Unknown";
document.getElementById("modalAdult").textContent = data.isAdult ? "Yes" : "No"; document.getElementById("modalGenres").textContent = (data.genres || []).join(", ");
document.getElementById("modalDescription").innerHTML = data.description || "No description available."; document.getElementById("modalTags").textContent = (data.tags || []).join(", ");
modal.style.display = "block"; document.getElementById("modalAdult").textContent = data.isAdult ? "Yes" : "No";
document.getElementById("modalDescription").innerHTML = data.description || "No description available.";
modal.style.display = "block";
} catch (error) {
console.error("Error displaying info:", error);
}
} }
function closeModal() { function closeModal() {