├── .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 | 
2 | 
3 |
4 |
5 |
6 |
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 |
51 |
52 |
53 |
54 |
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 |