[books] Add google as a source and clean up data model
This commit is contained in:
@ -23,6 +23,7 @@ VROBBLER_IGDB_CLIENT_SECRET="<key>"
|
|||||||
VROBBLER_COMICVINE_API_KEY="<key>"
|
VROBBLER_COMICVINE_API_KEY="<key>"
|
||||||
VROBBLER_TODOIST_CLIENT_ID="<id>"
|
VROBBLER_TODOIST_CLIENT_ID="<id>"
|
||||||
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
||||||
|
VROBBLER_GOOGLE_API_KEY="<key>"
|
||||||
|
|
||||||
# Storages
|
# Storages
|
||||||
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
||||||
|
|||||||
@ -30,10 +30,10 @@ class BookAdmin(admin.ModelAdmin):
|
|||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
"isbn",
|
"subtitle",
|
||||||
|
"isbn_13",
|
||||||
"first_publish_year",
|
"first_publish_year",
|
||||||
"pages",
|
"pages",
|
||||||
"openlibrary_id",
|
|
||||||
)
|
)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|||||||
42
vrobbler/apps/books/metadata.py
Normal file
42
vrobbler/apps/books/metadata.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
from meta_yt import YouTube
|
||||||
|
|
||||||
|
|
||||||
|
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
|
||||||
|
IMDB_VIDEO_URL = "https://www.imdb.com/title/tt"
|
||||||
|
|
||||||
|
|
||||||
|
class BookType:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class BookMetadata:
|
||||||
|
title: str
|
||||||
|
run_time_seconds: Optional[int]
|
||||||
|
authors = Optional[str]
|
||||||
|
goodreads_id = Optional[str]
|
||||||
|
koreader_data_by_hash = Optional[dict]
|
||||||
|
isbn = Optional[str]
|
||||||
|
# isbn_13 = Optional[str]
|
||||||
|
# isbn_10 = Optional[str]
|
||||||
|
pages = Optional[int]
|
||||||
|
language = Optional[str]
|
||||||
|
first_publish_year = Optional[int]
|
||||||
|
summary = Optional[str]
|
||||||
|
|
||||||
|
# General
|
||||||
|
cover_url: Optional[str]
|
||||||
|
genres: list[str]
|
||||||
|
|
||||||
|
def __init__(self, title: Optional[str] = ""):
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
def as_dict_with_authors_cover_and_genres(self) -> tuple:
|
||||||
|
book_dict = vars(self)
|
||||||
|
authors = book_dict.pop("authors")
|
||||||
|
cover = book_dict.pop("cover_url")
|
||||||
|
genres = book_dict.pop("genres")
|
||||||
|
return book_dict, authors, cover, genres
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 4.2.18 on 2025-01-27 04:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0022_alter_book_run_time_seconds"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="book",
|
||||||
|
old_name="isbn",
|
||||||
|
new_name="isbn_13",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="comicvine_data",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="goodreads_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="locg_slug",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="isbn_10",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="publish_date",
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
21
vrobbler/apps/books/migrations/0024_book_publisher.py
Normal file
21
vrobbler/apps/books/migrations/0024_book_publisher.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.2.18 on 2025-01-27 04:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"books",
|
||||||
|
"0023_rename_isbn_book_isbn_13_remove_book_comicvine_data_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="publisher",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -35,6 +35,7 @@ from vrobbler.apps.books.locg import (
|
|||||||
lookup_comic_from_locg,
|
lookup_comic_from_locg,
|
||||||
lookup_comic_writer_by_locg_slug,
|
lookup_comic_writer_by_locg_slug,
|
||||||
)
|
)
|
||||||
|
from vrobbler.apps.books.sources.google import lookup_book_from_google
|
||||||
from vrobbler.apps.scrobbles.dataclasses import BookLogData
|
from vrobbler.apps.scrobbles.dataclasses import BookLogData
|
||||||
|
|
||||||
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
||||||
@ -101,16 +102,16 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
|
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
authors = models.ManyToManyField(Author, blank=True)
|
authors = models.ManyToManyField(Author, blank=True)
|
||||||
goodreads_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
koreader_data_by_hash = models.JSONField(**BNULL)
|
koreader_data_by_hash = models.JSONField(**BNULL)
|
||||||
isbn = models.CharField(max_length=255, **BNULL)
|
isbn_13 = models.CharField(max_length=255, **BNULL)
|
||||||
|
isbn_10 = models.CharField(max_length=255, **BNULL)
|
||||||
pages = models.IntegerField(**BNULL)
|
pages = models.IntegerField(**BNULL)
|
||||||
language = models.CharField(max_length=4, **BNULL)
|
language = models.CharField(max_length=4, **BNULL)
|
||||||
first_publish_year = models.IntegerField(**BNULL)
|
first_publish_year = models.IntegerField(**BNULL)
|
||||||
|
publish_date = models.DateField(**BNULL)
|
||||||
|
publisher = models.CharField(max_length=255, **BNULL)
|
||||||
first_sentence = models.TextField(**BNULL)
|
first_sentence = models.TextField(**BNULL)
|
||||||
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||||
locg_slug = models.CharField(max_length=255, **BNULL)
|
|
||||||
comicvine_data = models.JSONField(**BNULL)
|
|
||||||
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
||||||
cover_small = ImageSpecField(
|
cover_small = ImageSpecField(
|
||||||
source="cover",
|
source="cover",
|
||||||
@ -153,6 +154,32 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_from_google(cls, title: str, overwrite: bool = False):
|
||||||
|
book, created = cls.objects.get_or_create(title=title)
|
||||||
|
if not created and not overwrite:
|
||||||
|
return book
|
||||||
|
|
||||||
|
bdict, authors, cover, genres = lookup_book_from_google(
|
||||||
|
title
|
||||||
|
).as_dict_with_authors_cover_and_genres()
|
||||||
|
|
||||||
|
if created or overwrite:
|
||||||
|
for k, v in bdict.items():
|
||||||
|
setattr(book, k, v)
|
||||||
|
book.save()
|
||||||
|
|
||||||
|
book.save_image_from_url(cover)
|
||||||
|
book.genre.add(*genres)
|
||||||
|
return book
|
||||||
|
|
||||||
|
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||||
|
if not self.cover or (force_update and url):
|
||||||
|
r = requests.get(url)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{self.title}_{self.uuid}.jpg"
|
||||||
|
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
def fix_metadata(self, data: dict = {}, force_update=False):
|
def fix_metadata(self, data: dict = {}, force_update=False):
|
||||||
if (not self.openlibrary_id or not self.locg_slug) or force_update:
|
if (not self.openlibrary_id or not self.locg_slug) or force_update:
|
||||||
author_name = ""
|
author_name = ""
|
||||||
@ -340,7 +367,9 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
)
|
)
|
||||||
return book
|
return book
|
||||||
|
|
||||||
book, book_created = cls.objects.get_or_create(isbn=data["isbn"])
|
book, book_created = cls.objects.get_or_create(
|
||||||
|
isbn_13=data["isbn"]
|
||||||
|
)
|
||||||
if book_created:
|
if book_created:
|
||||||
book.fix_metadata(data=data)
|
book.fix_metadata(data=data)
|
||||||
|
|
||||||
|
|||||||
0
vrobbler/apps/books/sources/__init__.py
Normal file
0
vrobbler/apps/books/sources/__init__.py
Normal file
68
vrobbler/apps/books/sources/google.py
Normal file
68
vrobbler/apps/books/sources/google.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
import requests
|
||||||
|
from books.metadata import BookMetadata
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
API_KEY = settings.GOOGLE_API_KEY
|
||||||
|
GOOGLE_BOOKS_URL = (
|
||||||
|
"https://www.googleapis.com/books/v1/volumes?q={title}&key={key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_book_from_google(title: str) -> BookMetadata:
|
||||||
|
book_metadata = BookMetadata(title=title)
|
||||||
|
|
||||||
|
url = GOOGLE_BOOKS_URL.format(title=title, key=API_KEY)
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"Bad response from Google", extra={"response": response}
|
||||||
|
)
|
||||||
|
return book_metadata
|
||||||
|
|
||||||
|
google_result = (
|
||||||
|
json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
|
||||||
|
)
|
||||||
|
publish_date = pendulum.parse(google_result.get("publishedDate"))
|
||||||
|
|
||||||
|
isbn_13 = ""
|
||||||
|
isbn_10 = ""
|
||||||
|
for ident in google_result.get("industryIdentifiers", []):
|
||||||
|
if ident.get("type") == "ISBN_13":
|
||||||
|
isbn_13 = ident.get("identifier")
|
||||||
|
if ident.get("type") == "ISBN_10":
|
||||||
|
isbn_10 = ident.get("identifier")
|
||||||
|
book_metadata.title = google_result.get("title")
|
||||||
|
if google_result.get("subtitle"):
|
||||||
|
book_metadata.title = ": ".join(
|
||||||
|
[google_result.get("title"), google_result.get("subtitle")]
|
||||||
|
)
|
||||||
|
book_metadata.authors = google_result.get("authors")
|
||||||
|
book_metadata.publisher = google_result.get("publisher")
|
||||||
|
book_metadata.first_publish_year = publish_date.year
|
||||||
|
book_metadata.pages = google_result.get("pageCount")
|
||||||
|
book_metadata.isbn_13 = isbn_13
|
||||||
|
book_metadata.isbn_10 = isbn_10
|
||||||
|
book_metadata.publish_date = google_result.get("publishedDate")
|
||||||
|
book_metadata.language = google_result.get("language")
|
||||||
|
book_metadata.summary = google_result.get("description")
|
||||||
|
book_metadata.genres = google_result.get("categories")
|
||||||
|
book_metadata.cover_url = (
|
||||||
|
google_result.get("imageLinks", {})
|
||||||
|
.get("thumbnail")
|
||||||
|
.replace("zoom=1", "zoom=15")
|
||||||
|
.replace("&edge=curl", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
book_metadata.run_time_seconds = book_metadata.pages * getattr(
|
||||||
|
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||||
|
)
|
||||||
|
|
||||||
|
return book_metadata
|
||||||
@ -74,6 +74,8 @@ POINTS_FOR_MOVEMENT_HISTORY = os.getenv(
|
|||||||
TODOIST_CLIENT_ID = os.getenv("VROBBLER_TODOIST_CLIENT_ID", "")
|
TODOIST_CLIENT_ID = os.getenv("VROBBLER_TODOIST_CLIENT_ID", "")
|
||||||
TODOIST_CLIENT_SECRET = os.getenv("VROBBLER_TODOIST_CLIENT_SECRET", "")
|
TODOIST_CLIENT_SECRET = os.getenv("VROBBLER_TODOIST_CLIENT_SECRET", "")
|
||||||
|
|
||||||
|
GOOGLE_API_KEY = os.getenv("VROBBLER_GOOGLE_API_KEY", "")
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern")
|
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern")
|
||||||
|
|||||||
Reference in New Issue
Block a user