├── .github ├── dependabot.yml └── workflows │ ├── issue-handler.yml │ └── main.yml ├── Dockerfile ├── LICENSE ├── README.md ├── gunicorn_config.py ├── requirements.txt └── src ├── _scrapers.py ├── eBookBuddy.py ├── static ├── dark.png ├── ebookbuddy.png ├── light.png ├── logo.png ├── readarr.svg ├── script.js └── style.css └── templates └── base.html /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "docker" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | time: "13:00" 12 | -------------------------------------------------------------------------------- /.github/workflows/issue-handler.yml: -------------------------------------------------------------------------------- 1 | name: Close Non-Bug Issues 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | issues: 7 | types: [opened] 8 | 9 | jobs: 10 | close-issue-if-not-bug: 11 | runs-on: ubuntu-latest 12 | env: 13 | GH_TOKEN: ${{ secrets.PAT_TOKEN }} 14 | steps: 15 | - name: Check if the issue contains "bug" 16 | id: check_bug 17 | env: 18 | BODY: ${{ github.event.issue.body }} 19 | TITLE: ${{ github.event.issue.title }} 20 | run: | 21 | # Check if the title or body contains "bug" 22 | if echo "$TITLE" | grep -qi 'bug' || echo "$BODY" | grep -qi 'bug'; then 23 | echo "This is a bug-related issue. Keeping it open." 24 | echo "is_bug=true" >> $GITHUB_ENV 25 | else 26 | echo "This is not a bug-related issue. Closing it." 27 | echo "is_bug=false" >> $GITHUB_ENV 28 | fi 29 | 30 | - name: Close issue and add comment if not bug 31 | if: env.is_bug == 'false' 32 | env: 33 | COMMENT: | 34 | ### Issue 35 | - **Feature Request:** 36 | If this is a feature request, unfortunately no new features are planned at present. The goal of this project is to keep the feature set as minimal as possible. Consider forking this repository and creating your own image to suit your requirements. 37 | **PRs** are open, but only for essential changes/features. 38 | 39 | - **Specific Issues:** 40 | If you're experiencing an issue, please check through previous issues first, as it may have already been addressed. 41 | If it hasn’t been covered, you'll need to clone this repository and run it locally to investigate the issue further. There are plenty of resources available to help you get familiar with Docker and the code used here, so please ensure you explore those fully. 42 | Please also note that this project may not work across all setups and systems. 43 | 44 | - **Genuine Bugs:** 45 | If you believe you've found a genuine bug that affects the main functionality, please raise an issue with detailed logs and a specific bug report. It would also be greatly appreciated if you can suggest a possible solution. 46 | 47 | Thanks, and best of luck! 48 | 49 | --- 50 | 51 | It can be frustrating when an **issue** gets closed automatically, but this process helps keep track of actionable bugs. 52 | **Feature requests** are only considered if the requester contributes code or takes significant steps toward implementing the feature themselves. Without this commitment or partial coding effort, the request will not be considered. Thank you for your understanding! 53 | 54 | --- 55 | 56 | **NOTE:** THIS IS AN AUTOMATICALLY GENERATED COMMENT. 57 | 58 | run: | 59 | gh issue close ${{ github.event.issue.number }} --comment "$COMMENT" --repo ${{ github.repository }} 60 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**/*.md' 9 | 10 | jobs: 11 | bump-version-and-create-release-tag: 12 | runs-on: ubuntu-latest 13 | env: 14 | GH_TOKEN: ${{ secrets.PAT_TOKEN }} 15 | outputs: 16 | new_version: ${{ steps.increment_version.outputs.new_version }} 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | with: 22 | token: ${{ secrets.PAT_TOKEN }} 23 | 24 | - name: Fetch and list tags 25 | run: | 26 | git fetch --tags 27 | echo "Tags:" 28 | git tag --list 29 | 30 | - name: Get current version 31 | id: get_version 32 | run: | 33 | VERSION=$(git tag --list | sed 's/^v//' | awk -F. '{ if (NF == 2) printf("%s.0.%s\n", $1, $2); else print $0 }' | sort -V | tail -n 1 | sed 's/^/v/') 34 | echo "CURRENT_VERSION=$VERSION" >> $GITHUB_ENV 35 | echo "Current version: $VERSION" 36 | 37 | - name: Increment version 38 | id: increment_version 39 | run: | 40 | NEW_VERSION=$(echo ${{ env.CURRENT_VERSION }} | awk -F. '{printf("%d.%d.%d", $1, $2, $3+1)}') 41 | echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV 42 | echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT 43 | echo "New version: $NEW_VERSION" 44 | 45 | - name: Create new Git tag 46 | run: | 47 | git config --global user.name 'github-actions[bot]' 48 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 49 | git tag -a v${{ env.NEW_VERSION }} -m "Release version ${{ env.NEW_VERSION }}" 50 | git push origin --tags 51 | 52 | - name: Create release 53 | run: | 54 | gh release create "v${{ env.NEW_VERSION }}" \ 55 | --repo="${GITHUB_REPOSITORY}" \ 56 | --title="v${{ env.NEW_VERSION }}" \ 57 | --generate-notes 58 | 59 | build-docker-image: 60 | runs-on: ubuntu-latest 61 | needs: bump-version-and-create-release-tag 62 | steps: 63 | - name: Checkout 64 | uses: actions/checkout@v4 65 | 66 | - name: Set up QEMU 67 | uses: docker/setup-qemu-action@v3 68 | 69 | - name: Set up Docker Buildx 70 | uses: docker/setup-buildx-action@v3 71 | 72 | - name: Login to Docker Hub 73 | uses: docker/login-action@v3 74 | with: 75 | username: ${{ secrets.DOCKERHUB_USERNAME }} 76 | password: ${{ secrets.DOCKERHUB_TOKEN }} 77 | 78 | - name: Convert repository name to lowercase 79 | id: lowercase_repo 80 | run: | 81 | REPO_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') 82 | echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV 83 | 84 | - name: Build and push 85 | uses: docker/build-push-action@v5 86 | with: 87 | context: . 88 | platforms: linux/amd64,linux/arm64 89 | file: ./Dockerfile 90 | push: true 91 | build-args: | 92 | RELEASE_VERSION=${{ needs.bump-version-and-create-release-tag.outputs.new_version }} 93 | tags: | 94 | ${{ env.REPO_NAME }}:${{ needs.bump-version-and-create-release-tag.outputs.new_version }} 95 | ${{ env.REPO_NAME }}:latest 96 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM python:3.12-alpine 3 | 4 | # Set build arguments 5 | ARG RELEASE_VERSION 6 | ENV RELEASE_VERSION=${RELEASE_VERSION} 7 | 8 | # Create User 9 | ARG UID=1000 10 | ARG GID=1000 11 | RUN addgroup -g $GID general_user && \ 12 | adduser -D -u $UID -G general_user -s /bin/sh general_user 13 | 14 | # Create directories and set permissions 15 | COPY . /ebookbuddy 16 | WORKDIR /ebookbuddy 17 | RUN chown -R $UID:$GID /ebookbuddy 18 | 19 | # Install Firefox and Xvfb 20 | RUN apk --no-cache add \ 21 | firefox \ 22 | xvfb \ 23 | ttf-freefont \ 24 | fontconfig \ 25 | dbus 26 | 27 | # Install requirements and run code 28 | RUN pip install --no-cache-dir -r requirements.txt 29 | ENV PYTHONPATH "${PYTHONPATH}:/ebookbuddy/src" 30 | EXPOSE 5000 31 | USER general_user 32 | CMD ["gunicorn", "src.eBookBuddy:app", "-c", "gunicorn_config.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 TheWicklowWolf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/TheWicklowWolf/ebookbuddy/actions/workflows/main.yml/badge.svg) 2 | ![Docker Pulls](https://img.shields.io/docker/pulls/thewicklowwolf/ebookbuddy.svg) 3 | 4 | 5 | 6 | image 7 | 8 | 9 | Book discovery tool that provides recommendations based on selected Readarr books. 10 | 11 | 12 | ## Run using docker-compose 13 | 14 | ```yaml 15 | services: 16 | ebookbuddy: 17 | image: thewicklowwolf/ebookbuddy:latest 18 | container_name: ebookbuddy 19 | volumes: 20 | - /path/to/config:/ebookbuddy/config # Optional 21 | - /etc/localtime:/etc/localtime:ro 22 | ports: 23 | - 5000:5000 24 | restart: unless-stopped 25 | ``` 26 | 27 | ## Configuration via environment variables 28 | 29 | Certain values can be set via environment variables: 30 | 31 | * __readarr_address__: The URL for Readarr. Defaults to `http://192.168.1.2:8787`. 32 | * __readarr_api_key__: The API key for Readarr. Defaults to ``. 33 | * __root_folder_path__: The root folder path for Books. Defaults to `/data/media/books/`. 34 | * __google_books_api_key__: The API key for Google Books. Defaults to ``. 35 | * __readarr_api_timeout__: Timeout duration for Readarr API calls. Defaults to `120`. 36 | * __quality_profile_id__: Quality Profile ID in Readarr. Defaults to `1`. 37 | * __metadata_profile_id__: Metadata Profile ID in Readarr. Defaults to `1` 38 | * __search_for_missing_book__: Whether to start searching for book when adding. Defaults to `False` 39 | * __minimum_rating__: Minimum Movie Rating. Defaults to `3.5`. 40 | * __minimum_votes__: Minimum Vote Count. Defaults to `500`. 41 | * __goodreads_wait_delay__: Delay to allow for slow data retrieval from GoodReads. Defaults to `12.5`. 42 | * __readarr_wait_delay__: Delay to allow for slow data retrieval from GoodReads. Defaults to `7.5`. 43 | * __thread_limit__: Max number of concurrent threads to use for data retrieval. Defaults to `1`. 44 | * __auto_start__: Whether to run automatically at startup. Defaults to `False`. 45 | * __auto_start_delay__: Delay duration for Auto Start in Seconds (if enabled). Defaults to `60`. 46 | 47 | --- 48 | 49 | 50 | image 51 | 52 | 53 | 54 | image 55 | 56 | --- 57 | 58 | https://hub.docker.com/r/thewicklowwolf/ebookbuddy 59 | -------------------------------------------------------------------------------- /gunicorn_config.py: -------------------------------------------------------------------------------- 1 | bind = "0.0.0.0:5000" 2 | workers = 1 3 | threads = 4 4 | timeout = 120 5 | worker_class = "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | gunicorn 2 | gevent 3 | gevent-websocket 4 | flask 5 | flask_socketio 6 | requests 7 | thefuzz 8 | Unidecode 9 | selenium 10 | webdriver-manager 11 | PyVirtualDisplay -------------------------------------------------------------------------------- /src/_scrapers.py: -------------------------------------------------------------------------------- 1 | import random 2 | import platform 3 | from urllib.parse import urlparse 4 | from selenium import webdriver 5 | from selenium.webdriver.firefox.service import Service as FireFoxService 6 | from selenium.webdriver.common.by import By 7 | from selenium.webdriver.common.keys import Keys 8 | from selenium.webdriver.support.ui import WebDriverWait 9 | from selenium.webdriver.support import expected_conditions as EC 10 | from thefuzz import fuzz 11 | from pyvirtualdisplay import Display 12 | from webdriver_manager.firefox import GeckoDriverManager 13 | 14 | 15 | class Goodreads_Scraper: 16 | def __init__(self, logger, stop_event, minimum_rating, minimum_votes, goodreads_wait_delay): 17 | self.diagnostic_logger = logger 18 | self.stop_event = stop_event 19 | self.minimum_rating = minimum_rating 20 | self.minimum_votes = minimum_votes 21 | self.goodreads_wait_delay = goodreads_wait_delay 22 | self.user_agents = [ 23 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36", 24 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36", 25 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", 26 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36", 27 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36 Edg/111.0.1661.62", 28 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36", 29 | ] 30 | self.firexfox_options = webdriver.FirefoxOptions() 31 | self.firexfox_options.add_argument("--no-sandbox") 32 | self.firexfox_options.add_argument("--disable-dev-shm-usage") 33 | self.firexfox_options.add_argument("--avoid-stats=true") 34 | self.firexfox_options.add_argument("--window-size=1280,768") 35 | if "linux" in platform.platform().lower(): 36 | display = Display(backend="xvfb", visible=False, size=(1280, 768)) 37 | display.start() 38 | else: 39 | self.firexfox_options.add_argument("--headless") 40 | 41 | def get_firexfox_driver(self): 42 | firexfox_options = self.firexfox_options 43 | firexfox_options.add_argument(f"--user-agent={random.choice(self.user_agents)}") 44 | if "linux" in platform.platform().lower(): 45 | return webdriver.Firefox(options=firexfox_options, service=FireFoxService(GeckoDriverManager().install())) 46 | else: 47 | return webdriver.Firefox(options=firexfox_options) 48 | 49 | def goodreads_recommendations(self, query): 50 | similar_books = [] 51 | book_link = None 52 | driver = None 53 | try: 54 | try: 55 | if self.stop_event.is_set(): 56 | self.diagnostic_logger.info("Stop request detected, exiting...") 57 | return [] 58 | self.diagnostic_logger.error(f"Creating New Driver...") 59 | driver = self.get_firexfox_driver() 60 | url = f"https://www.goodreads.com/search?q={query.replace(' ', '+')}" 61 | driver.get(url) 62 | 63 | except Exception as e: 64 | self.diagnostic_logger.error(f"Failed to create driver: {str(e)}") 65 | raise Exception("Failed to create driver...") 66 | 67 | try: 68 | wait = WebDriverWait(driver, self.goodreads_wait_delay) 69 | self.diagnostic_logger.info(f"Waiting to see if Overlay is displayed...") 70 | overlay = wait.until(lambda driver: self.stop_event.is_set() or EC.visibility_of_element_located((By.CLASS_NAME, "Overlay__window"))(driver)) 71 | if self.stop_event.is_set(): 72 | self.diagnostic_logger.info("Stop request detected, exiting...") 73 | return [] 74 | 75 | except Exception as e: 76 | self.diagnostic_logger.info(f"No Overlay displayed, continuing...") 77 | overlay = None 78 | 79 | try: 80 | if self.stop_event.is_set(): 81 | self.diagnostic_logger.info("Stop request detected, exiting...") 82 | return [] 83 | if overlay: 84 | self.diagnostic_logger.info(f"Overlay displayed on search, attempting to close it...") 85 | close_div = overlay.find_element(By.CLASS_NAME, "modal__close") 86 | close_button = close_div.find_element(By.CSS_SELECTOR, "img[alt='Dismiss']") 87 | close_button.click() 88 | 89 | except Exception as e: 90 | self.diagnostic_logger.error(f"Failed to close overlay: {str(e)}") 91 | self.diagnostic_logger.info(f"Trying to continue") 92 | 93 | try: 94 | driver.find_element(By.TAG_NAME, "body").send_keys(Keys.PAGE_DOWN, Keys.PAGE_DOWN) 95 | table = driver.find_element(By.CLASS_NAME, "tableList") 96 | search_results = table.find_elements(By.CSS_SELECTOR, "tr") 97 | 98 | for result in search_results: 99 | if self.stop_event.is_set(): 100 | self.diagnostic_logger.info("Stop request detected, exiting...") 101 | return [] 102 | item_title_element = result.find_element(By.CSS_SELECTOR, "span[itemprop='name']") 103 | item_title = item_title_element.text.strip() 104 | 105 | author_tag = result.find_element(By.CSS_SELECTOR, "span[itemprop='author']") 106 | item_author = author_tag.find_element(By.CSS_SELECTOR, "span[itemprop='name']").text.strip() 107 | 108 | book_string = f"{item_author} - {item_title}" 109 | match_ratio = fuzz.ratio(book_string, query) 110 | if match_ratio > 90 or query in book_string: 111 | self.diagnostic_logger.error(f"Found: {item_title} by {item_author} as {match_ratio}% match for {query}") 112 | book_link_element = result.find_element(By.CSS_SELECTOR, "a.bookTitle") 113 | book_link = book_link_element.get_attribute("href") 114 | break 115 | else: 116 | self.diagnostic_logger.info(f"No Matching book for {query}") 117 | 118 | except Exception as e: 119 | self.diagnostic_logger.error(f"Error trying to get link: {str(e)}") 120 | 121 | try: 122 | if not book_link: 123 | raise Exception(f"Could not Find a link for book: {query}") 124 | if not all([urlparse(book_link).scheme, urlparse(book_link).netloc]): 125 | raise Exception(f"Invalid URL: {book_link}") 126 | 127 | driver.get(book_link) 128 | try: 129 | wait = WebDriverWait(driver, self.goodreads_wait_delay) 130 | self.diagnostic_logger.info(f"Waiting to see if Overlay is displayed...") 131 | overlay = wait.until(lambda driver: self.stop_event.is_set() or EC.visibility_of_element_located((By.CLASS_NAME, "Overlay__window"))(driver)) 132 | 133 | if self.stop_event.is_set(): 134 | self.diagnostic_logger.info("Stop request detected, exiting...") 135 | return [] 136 | except Exception as e: 137 | self.diagnostic_logger.info(f"No Overlay displayed, continuing...") 138 | overlay = None 139 | 140 | if overlay: 141 | try: 142 | self.diagnostic_logger.info(f"Overlay displayed on book link, attempting to close it...") 143 | overlay.click() 144 | close_button = overlay.find_element(By.CLASS_NAME, "Button__container") 145 | close_button.click() 146 | except Exception as e: 147 | self.diagnostic_logger.error(f"Failed to close overlay: {str(e)}") 148 | self.diagnostic_logger.info(f"Attempting to Continue") 149 | 150 | try: 151 | if self.stop_event.is_set(): 152 | self.diagnostic_logger.info("Stop request detected, exiting...") 153 | return [] 154 | element = driver.find_element(By.CLASS_NAME, "BookPage__relatedTopContent") 155 | driver.execute_script("arguments[0].scrollIntoView();", element) 156 | wait = WebDriverWait(driver, self.goodreads_wait_delay) 157 | self.diagnostic_logger.info(f"Waiting until Carousel is displayed...") 158 | carousel = wait.until(lambda driver: self.stop_event.is_set() or EC.visibility_of_element_located((By.CLASS_NAME, "Carousel"))(driver)) 159 | 160 | except: 161 | try: 162 | self.diagnostic_logger.info(f"Could not find Carousel on first attempt trying again...") 163 | wait = WebDriverWait(driver, self.goodreads_wait_delay) 164 | self.diagnostic_logger.info(f"Waiting until Carousel is displayed...") 165 | carousel = wait.until(lambda driver: self.stop_event.is_set() or EC.visibility_of_element_located((By.CLASS_NAME, "Carousel"))(driver)) 166 | 167 | except Exception as e: 168 | self.diagnostic_logger.error(f"Failed to get book info: {str(e)}") 169 | raise Exception("No Valid Carousel") 170 | 171 | if self.stop_event.is_set(): 172 | self.diagnostic_logger.info("Stop request detected, exiting...") 173 | return [] 174 | 175 | next_button = driver.find_element(By.CSS_SELECTOR, 'button[aria-label="Carousel, Next page"]') 176 | book_cards = carousel.find_elements(By.CLASS_NAME, "BookCard") 177 | 178 | total_cards = len(book_cards) 179 | for i in range(0, total_cards, 4): 180 | book_cards = carousel.find_elements(By.CLASS_NAME, "BookCard") 181 | for card in book_cards[i : i + 4]: 182 | try: 183 | if self.stop_event.is_set(): 184 | self.diagnostic_logger.info("Stop request detected, exiting...") 185 | return [] 186 | title = card.find_element(By.CSS_SELECTOR, '[data-testid="title"]').text 187 | author = card.find_element(By.CSS_SELECTOR, '[data-testid="author"]').text 188 | rating = card.find_element(By.CLASS_NAME, "AverageRating__ratingValue").text 189 | votes = card.find_element(By.CSS_SELECTOR, '[data-testid="ratingsCount"]').text.strip() 190 | image = card.find_element(By.CSS_SELECTOR, "img.ResponsiveImage") 191 | image_url = image.get_attribute("src") 192 | if "m" in votes: 193 | vote_count = int(float(votes.replace("m", "").replace(",", "")) * 1000000) 194 | elif "k" in votes: 195 | vote_count = int(float(votes.replace("k", "").replace(",", "")) * 1000) 196 | else: 197 | vote_count = int(0 if votes.replace(",", "") == "" else votes.replace(",", "")) 198 | ratings_value = 0.0 if rating == "" else float(rating) 199 | if ratings_value > self.minimum_rating and vote_count > self.minimum_votes: 200 | new_book_detail = { 201 | "Name": title, 202 | "Author": author, 203 | "Rating": f"Rating: {rating}", 204 | "Votes": f"Votes: {votes}", 205 | "Overview": "", 206 | "Image_Link": image_url, 207 | "Base_Book": query, 208 | "Status": "", 209 | "Page_Count": "", 210 | "Published_Date": "", 211 | } 212 | similar_books.append(new_book_detail) 213 | 214 | except Exception as e: 215 | self.diagnostic_logger.error(f"Failed to get book info: {str(e)}") 216 | 217 | if i + 4 < total_cards and next_button.is_enabled(): 218 | next_button.click() 219 | self.diagnostic_logger.info(f"Checking Next Batch...") 220 | self.stop_event.wait(1) 221 | 222 | except Exception as e: 223 | self.diagnostic_logger.error(f"Error extracting data: {str(e)}") 224 | 225 | except Exception as e: 226 | self.diagnostic_logger.error(f"Failed to get similar books: {str(e)}") 227 | 228 | finally: 229 | self.diagnostic_logger.info(f"Discovered {len(similar_books)} potential books") 230 | if driver: 231 | driver.quit() 232 | return similar_books 233 | -------------------------------------------------------------------------------- /src/eBookBuddy.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import logging 4 | import os 5 | import random 6 | import threading 7 | import concurrent.futures 8 | from flask import Flask, render_template, request 9 | from flask_socketio import SocketIO 10 | import requests 11 | from thefuzz import fuzz 12 | from unidecode import unidecode 13 | import _scrapers 14 | 15 | 16 | class DataHandler: 17 | def __init__(self): 18 | logging.basicConfig(level=logging.INFO, format="%(message)s") 19 | self.diagnostic_logger = logging.getLogger() 20 | 21 | app_name_text = os.path.basename(__file__).replace(".py", "") 22 | release_version = os.environ.get("RELEASE_VERSION", "unknown") 23 | self.diagnostic_logger.warning(f"{'*' * 50}\n") 24 | self.diagnostic_logger.warning(f"{app_name_text} Version: {release_version}\n") 25 | self.diagnostic_logger.warning(f"{'*' * 50}") 26 | 27 | self.search_in_progress_flag = False 28 | self.search_exhausted_flag = True 29 | self.clients_connected_counter = 0 30 | self.config_folder = "config" 31 | self.recommended_books = [] 32 | self.readarr_items = [] 33 | self.cleaned_readarr_items = [] 34 | self.stop_event = threading.Event() 35 | self.stop_event.set() 36 | if not os.path.exists(self.config_folder): 37 | os.makedirs(self.config_folder) 38 | self.load_environ_or_config_settings() 39 | self.goodreads_scraper = _scrapers.Goodreads_Scraper(self.diagnostic_logger, self.stop_event, self.minimum_rating, self.minimum_votes, self.goodreads_wait_delay) 40 | if self.auto_start: 41 | try: 42 | auto_start_thread = threading.Timer(self.auto_start_delay, self.automated_startup) 43 | auto_start_thread.daemon = True 44 | auto_start_thread.start() 45 | 46 | except Exception as e: 47 | self.diagnostic_logger.error(f"Auto Start Error: {str(e)}") 48 | 49 | def load_environ_or_config_settings(self): 50 | # Defaults 51 | default_settings = { 52 | "readarr_address": "http://192.168.1.2:8787", 53 | "readarr_api_key": "", 54 | "root_folder_path": "/data/media/books", 55 | "google_books_api_key": "", 56 | "readarr_api_timeout": 120.0, 57 | "quality_profile_id": 1, 58 | "metadata_profile_id": 1, 59 | "search_for_missing_book": False, 60 | "minimum_rating": 3.5, 61 | "minimum_votes": 500, 62 | "goodreads_wait_delay": 12.5, 63 | "readarr_wait_delay": 7.5, 64 | "thread_limit": 1, 65 | "auto_start": False, 66 | "auto_start_delay": 60, 67 | } 68 | 69 | # Load settings from environmental variables (which take precedence) over the configuration file. 70 | self.readarr_address = os.environ.get("readarr_address", "") 71 | self.readarr_api_key = os.environ.get("readarr_api_key", "") 72 | self.root_folder_path = os.environ.get("root_folder_path", "") 73 | self.google_books_api_key = os.environ.get("google_books_api_key", "") 74 | readarr_api_timeout = os.environ.get("readarr_api_timeout", "") 75 | self.readarr_api_timeout = float(readarr_api_timeout) if readarr_api_timeout else "" 76 | quality_profile_id = os.environ.get("quality_profile_id", "") 77 | self.quality_profile_id = int(quality_profile_id) if quality_profile_id else "" 78 | metadata_profile_id = os.environ.get("metadata_profile_id", "") 79 | self.metadata_profile_id = int(metadata_profile_id) if metadata_profile_id else "" 80 | search_for_missing_book = os.environ.get("search_for_missing_book", "") 81 | self.search_for_missing_book = search_for_missing_book.lower() == "true" if search_for_missing_book != "" else "" 82 | minimum_rating = os.environ.get("minimum_rating", "") 83 | self.minimum_rating = float(minimum_rating) if minimum_rating else "" 84 | minimum_votes = os.environ.get("minimum_votes", "") 85 | self.minimum_votes = int(minimum_votes) if minimum_votes else "" 86 | goodreads_wait_delay = os.environ.get("goodreads_wait_delay", "") 87 | self.goodreads_wait_delay = float(goodreads_wait_delay) if goodreads_wait_delay else "" 88 | readarr_wait_delay = os.environ.get("readarr_wait_delay", "") 89 | self.readarr_wait_delay = float(readarr_wait_delay) if readarr_wait_delay else "" 90 | thread_limit = os.environ.get("thread_limit", "") 91 | self.thread_limit = int(thread_limit) if thread_limit else "" 92 | auto_start = os.environ.get("auto_start", "") 93 | self.auto_start = auto_start.lower() == "true" if auto_start != "" else "" 94 | auto_start_delay = os.environ.get("auto_start_delay", "") 95 | self.auto_start_delay = float(auto_start_delay) if auto_start_delay else "" 96 | 97 | # Load variables from the configuration file if not set by environmental variables. 98 | try: 99 | self.settings_config_file = os.path.join(self.config_folder, "settings_config.json") 100 | if os.path.exists(self.settings_config_file): 101 | self.diagnostic_logger.info(f"Loading Config via file") 102 | with open(self.settings_config_file, "r") as json_file: 103 | ret = json.load(json_file) 104 | for key in ret: 105 | if getattr(self, key) == "": 106 | setattr(self, key, ret[key]) 107 | except Exception as e: 108 | self.diagnostic_logger.error(f"Error Loading Config: {str(e)}") 109 | 110 | # Load defaults if not set by an environmental variable or configuration file. 111 | for key, value in default_settings.items(): 112 | if getattr(self, key) == "": 113 | setattr(self, key, value) 114 | 115 | # Save config. 116 | self.save_config_to_file() 117 | 118 | def automated_startup(self): 119 | self.request_books_from_readarr(checked=True) 120 | items = [x["name"] for x in self.readarr_items] 121 | self.start(items) 122 | 123 | def connection(self): 124 | if self.recommended_books: 125 | if self.clients_connected_counter == 0: 126 | if len(self.recommended_books) > 25: 127 | self.recommended_books = random.sample(self.recommended_books, 25) 128 | else: 129 | self.diagnostic_logger.info(f"Shuffling Books") 130 | random.shuffle(self.recommended_books) 131 | socketio.emit("more_books_loaded", self.recommended_books) 132 | 133 | self.clients_connected_counter += 1 134 | 135 | def disconnection(self): 136 | self.clients_connected_counter = max(0, self.clients_connected_counter - 1) 137 | 138 | def start(self, data): 139 | try: 140 | socketio.emit("clear") 141 | self.search_exhausted_flag = False 142 | self.books_to_use_in_search = [] 143 | self.recommended_books = [] 144 | 145 | for item in self.readarr_items: 146 | item_name = item["name"] 147 | if item_name in data: 148 | item["checked"] = True 149 | self.books_to_use_in_search.append(item_name) 150 | else: 151 | item["checked"] = False 152 | 153 | if self.books_to_use_in_search: 154 | self.stop_event.clear() 155 | else: 156 | self.stop_event.set() 157 | raise Exception("No Readarr Books Selected") 158 | 159 | except Exception as e: 160 | self.diagnostic_logger.error(f"Startup Error: {str(e)}") 161 | self.stop_event.set() 162 | ret = {"Status": "Error", "Code": str(e), "Data": self.readarr_items, "Running": not self.stop_event.is_set()} 163 | socketio.emit("readarr_sidebar_update", ret) 164 | 165 | else: 166 | thread = threading.Thread(target=data_handler.find_similar_books, name="Start_Finding_Thread") 167 | thread.daemon = True 168 | thread.start() 169 | 170 | def request_books_from_readarr(self, checked=False): 171 | try: 172 | self.diagnostic_logger.info(f"Getting Books from Readarr") 173 | self.readarr_books_in_library = [] 174 | endpoint_authors = f"{self.readarr_address}/api/v1/author" 175 | headers = {"Accept": "application/json", "X-Api-Key": self.readarr_api_key} 176 | response_authors = requests.get(endpoint_authors, headers=headers, timeout=self.readarr_api_timeout) 177 | if response_authors.status_code != 200: 178 | raise Exception(f"Failed to fetch authors from Readarr: {response_authors.text}") 179 | 180 | authors = response_authors.json() 181 | 182 | for author in authors: 183 | author_id = author["id"] 184 | author_name = author["authorName"] 185 | 186 | # Fetch books by author from Readarr 187 | endpoint_books = f"{self.readarr_address}/api/v1/book?authorId={author_id}" 188 | response_books = requests.get(endpoint_books, headers=headers, timeout=self.readarr_api_timeout) 189 | if response_books.status_code != 200: 190 | raise Exception(f"Failed to fetch books by author '{author_name}' from Readarr: {response_books.text}") 191 | 192 | books = response_books.json() 193 | 194 | # Filter books with files 195 | for book in books: 196 | if book.get("statistics", {}).get("bookFileCount", 0) > 0: 197 | self.readarr_books_in_library.append({"author": author_name, "title": book.get("title")}) 198 | book_author_and_title = f'{author_name} - {book.get("title")}' 199 | cleaned_book = unidecode(book_author_and_title).lower() 200 | self.cleaned_readarr_items.append(cleaned_book) 201 | 202 | self.readarr_items = [{"name": f"{book['author']} - {book['title']}", "checked": checked} for book in self.readarr_books_in_library] 203 | 204 | status = "Success" 205 | self.readarr_items = sorted(self.readarr_items, key=lambda x: x["name"]) 206 | 207 | ret = {"Status": status, "Code": response_books.status_code if status == "Error" else None, "Data": self.readarr_items, "Running": not self.stop_event.is_set()} 208 | 209 | except Exception as e: 210 | self.diagnostic_logger.error(f"Error Getting Book list from Readarr: {str(e)}") 211 | ret = {"Status": "Error", "Code": 500, "Data": str(e), "Running": not self.stop_event.is_set()} 212 | 213 | finally: 214 | socketio.emit("readarr_sidebar_update", ret) 215 | 216 | def find_similar_books(self): 217 | if self.stop_event.is_set() or self.search_in_progress_flag: 218 | if self.search_in_progress_flag: 219 | self.diagnostic_logger.info(f"Searching already in progress") 220 | socketio.emit("new_toast_msg", {"title": "Search in progress", "message": f"It's just slow...."}) 221 | return 222 | elif not self.search_exhausted_flag: 223 | try: 224 | self.diagnostic_logger.info(f"Searching for new books") 225 | socketio.emit("new_toast_msg", {"title": "Searching for new books", "message": f"Please be patient...."}) 226 | 227 | self.search_exhausted_flag = True 228 | self.search_in_progress_flag = True 229 | minimum_count = self.thread_limit if self.thread_limit > 1 and self.thread_limit < 16 else 6 230 | sample_count = min(minimum_count, len(self.books_to_use_in_search)) 231 | random_books = random.sample(self.books_to_use_in_search, sample_count) 232 | with concurrent.futures.ThreadPoolExecutor(max_workers=self.thread_limit) as executor: 233 | futures = [executor.submit(self.goodreads_scraper.goodreads_recommendations, book_name) for book_name in random_books] 234 | for future in concurrent.futures.as_completed(futures): 235 | related_books = future.result() 236 | new_book_count = 0 237 | if self.stop_event.is_set(): 238 | for f in futures: 239 | f.cancel() 240 | break 241 | for book_item in related_books: 242 | if self.stop_event.is_set(): 243 | for f in futures: 244 | f.cancel() 245 | break 246 | book_author_and_title = f"{book_item['Author']} - {book_item['Name']}" 247 | cleaned_book = unidecode(book_author_and_title).lower() 248 | if cleaned_book not in self.cleaned_readarr_items: 249 | for item in self.recommended_books: 250 | match_ratio = fuzz.ratio(book_author_and_title, f"{item['Author']} - {item['Name']}") 251 | if match_ratio > 95: 252 | break 253 | else: 254 | self.recommended_books.append(book_item) 255 | socketio.emit("more_books_loaded", [book_item]) 256 | self.search_exhausted_flag = False 257 | new_book_count += 1 258 | 259 | if new_book_count > 0: 260 | self.diagnostic_logger.info(f"Found {new_book_count} new suggestions that are not already in Readarr") 261 | 262 | if self.search_exhausted_flag and not self.stop_event.is_set(): 263 | self.diagnostic_logger.info("Search Exhausted - Try selecting more books from existing Readarr library") 264 | socketio.emit("new_toast_msg", {"title": "Search Exhausted", "message": "Try selecting more books from existing Readarr library"}) 265 | 266 | except Exception as e: 267 | self.diagnostic_logger.error(f"Failure Scraping Goodreads: {str(e)}") 268 | socketio.emit("new_toast_msg", {"title": "Search Failed", "message": "Check Logs...."}) 269 | 270 | finally: 271 | self.search_in_progress_flag = False 272 | self.diagnostic_logger.info(f"Finished Searching") 273 | 274 | elif self.search_exhausted_flag: 275 | try: 276 | self.search_in_progress_flag = True 277 | self.diagnostic_logger.info("Search Exhausted - Try selecting more books from existing Readarr library") 278 | socketio.emit("new_toast_msg", {"title": "Search Exhausted", "message": "Try selecting more books from existing Readarr library"}) 279 | self.stop_event.wait(2) 280 | 281 | except Exception as e: 282 | self.diagnostic_logger.error(f"Search Exhausted Error: {str(e)}") 283 | 284 | finally: 285 | self.search_in_progress_flag = False 286 | self.diagnostic_logger.info(f"Finished Searching") 287 | 288 | def add_to_readarr(self, book_data): 289 | try: 290 | book_name = book_data["Name"] 291 | author_name = book_data["Author"] 292 | book_author_and_title = f"{author_name} - {book_name}" 293 | author_data = self._readarr_author_lookup(author_name) 294 | status = self._readarr_book_lookup(author_data, book_name) 295 | 296 | if status == "Success": 297 | self.readarr_items.append({"name": book_author_and_title, "checked": False}) 298 | cleaned_book = unidecode(book_author_and_title).lower() 299 | self.cleaned_readarr_items.append(cleaned_book) 300 | self.diagnostic_logger.info(f"Book: {book_author_and_title} successfully added to Readarr.") 301 | status = "Added" 302 | else: 303 | status = "Failed to Add" 304 | self.diagnostic_logger.info(f"Failed to add to Readarr as no matching book for: {book_author_and_title}.") 305 | socketio.emit("new_toast_msg", {"title": "Failed to add Book", "message": f"No Matching Book for: {book_author_and_title}"}) 306 | 307 | for item in self.recommended_books: 308 | if item["Name"] == book_name and item["Author"] == author_name: 309 | item["Status"] = status 310 | socketio.emit("refresh_book", item) 311 | break 312 | else: 313 | self.diagnostic_logger.info(f"{item['Author']} - {item['Name']} not found in Similar Book List") 314 | 315 | except Exception as e: 316 | self.diagnostic_logger.error(f"Error Adding Book to Readarr: {str(e)}") 317 | 318 | def _readarr_book_lookup(self, author_data, book_name): 319 | try: 320 | time.sleep(self.readarr_wait_delay) 321 | headers = {"Content-Type": "application/json", "X-Api-Key": self.readarr_api_key} 322 | readarr_book_url = f"{self.readarr_address}/api/v1/book" 323 | readarr_book_monitor_url = f"{self.readarr_address}/api/v1/book/monitor" 324 | 325 | author_books_response = requests.get(f"{readarr_book_url}?authorId={author_data.get('id')}", headers=headers) 326 | if author_books_response.status_code != 200: 327 | raise Exception(f"Failed to get books from author: {author_books_response.content.decode('utf-8')}") 328 | 329 | # Find a match for the requested book 330 | author_books_data = author_books_response.json() 331 | for book_item in author_books_data: 332 | match_ratio = fuzz.ratio(book_item["title"], book_name) 333 | if match_ratio > 90: 334 | book_data = book_item 335 | break 336 | else: 337 | raise Exception(f"Book: {book_name} not found in Readarr under author: {author_data.get('authorName')}.") 338 | 339 | payload = {"bookIds": [book_data.get("id")], "monitored": True} 340 | response = requests.put(readarr_book_monitor_url, headers=headers, json=payload) 341 | if response.status_code == 202: 342 | self.diagnostic_logger.info(f"Book: {book_name} monitoring status updated successfully.") 343 | return "Success" 344 | else: 345 | self.diagnostic_logger.error(f"Failed to update monitoring status for Book: {book_name}. Error: {response.content.decode('utf-8')}") 346 | return "Failure" 347 | 348 | except Exception as e: 349 | self.diagnostic_logger.error(f"Book not added Readarr: {str(e)}") 350 | return "Failure" 351 | 352 | def _readarr_author_lookup(self, author_name): 353 | readarr_author_lookup_url = f"{self.readarr_address}/api/v1/author/lookup" 354 | readarr_author_url = f"{self.readarr_address}/api/v1/author" 355 | params = {"term": author_name} 356 | headers = {"Content-Type": "application/json", "X-Api-Key": self.readarr_api_key} 357 | 358 | # Check if the author exists in Readarr 359 | author_response = requests.get(readarr_author_url, headers=headers) 360 | authors = author_response.json() 361 | 362 | if author_response.status_code == 200: 363 | for author in authors: 364 | match_ratio = fuzz.ratio(author["authorName"], author_name) 365 | if match_ratio > 95: 366 | author_data = author 367 | break 368 | else: 369 | author_data = None 370 | 371 | if not author_data: 372 | # Search for Author 373 | author_lookup = requests.get(readarr_author_lookup_url, params=params, headers=headers) 374 | if author_lookup.status_code != 200: 375 | raise Exception(f"Readarr Lookup failed: {author_lookup.content.decode('utf-8')}") 376 | 377 | search_results = author_lookup.json() 378 | for result in search_results: 379 | match_ratio = fuzz.ratio(result["authorName"], author_name) 380 | if match_ratio > 95: 381 | author_data = result 382 | break 383 | else: 384 | raise Exception(f"No match for: {author_name}") 385 | 386 | # Add Author as not in Readdar 387 | author_payload = { 388 | "authorName": author_data.get("authorName"), 389 | "metadataProfileId": self.metadata_profile_id, 390 | "qualityProfileId": self.quality_profile_id, 391 | "rootFolderPath": self.root_folder_path, 392 | "path": os.path.join(self.root_folder_path, author_data.get("authorName")), 393 | "foreignAuthorId": author_data.get("foreignAuthorId"), 394 | "monitored": True, 395 | "monitorNewItems": "none", 396 | "addOptions": { 397 | "monitor": "future", 398 | "searchForMissingBooks": self.search_for_missing_book, 399 | "monitored": True, 400 | }, 401 | } 402 | author_response = requests.post(readarr_author_url, headers=headers, json=author_payload) 403 | author_data = author_response.json() 404 | if author_response.status_code != 201: 405 | raise Exception(f"Failed to add author: {author_response.content.decode('utf-8')}") 406 | 407 | return author_data 408 | 409 | def load_settings(self): 410 | try: 411 | data = { 412 | "readarr_address": self.readarr_address, 413 | "readarr_api_key": self.readarr_api_key, 414 | "root_folder_path": self.root_folder_path, 415 | "google_books_api_key": self.google_books_api_key, 416 | } 417 | socketio.emit("settings_loaded", data) 418 | except Exception as e: 419 | self.diagnostic_logger.error(f"Failed to load settings: {str(e)}") 420 | 421 | def update_settings(self, data): 422 | try: 423 | self.readarr_address = data["readarr_address"] 424 | self.readarr_api_key = data["readarr_api_key"] 425 | self.root_folder_path = data["root_folder_path"] 426 | self.google_books_api_key = data["google_books_api_key"] 427 | except Exception as e: 428 | self.diagnostic_logger.error(f"Failed to update settings: {str(e)}") 429 | 430 | def save_config_to_file(self): 431 | try: 432 | with open(self.settings_config_file, "w") as json_file: 433 | json.dump( 434 | { 435 | "readarr_address": self.readarr_address, 436 | "readarr_api_key": self.readarr_api_key, 437 | "root_folder_path": self.root_folder_path, 438 | "google_books_api_key": self.google_books_api_key, 439 | "readarr_api_timeout": float(self.readarr_api_timeout), 440 | "quality_profile_id": self.quality_profile_id, 441 | "metadata_profile_id": self.metadata_profile_id, 442 | "search_for_missing_book": self.search_for_missing_book, 443 | "minimum_rating": self.minimum_rating, 444 | "minimum_votes": self.minimum_votes, 445 | "goodreads_wait_delay": self.goodreads_wait_delay, 446 | "readarr_wait_delay": self.readarr_wait_delay, 447 | "thread_limit": self.thread_limit, 448 | "auto_start": self.auto_start, 449 | "auto_start_delay": self.auto_start_delay, 450 | }, 451 | json_file, 452 | indent=4, 453 | ) 454 | 455 | except Exception as e: 456 | self.diagnostic_logger.error(f"Error Saving Config: {str(e)}") 457 | 458 | def query_google_books(self, book): 459 | try: 460 | book_info = {} 461 | url = "https://www.googleapis.com/books/v1/volumes" 462 | query = f'{book["Author"]} - {book["Name"]}' 463 | params = {"q": query.replace(" ", "+"), "key": self.google_books_api_key} 464 | response = requests.get(url, params=params) 465 | data = response.json() 466 | if "items" in data: 467 | for book_item in data["items"]: 468 | book_info = book_item["volumeInfo"] 469 | title = book_info.get("title", "Title not available") 470 | author = book_info.get("authors", ["No Author Found"])[0] 471 | 472 | book_string = f"{author} - {title}" 473 | match_ratio = fuzz.ratio(book_string, query) 474 | if match_ratio > 90 or query in book_string: 475 | break 476 | 477 | except Exception as e: 478 | self.diagnostic_logger.error(f"Error retrieving book Data from Google Books API: {str(e)}") 479 | 480 | finally: 481 | return book_info 482 | 483 | def overview(self, book): 484 | try: 485 | book_info = self.query_google_books(book) 486 | book["Overview"] = book_info.get("description", "") 487 | book["Published_Date"] = book_info.get("publishedDate") 488 | book["Page_Count"] = book_info.get("pageCount") 489 | 490 | except Exception as e: 491 | self.diagnostic_logger.error(f"Error retrieving book overview: {str(e)}") 492 | 493 | finally: 494 | socketio.emit("overview", book, room=request.sid) 495 | 496 | 497 | app = Flask(__name__) 498 | app.secret_key = "secret_key" 499 | socketio = SocketIO(app) 500 | data_handler = DataHandler() 501 | 502 | 503 | @app.route("/") 504 | def home(): 505 | return render_template("base.html") 506 | 507 | 508 | @socketio.on("side_bar_opened") 509 | def side_bar_opened(): 510 | if data_handler.readarr_items: 511 | ret = {"Status": "Success", "Data": data_handler.readarr_items, "Running": not data_handler.stop_event.is_set()} 512 | socketio.emit("readarr_sidebar_update", ret) 513 | 514 | 515 | @socketio.on("get_readarr_books") 516 | def get_readarr_books(): 517 | thread = threading.Thread(target=data_handler.request_books_from_readarr, name="Readarr_Thread") 518 | thread.daemon = True 519 | thread.start() 520 | 521 | 522 | @socketio.on("adder") 523 | def add_to_readarr(book): 524 | thread = threading.Thread(target=data_handler.add_to_readarr, args=(book,), name="Add_Book_Thread") 525 | thread.daemon = True 526 | thread.start() 527 | 528 | 529 | @socketio.on("connect") 530 | def connection(): 531 | thread = threading.Thread(target=data_handler.connection, name="Connect") 532 | thread.daemon = True 533 | thread.start() 534 | 535 | 536 | @socketio.on("disconnect") 537 | def disconnection(): 538 | data_handler.disconnection() 539 | 540 | 541 | @socketio.on("load_settings") 542 | def load_settings(): 543 | data_handler.load_settings() 544 | 545 | 546 | @socketio.on("update_settings") 547 | def update_settings(data): 548 | data_handler.update_settings(data) 549 | data_handler.save_config_to_file() 550 | 551 | 552 | @socketio.on("start_req") 553 | def starter(data): 554 | data_handler.start(data) 555 | 556 | 557 | @socketio.on("stop_req") 558 | def stopper(): 559 | data_handler.stop_event.set() 560 | 561 | 562 | @socketio.on("load_more_books") 563 | def load_more_books(): 564 | thread = threading.Thread(target=data_handler.find_similar_books, name="Find_Similar") 565 | thread.daemon = True 566 | thread.start() 567 | 568 | 569 | @socketio.on("overview_req") 570 | def overview(book): 571 | data_handler.overview(book) 572 | 573 | 574 | if __name__ == "__main__": 575 | socketio.run(app, host="0.0.0.0", port=5000) 576 | -------------------------------------------------------------------------------- /src/static/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheWicklowWolf/eBookBuddy/c029bfa89f6f4e2dc8e9099b327d88422e368053/src/static/dark.png -------------------------------------------------------------------------------- /src/static/ebookbuddy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheWicklowWolf/eBookBuddy/c029bfa89f6f4e2dc8e9099b327d88422e368053/src/static/ebookbuddy.png -------------------------------------------------------------------------------- /src/static/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheWicklowWolf/eBookBuddy/c029bfa89f6f4e2dc8e9099b327d88422e368053/src/static/light.png -------------------------------------------------------------------------------- /src/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheWicklowWolf/eBookBuddy/c029bfa89f6f4e2dc8e9099b327d88422e368053/src/static/logo.png -------------------------------------------------------------------------------- /src/static/readarr.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/static/script.js: -------------------------------------------------------------------------------- 1 | var return_to_top = document.getElementById("return-to-top"); 2 | var readarr_get_books_button = document.getElementById('readarr-get-books-button'); 3 | var start_stop_button = document.getElementById('start-stop-button'); 4 | var readarr_status = document.getElementById('readarr-status'); 5 | var readarr_spinner = document.getElementById('readarr-spinner'); 6 | var readarr_item_list = document.getElementById("readarr-item-list"); 7 | var readarr_select_all_checkbox = document.getElementById("readarr-select-all"); 8 | var readarr_select_all_container = document.getElementById("readarr-select-all-container"); 9 | var config_modal = document.getElementById('config-modal'); 10 | var readarr_sidebar = document.getElementById('readarr-sidebar'); 11 | var save_message = document.getElementById("save-message"); 12 | var save_changes_button = document.getElementById("save-changes-button"); 13 | const readarr_address = document.getElementById("readarr-address"); 14 | const readarr_api_key = document.getElementById("readarr-api-key"); 15 | const root_folder_path = document.getElementById("root-folder-path"); 16 | const google_books_api_key = document.getElementById("googlebooks-api-key"); 17 | var readarr_items = []; 18 | var socket = io(); 19 | 20 | function check_if_all_selected() { 21 | var checkboxes = document.querySelectorAll('input[name="readarr-item"]'); 22 | var all_checked = true; 23 | for (var i = 0; i < checkboxes.length; i++) { 24 | if (!checkboxes[i].checked) { 25 | all_checked = false; 26 | break; 27 | } 28 | } 29 | readarr_select_all_checkbox.checked = all_checked; 30 | } 31 | 32 | function load_readarr_data(response) { 33 | var every_check_box = document.querySelectorAll('input[name="readarr-item"]'); 34 | if (response.Running) { 35 | start_stop_button.classList.remove('btn-success'); 36 | start_stop_button.classList.add('btn-warning'); 37 | start_stop_button.textContent = "Stop"; 38 | every_check_box.forEach(item => { 39 | item.disabled = true; 40 | }); 41 | readarr_select_all_checkbox.disabled = true; 42 | readarr_get_books_button.disabled = true; 43 | } else { 44 | start_stop_button.classList.add('btn-success'); 45 | start_stop_button.classList.remove('btn-warning'); 46 | start_stop_button.textContent = "Start"; 47 | every_check_box.forEach(item => { 48 | item.disabled = false; 49 | }); 50 | readarr_select_all_checkbox.disabled = false; 51 | readarr_get_books_button.disabled = false; 52 | } 53 | check_if_all_selected(); 54 | } 55 | 56 | function append_books(books) { 57 | var book_row = document.getElementById('book-row'); 58 | var template = document.getElementById('book-template'); 59 | books.forEach(function (book) { 60 | var clone = document.importNode(template.content, true); 61 | var book_col = clone.querySelector('#book-column'); 62 | 63 | book_col.querySelector('.card-title').textContent = `${book.Name}`; 64 | if (book.Image_Link) { 65 | book_col.querySelector('.card-img-top').src = book.Image_Link; 66 | book_col.querySelector('.card-img-top').alt = book.Name; 67 | } else { 68 | book_col.querySelector('.book-img-container').removeChild(book_col.querySelector('.card-img-top')); 69 | } 70 | book_col.querySelector('.add-to-readarr-btn').addEventListener('click', function () { 71 | var add_button = this; 72 | add_button.disabled = true; 73 | add_button.textContent = "Adding..."; 74 | add_to_readarr(book); 75 | }); 76 | book_col.querySelector('.get-overview-btn').addEventListener('click', function () { 77 | var overview_button = this; 78 | overview_button.disabled = true; 79 | overview_req(book, overview_button); 80 | }); 81 | book_col.querySelector('.votes').textContent = book.Votes; 82 | book_col.querySelector('.rating').textContent = book.Rating; 83 | 84 | var add_button = book_col.querySelector('.add-to-readarr-btn'); 85 | if (book.Status === "Added" || book.Status === "Already in Readarr") { 86 | book_col.querySelector('.card-body').classList.add('status-green'); 87 | add_button.classList.remove('btn-primary'); 88 | add_button.classList.add('btn-secondary'); 89 | add_button.disabled = true; 90 | add_button.textContent = book.Status; 91 | } else if (book.Status === "Failed to Add" || book.Status === "Invalid Path" || book.Status === "Invalid Book ID") { 92 | book_col.querySelector('.card-body').classList.add('status-red'); 93 | add_button.classList.remove('btn-primary'); 94 | add_button.classList.add('btn-danger'); 95 | add_button.disabled = true; 96 | add_button.textContent = book.Status; 97 | } else { 98 | book_col.querySelector('.card-body').classList.add('status-blue'); 99 | } 100 | book_row.appendChild(clone); 101 | }); 102 | } 103 | 104 | function add_to_readarr(book) { 105 | if (socket.connected) { 106 | socket.emit("adder", book); 107 | } 108 | else { 109 | book_toast("Connection Lost", "Please reload to continue."); 110 | } 111 | } 112 | 113 | function book_toast(header, message) { 114 | var toast_container = document.querySelector('.toast-container'); 115 | var toast_template = document.getElementById('toast-template').cloneNode(true); 116 | toast_template.classList.remove('d-none'); 117 | 118 | toast_template.querySelector('.toast-header strong').textContent = header; 119 | toast_template.querySelector('.toast-body').textContent = message; 120 | toast_template.querySelector('.text-muted').textContent = new Date().toLocaleString(); 121 | 122 | toast_container.appendChild(toast_template); 123 | var toast = new bootstrap.Toast(toast_template); 124 | toast.show(); 125 | toast_template.addEventListener('hidden.bs.toast', function () { 126 | toast_template.remove(); 127 | }); 128 | } 129 | 130 | return_to_top.addEventListener("click", function () { 131 | window.scrollTo({ top: 0, behavior: "smooth" }); 132 | }); 133 | 134 | readarr_select_all_checkbox.addEventListener("change", function () { 135 | var is_checked = this.checked; 136 | var checkboxes = document.querySelectorAll('input[name="readarr-item"]'); 137 | checkboxes.forEach(function (checkbox) { 138 | checkbox.checked = is_checked; 139 | }); 140 | }); 141 | 142 | readarr_get_books_button.addEventListener('click', function () { 143 | readarr_get_books_button.disabled = true; 144 | readarr_spinner.classList.remove('d-none'); 145 | readarr_status.textContent = "Accessing Readarr API"; 146 | readarr_item_list.innerHTML = ''; 147 | socket.emit("get_readarr_books"); 148 | }); 149 | 150 | start_stop_button.addEventListener('click', function () { 151 | var running_state = start_stop_button.textContent.trim() === "Start" ? true : false; 152 | if (running_state) { 153 | start_stop_button.classList.remove('btn-success'); 154 | start_stop_button.classList.add('btn-warning'); 155 | start_stop_button.textContent = "Stop"; 156 | var checked_items = Array.from(document.querySelectorAll('input[name="readarr-item"]:checked')) 157 | .map(item => item.value); 158 | document.querySelectorAll('input[name="readarr-item"]').forEach(item => { 159 | item.disabled = true; 160 | }); 161 | readarr_get_books_button.disabled = true; 162 | readarr_select_all_checkbox.disabled = true; 163 | socket.emit("start_req", checked_items); 164 | } 165 | else { 166 | start_stop_button.classList.add('btn-success'); 167 | start_stop_button.classList.remove('btn-warning'); 168 | start_stop_button.textContent = "Start"; 169 | document.querySelectorAll('input[name="readarr-item"]').forEach(item => { 170 | item.disabled = false; 171 | }); 172 | readarr_get_books_button.disabled = false; 173 | readarr_select_all_checkbox.disabled = false; 174 | socket.emit("stop_req"); 175 | } 176 | }); 177 | 178 | save_changes_button.addEventListener("click", () => { 179 | socket.emit("update_settings", { 180 | "readarr_address": readarr_address.value, 181 | "readarr_api_key": readarr_api_key.value, 182 | "root_folder_path": root_folder_path.value, 183 | "google_books_api_key": google_books_api_key.value, 184 | }); 185 | save_message.style.display = "block"; 186 | setTimeout(function () { 187 | save_message.style.display = "none"; 188 | }, 1000); 189 | }); 190 | 191 | config_modal.addEventListener('show.bs.modal', function (event) { 192 | socket.emit("load_settings"); 193 | 194 | function handle_settings_loaded(settings) { 195 | readarr_address.value = settings.readarr_address; 196 | readarr_api_key.value = settings.readarr_api_key; 197 | root_folder_path.value = settings.root_folder_path; 198 | google_books_api_key.value = settings.google_books_api_key; 199 | socket.off("settings_loaded", handle_settings_loaded); 200 | } 201 | socket.on("settings_loaded", handle_settings_loaded); 202 | }); 203 | 204 | readarr_sidebar.addEventListener('show.bs.offcanvas', function (event) { 205 | socket.emit("side_bar_opened"); 206 | }); 207 | 208 | window.addEventListener('scroll', function () { 209 | if (window.innerHeight + window.scrollY >= document.body.offsetHeight) { 210 | load_more_books_req(); 211 | } 212 | }); 213 | 214 | window.addEventListener('touchmove', function () { 215 | if (window.innerHeight + window.scrollY >= document.body.offsetHeight) { 216 | load_more_books_req(); 217 | } 218 | }); 219 | 220 | window.addEventListener('touchend', () => { 221 | const { scrollHeight, scrollTop, clientHeight } = document.documentElement; 222 | if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) { 223 | load_more_books_req(); 224 | } 225 | }); 226 | 227 | socket.on("readarr_sidebar_update", (response) => { 228 | if (response.Status == "Success") { 229 | readarr_status.textContent = "Readarr List Retrieved"; 230 | readarr_items = response.Data; 231 | readarr_item_list.innerHTML = ''; 232 | readarr_select_all_container.classList.remove('d-none'); 233 | 234 | for (var i = 0; i < readarr_items.length; i++) { 235 | var item = readarr_items[i]; 236 | 237 | var div = document.createElement("div"); 238 | div.className = "form-check"; 239 | 240 | var input = document.createElement("input"); 241 | input.type = "checkbox"; 242 | input.className = "form-check-input"; 243 | input.id = "readarr-" + i; 244 | input.name = "readarr-item"; 245 | input.value = item.name; 246 | 247 | if (item.checked) { 248 | input.checked = true; 249 | } 250 | 251 | var label = document.createElement("label"); 252 | label.className = "form-check-label"; 253 | label.htmlFor = "readarr-" + i; 254 | label.textContent = item.name; 255 | 256 | input.addEventListener("change", function () { 257 | check_if_all_selected(); 258 | }); 259 | 260 | div.appendChild(input); 261 | div.appendChild(label); 262 | 263 | readarr_item_list.appendChild(div); 264 | } 265 | } 266 | else { 267 | readarr_status.textContent = response.Code; 268 | } 269 | readarr_get_books_button.disabled = false; 270 | readarr_spinner.classList.add('d-none'); 271 | load_readarr_data(response); 272 | }); 273 | 274 | socket.on("refresh_book", (book) => { 275 | var book_cards = document.querySelectorAll('#book-column'); 276 | book_cards.forEach(function (card) { 277 | var card_body = card.querySelector('.card-body'); 278 | var card_book_name = card_body.querySelector('.card-title').textContent.trim(); 279 | card_book_name = card_book_name.replace(/\s*\(\d{4}\)$/, ""); 280 | if (card_book_name === book.Name) { 281 | card_body.classList.remove('status-green', 'status-red', 'status-blue'); 282 | 283 | var add_button = card_body.querySelector('.add-to-readarr-btn'); 284 | 285 | if (book.Status === "Added" || book.Status === "Already in Readarr") { 286 | card_body.classList.add('status-green'); 287 | add_button.classList.remove('btn-primary'); 288 | add_button.classList.add('btn-secondary'); 289 | add_button.disabled = true; 290 | add_button.textContent = book.Status; 291 | } else if (book.Status === "Failed to Add" || book.Status === "Invalid Path") { 292 | card_body.classList.add('status-red'); 293 | add_button.classList.remove('btn-primary'); 294 | add_button.classList.add('btn-danger'); 295 | add_button.disabled = true; 296 | add_button.textContent = book.Status; 297 | } else { 298 | card_body.classList.add('status-blue'); 299 | add_button.disabled = false; 300 | } 301 | return; 302 | } 303 | }); 304 | }); 305 | 306 | socket.on('more_books_loaded', function (data) { 307 | append_books(data); 308 | }); 309 | 310 | socket.on('clear', function () { 311 | clear_all(); 312 | }); 313 | 314 | socket.on("new_toast_msg", function (data) { 315 | book_toast(data.title, data.message); 316 | }); 317 | 318 | socket.on("disconnect", function () { 319 | book_toast("Connection Lost", "Please refresh to continue."); 320 | clear_all(); 321 | }); 322 | 323 | function clear_all() { 324 | var book_row = document.getElementById('book-row'); 325 | var book_cards = book_row.querySelectorAll('#book-column'); 326 | book_cards.forEach(function (card) { 327 | card.remove(); 328 | }); 329 | } 330 | 331 | let overview_request_flag = false; 332 | function overview_req(book, overview_button) { 333 | if (!overview_request_flag) { 334 | overview_request_flag = true; 335 | socket.emit("overview_req", book); 336 | setTimeout(() => { 337 | overview_request_flag = false; 338 | overview_button.disabled = false; 339 | }, 2000); 340 | } 341 | } 342 | 343 | let load_more_request_flag = false; 344 | function load_more_books_req() { 345 | if (!load_more_request_flag) { 346 | load_more_request_flag = true; 347 | socket.emit("load_more_books"); 348 | setTimeout(() => { 349 | load_more_request_flag = false; 350 | }, 1000); 351 | } 352 | } 353 | 354 | function book_overview_modal(book) { 355 | const scrollbar_width = window.innerWidth - document.documentElement.clientWidth; 356 | document.body.style.overflow = 'hidden'; 357 | document.body.style.paddingRight = `${scrollbar_width}px`; 358 | 359 | var modal_title = document.getElementById('overview-modal-title'); 360 | var modal_body = document.getElementById('modal-body'); 361 | 362 | modal_title.textContent = `${book.Author} - ${book.Name}`; 363 | modal_body.innerHTML = `${book.Overview}

