diff --git a/.bumpversion.toml b/.bumpversion.toml new file mode 100644 index 0000000..894b569 --- /dev/null +++ b/.bumpversion.toml @@ -0,0 +1,21 @@ +[tool.bumpversion] +current_version = "0.1.0" +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\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 = [] diff --git a/.gitea/ISSUE_TEMPLATE/bug.yml b/.gitea/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..8837f36 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,57 @@ +name: Bug Report +description: Report a bug in this project +title: "[Bug]: " +labels: + - kind/bug + - triage/needs triage +body: + + - type: textarea + id: bug + attributes: + label: Describe the bug + description: | + A clear and concise description of what the bug is. + What did you expect to happen? What happened instead? + Include screenshots if applicable. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: | + A clear and concise description of how to reproduce the bug. + Include steps, code snippets, or screenshots if applicable. + validations: + required: true + - type: textarea + id: additional-info + attributes: + label: Additional information + description: | + Add any other context or screenshots about the bug here. + - type: dropdown + id: browsers + attributes: + label: What browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + + - type: dropdown + id: operating-systems + attributes: + label: What operating systems are you seeing the problem on? + multiple: true + options: + - Windows + - macOS + - Linux + - iOS + - Android + + \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..d0d5e93 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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<> $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<> $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 }} + diff --git a/.gitignore b/.gitignore index ed468fc..7037468 100644 --- a/.gitignore +++ b/.gitignore @@ -227,7 +227,6 @@ docs/ config.yaml **/tempCodeRunnerFile.py -uv.lock .history .venv venv diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..005db73 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Use the official Python image as the base image +FROM python:3.11-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the application files into the container +COPY . /app + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Expose the port the app runs on +EXPOSE 5001 + +# Set the default command to run the application +CMD ["python", "src/app.py"] diff --git a/pyproject.toml b/pyproject.toml index 2640fc7..1b6eb5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,15 +5,22 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "flask>=3.1.0", + "anilistapi", + "flask>=3.1.1", "httpx>=0.28.1", + "httpx-retries>=0.3.2", "jinja2>=3.1.6", + "komcache", + "komconfig", "komgapi", + "komsuite-nyaapy", + "limit>=0.2.3", "quart>=0.20.0", ] [tool.uv.sources] -anilistapi = { workspace = true } -komconfig = { workspace = true } -komcache = { workspace = true } +komsuite-nyaapy = { workspace = true } komgapi = { workspace = true } +komcache = { workspace = true } +komconfig = { workspace = true } +anilistapi = { workspace = true } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fed2509 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/src/app.py b/src/app.py index 7ab03e8..766d15f 100644 --- a/src/app.py +++ b/src/app.py @@ -1,21 +1,31 @@ from quart import Quart, render_template, request, jsonify import httpx -from anilistapi.queries.manga import REQUESTS_QUERY, TAGS_QUERY, GENRES_QUERY +from anilistapi.queries.manga import ( + REQUESTS_QUERY, + TAGS_QUERY, + GENRES_QUERY, + REQUESTED_QUERY, +) from anilistapi.schemas.manga import Manga from komconfig import KomConfig from komcache import KomCache from komgapi import komgapi as KOMGAPI from typing import Any, Dict, List +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__) cache = KomCache() cache.create_table( "CREATE TABLE IF NOT EXISTS manga_requests (id INTEGER PRIMARY KEY, manga_id INTEGER, grabbed BOOLEAN DEFAULT 0)" ) -cache.create_table( - "CREATE TABLE IF NOT EXISTS manga_titles (id INTEGER PRIMARY KEY, anilist_id INTEGER DEFAULT 0, komga_title UNIQUE)" -) settings = KomConfig() @@ -25,35 +35,32 @@ komga = KOMGAPI( password=settings.komga.password, ) komga_series = komga.seriesList() -# store the entries in the database table manga_titles, komga_series is a list of strings of the series names -for series in komga_series: - # check if the series is already in the database - existing_series = cache.fetch_one( - query="SELECT komga_title FROM manga_titles WHERE komga_title = ?", - args=(series,), - ) - if existing_series: - # series already exists, skip - continue - else: - cache.insert( - # insert into if not in database - query="INSERT OR IGNORE INTO manga_titles (komga_title) VALUES (?)", - args=(series,), - ) -komga_series = [series.lower() for series in komga_series] -# Update type annotations for fetch_data -async def fetch_data(data: Dict[str, Any]) -> List[Dict[str, Any]]: +@limit(90, 60) +async def fetch_data( + inputdata: Dict[str, Any], check_downloads: bool = False +) -> List[Dict[str, Any]]: + log.error(f"fetch_data called with data: {inputdata}") async with httpx.AsyncClient() as client: try: - variables: Dict[str, Any] = {"search": data["query"]} - if len(data["genres"]) > 0: - variables["genres"] = data["genres"] - if len(data["tags"]) > 0: - variables["tags"] = data["tags"] - print(data["query"], variables) + # Log the incoming data for debugging + + # Ensure 'query' key exists in the data dictionary + if "query" not in inputdata or not inputdata["query"]: + raise ValueError("Missing or empty 'query' key in data") + + variables: Dict[str, Any] = {"search": inputdata["query"]} + if inputdata.get("genres"): + 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( "https://graphql.anilist.co", json={ @@ -62,7 +69,9 @@ async def fetch_data(data: Dict[str, Any]) -> List[Dict[str, Any]]: }, ) response.raise_for_status() + data = response.json() + log.debug(f"GraphQL response: {data}") results: List[Dict[str, Any]] = [] for item in data.get("data", {}).get("Page", {}).get("media", []): @@ -70,7 +79,11 @@ async def fetch_data(data: Dict[str, Any]) -> List[Dict[str, Any]]: in_komga = komga.getSeries( manga.title.english if manga.title.english else manga.title.romaji ) - print(in_komga, manga.title.english) + requested = cache.fetch_one( + query="SELECT manga_id, grabbed FROM manga_requests WHERE manga_id = ?", + args=(manga.id,), + ) + komga_request = bool(requested) results.append( { "id": manga.id, @@ -81,7 +94,7 @@ async def fetch_data(data: Dict[str, Any]) -> List[Dict[str, Any]]: if manga.coverImage else "https://demofree.sirv.com/nope-not-here.jpg", "status": manga.status, - "type": manga.type, + "type": manga.format, "genres": manga.genres or [], "tags": [tag.name for tag in (manga.tags or [])], "description": manga.description.replace("
", "\n") @@ -89,24 +102,107 @@ async def fetch_data(data: Dict[str, Any]) -> List[Dict[str, Any]]: else "No description available", "isAdult": manga.isAdult, "in_komga": in_komga, + "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 + except ValueError as ve: + log.error(f"ValueError in fetch_data: {ve}") + return [] 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 [] except Exception as e: - print(f"Unexpected error: {e}") + log.error(f"Unexpected error in fetch_data: {e}") return [] +@limit(90, 60) +async def fetch_requested_data(data: list[int]) -> List[Dict[str, Any]]: + requested_data: list[dict[str, Any]] = [] + for manga_id in data: + async with httpx.AsyncClient() as client: + try: + variables: Dict[str, Any] = { + "search": manga_id, + "type": "MANGA", + } # TODO: replace the type once the UI has been updated + # print(data, variables) + response = await client.post( + "https://graphql.anilist.co", + json={ + "query": REQUESTED_QUERY, + "variables": variables, + }, + ) + response.raise_for_status() + data = response.json() + + # print(data) + entry = data.get("data", {}).get("Media", {}) + if entry: + manga = Manga(**entry) + + in_komga = komga.getSeries( + manga.title.english + if manga.title.english + else manga.title.romaji + ) + requested = cache.fetch_one( + query="SELECT grabbed FROM manga_requests WHERE manga_id = ?", + args=(manga.id,), + ) + komga_request = bool(requested) + + requested_data.append( + { + "id": manga.id, + "title": manga.title.english + if manga.title.english + else manga.title.romaji, + "image": manga.coverImage.get("large") + if manga.coverImage + else None, + "status": manga.status, + "type": manga.format, + "genres": manga.genres or [], + "tags": [tag.name for tag in (manga.tags or [])], + "description": manga.description.replace("
", "\n") + if manga.description + else None, + "isAdult": manga.isAdult, + "in_komga": bool(in_komga), + "requested": bool(komga_request), + } + ) + log.debug( + requested_data, + ) + + except httpx.RequestError as e: + print(f"An error occurred while requesting data: {e}") + requested_data.append({"id": manga_id}) + except Exception as e: + print(f"Unexpected error: {e}") + requested_data.append({"id": manga_id}) + return requested_data + @app.route("/api/genres") async def get_genres(): async with httpx.AsyncClient() as client: try: response = await client.post( - f"https://graphql.anilist.co", + "https://graphql.anilist.co", json={"query": GENRES_QUERY}, ) response.raise_for_status() @@ -124,7 +220,7 @@ async def get_tags(): async with httpx.AsyncClient() as client: try: response = await client.post( - f"https://graphql.anilist.co", + "https://graphql.anilist.co", json={"query": TAGS_QUERY}, ) response.raise_for_status() @@ -146,6 +242,43 @@ async def search(): 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"]) async def index(): return await render_template("index.html", komga_series=komga_series) @@ -154,17 +287,34 @@ async def index(): @app.route("/request", methods=["POST"]) async def log_request(): data = await request.get_json() - item = data.get("title") + item = data.get("item") if item: asynccache = KomCache() - manga_title = data.get("title") asynccache.insert( - "INSERT INTO manga_requests (manga_id, manga_title) VALUES (?, ?)", - (item, manga_title), + "INSERT INTO manga_requests (manga_id) VALUES (?)", + (item,), ) return jsonify({"status": "success"}) return jsonify({"status": "failed"}), 400 +@app.route("/requests", methods=["GET"]) +async def requests_page(): + requests = ( + cache.fetch_all(query="SELECT manga_id, grabbed FROM manga_requests") or [] + ) + entries: List[Dict[str, Any]] = [] + req_ids = [req[0] for req in requests] + if req_ids: + entries = await fetch_requested_data(req_ids) + else: + entries = [] + data = [] + for entry in entries: + data.append(dict(entry)) + + return await render_template("requests.html", requests=data) + + if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5001) diff --git a/src/static/style.css b/src/static/style.css index e5f0eb2..514b1e3 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -1,8 +1,9 @@ +/* other styles are in magic.css */ /* ========== GRID LAYOUT ========== */ .results { display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 20px; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1em; margin-top: 20px; } @@ -20,11 +21,16 @@ border: 1px solid #ccc; background: #fafafa; text-align: center; - transition: box-shadow 0.2s; + transition: box-shadow 0.2s transform 0.2s; + border-radius: 10px; } .card:hover { box-shadow: 0 0 10px #aaa; + transform: scale(1.1); + /* pop out by 10% */ + z-index: 2; + /* ensure it appears above others */ } .card img { @@ -39,6 +45,32 @@ justify-content: space-around; } +.actions .info { + background-color: #007bff; + color: white; + border: none; + padding: 0.5em 1em; + cursor: pointer; + border-radius: 5px; + margin-right: 0.5em; +} + +.actions .info:hover { + background-color: #0056b3; +} + +.actions .request { + background-color: #28a745; + color: white; + border: none; + padding: 0.5em 1em; + cursor: pointer; + border-radius: 5px; +} + +.actions .request:hover { + background-color: #1e7e34; +} /* ========== MODAL ========== */ .modal { position: fixed; @@ -84,12 +116,15 @@ /* ========== BLUR CONTROL VIA BODY CLASS ========== */ body.nsfw-disabled .image-container.nsfw img { - filter: blur(8px); + filter: grayscale(100%) blur(5px) !important; + /* Ensure the blur is applied */ + transition: filter 0.3s ease; + /* Add smooth transition */ } body.nsfw-disabled .image-container.nsfw:hover img { - filter: none; - /* Remove blur on hover, set as important */ + filter: none !important; + /* Remove blur on hover */ } @@ -145,21 +180,54 @@ body.nsfw-disabled .image-container.nsfw:hover img { .reset { /* Implement design here */ + background-color: #f44336; + /* round the edges of the button */ + border-radius: 9px; + + } + + .selectors { + display: flex; + gap: 10px; + margin-bottom: 8px; + margin-top: 5px; + flex-wrap: wrap; } -.info { - /* Implement design here */ -} -.request { - /* Implement design here */ -} + +/* ========== KOMGA SPECIFIC STYLES ========== */ + .card.komga { border: 3px solid green; box-sizing: border-box; /* disable the request button for entries in Komga */ + /* only allow the info button to be clicked */ + opacity: 0.3; + +} + +.card.requested { + border: 3px solid orange; + box-sizing: border-box; + /* disable the request button for entries in Komga */ + /* only allow the info button to be clicked */ + opacity: 0.3; +} + +.card.manga.requested .request { pointer-events: none; opacity: 0.5; +} +.card.komga .request { + pointer-events: none; + opacity: 0.5; +} +.card.komga:hover, +.card.requested:hover { + transform: none !important; + box-shadow: none !important; + z-index: auto !important; } \ No newline at end of file diff --git a/src/templates/index.html b/src/templates/index.html index b5ad9cc..dd30f1c 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -14,6 +14,7 @@
+ @@ -30,22 +31,39 @@
+
+ + +
+ + + +
+ + - {% if results %} - +
+ {% if results %} {% for result in results %} -
+
Cover @@ -56,20 +74,19 @@

{{ result.title }}

- + {% if not result.in_komga %} - + {% endif %}
{% endfor %} + {% endif %}
- {% endif %} -
- -
+ +
+ + + + + + \ No newline at end of file