Published Date: ${book.Published_Date}
Page Count: ${book.Page_Count}

Recommendation from: ${book.Base_Book}`; 364 | 365 | var overview_modal = new bootstrap.Modal(document.getElementById('overview-modal')); 366 | overview_modal.show(); 367 | 368 | overview_modal._element.addEventListener('hidden.bs.modal', function () { 369 | document.body.style.overflow = 'auto'; 370 | document.body.style.paddingRight = '0'; 371 | }); 372 | } 373 | 374 | socket.on("overview", function (book) { 375 | book_overview_modal(book); 376 | }); 377 | 378 | const theme_switch = document.getElementById('theme-switch'); 379 | const saved_theme = localStorage.getItem('theme'); 380 | const saved_switch_position = localStorage.getItem('switch-position'); 381 | 382 | if (saved_switch_position) { 383 | theme_switch.checked = saved_switch_position === 'true'; 384 | } 385 | 386 | if (saved_theme) { 387 | document.documentElement.setAttribute('data-bs-theme', saved_theme); 388 | } 389 | 390 | theme_switch.addEventListener('click', () => { 391 | if (document.documentElement.getAttribute('data-bs-theme') === 'dark') { 392 | document.documentElement.setAttribute('data-bs-theme', 'light'); 393 | } else { 394 | document.documentElement.setAttribute('data-bs-theme', 'dark'); 395 | } 396 | localStorage.setItem('theme', document.documentElement.getAttribute('data-bs-theme')); 397 | localStorage.setItem('switch_position', theme_switch.checked); 398 | }); 399 | -------------------------------------------------------------------------------- /src/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | .logo { 7 | width: 60px; 8 | margin-right: 0px; 9 | } 10 | 11 | .scrollable-content { 12 | flex: 1; 13 | overflow: auto; 14 | max-width: 100%; 15 | } 16 | 17 | .form-group { 18 | margin-bottom: 0rem !important; 19 | } 20 | 21 | .logo-and-title { 22 | display: flex; 23 | } 24 | 25 | .book-img-container { 26 | position: relative; 27 | overflow: hidden; 28 | } 29 | 30 | .book-img-overlay { 31 | position: absolute; 32 | top: 0; 33 | left: 0; 34 | width: 100%; 35 | height: 100%; 36 | background-color: rgba(0, 0, 0, 0.7); 37 | opacity: 0; 38 | transition: opacity 0.3s ease; 39 | } 40 | 41 | .book-img-container:hover .book-img-overlay { 42 | opacity: 1; 43 | } 44 | 45 | .button-container { 46 | position: absolute; 47 | top: 50%; 48 | left: 50%; 49 | transform: translate(-50%, -50%); 50 | z-index: 1; 51 | display: flex; 52 | flex-direction: column; 53 | } 54 | 55 | .add-to-readarr-btn, 56 | .get-overview-btn { 57 | margin: 2px 0px; 58 | z-index: 1; 59 | opacity: 0; 60 | } 61 | 62 | .book-img-container:hover .add-to-readarr-btn, 63 | .book-img-container:hover .get-overview-btn { 64 | transition: opacity 0.6s ease; 65 | opacity: 1; 66 | } 67 | 68 | .status-indicator { 69 | position: absolute; 70 | top: 0; 71 | right: 0; 72 | width: 20px; 73 | height: 20px; 74 | } 75 | 76 | .led { 77 | width: 12px; 78 | height: 12px; 79 | border-radius: 50%; 80 | border: 2px solid; 81 | position: absolute; 82 | top: 4px; 83 | right: 4px; 84 | } 85 | 86 | .status-green .led { 87 | background-color: #28a745; 88 | border-color: #28a745; 89 | background-color: var(--bs-success); 90 | border-color: var(--bs-success); 91 | } 92 | 93 | .status-red .led { 94 | background-color: #dc3545; 95 | border-color: #dc3545; 96 | background-color: var(--bs-danger); 97 | border-color: var(--bs-danger); 98 | } 99 | 100 | .status-blue .led { 101 | background-color: #007bff; 102 | border-color: #007bff; 103 | background-color: var(--bs-primary); 104 | border-color: var(--bs-primary); 105 | } 106 | 107 | @media screen and (max-width: 600px) { 108 | h1{ 109 | margin-bottom: 0rem!important; 110 | } 111 | .logo{ 112 | height: 40px; 113 | width: 40px; 114 | } 115 | .container { 116 | width: 98%; 117 | } 118 | .custom-spacing .form-group-modal { 119 | margin-bottom: 5px; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 16 | 17 | 20 | 21 | 24 | eBookBuddy 25 | 26 | 27 | 28 | 29 |
30 |
31 |
32 | 36 |

eBookBuddy

37 | 41 |
42 |
43 |
44 | 45 | 46 | 88 | 89 | 90 |
91 |
92 |
93 | 94 |
95 |

Readarr

96 |
97 |
98 | 99 |
100 | 101 |
102 |
103 |
104 |
105 | 110 |
111 |
112 | 113 |
114 |
115 | 116 |
117 |
118 |
119 | 120 |
121 |
122 |
123 |
124 | 125 |
126 |
127 |
128 |
129 | 130 | 131 |
132 |
133 |
134 |
135 |
136 | 137 |
138 |
139 |
140 |
141 |
142 | 143 | 144 |
145 |
146 | 175 |
176 |
177 | 178 | 179 | 194 | 195 | 196 |
197 | 206 |
207 | 208 | 209 | 210 | 211 | --------------------------------------------------------------------------------