models.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import logging
  2. from uuid import uuid4
  3. import requests
  4. from books.openlibrary import (
  5. lookup_author_from_openlibrary,
  6. lookup_book_from_openlibrary,
  7. )
  8. from django.conf import settings
  9. from django.contrib.auth import get_user_model
  10. from django.core.files.base import ContentFile
  11. from django.db import models
  12. from django.urls import reverse
  13. from django_extensions.db.models import TimeStampedModel
  14. from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableMixin
  15. from scrobbles.utils import get_scrobbles_for_media
  16. logger = logging.getLogger(__name__)
  17. User = get_user_model()
  18. BNULL = {"blank": True, "null": True}
  19. class Author(TimeStampedModel):
  20. name = models.CharField(max_length=255)
  21. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  22. openlibrary_id = models.CharField(max_length=255, **BNULL)
  23. headshot = models.ImageField(upload_to="books/authors/", **BNULL)
  24. bio = models.TextField(**BNULL)
  25. wikipedia_url = models.CharField(max_length=255, **BNULL)
  26. isni = models.CharField(max_length=255, **BNULL)
  27. wikidata_id = models.CharField(max_length=255, **BNULL)
  28. goodreads_id = models.CharField(max_length=255, **BNULL)
  29. librarything_id = models.CharField(max_length=255, **BNULL)
  30. amazon_id = models.CharField(max_length=255, **BNULL)
  31. def __str__(self):
  32. return f"{self.name}"
  33. def fix_metadata(self, data_dict: dict = {}):
  34. if not data_dict and self.openlibrary_id:
  35. data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
  36. if not data_dict or not data_dict.get("name"):
  37. return
  38. headshot_url = data_dict.pop("author_headshot_url", "")
  39. Author.objects.filter(pk=self.id).update(**data_dict)
  40. self.refresh_from_db()
  41. if headshot_url:
  42. r = requests.get(headshot_url)
  43. if r.status_code == 200:
  44. fname = f"{self.name}_{self.uuid}.jpg"
  45. self.headshot.save(fname, ContentFile(r.content), save=True)
  46. class Book(LongPlayScrobblableMixin):
  47. COMPLETION_PERCENT = getattr(settings, "BOOK_COMPLETION_PERCENT", 95)
  48. AVG_PAGE_READING_SECONDS = getattr(
  49. settings, "AVERAGE_PAGE_READING_SECONDS", 60
  50. )
  51. title = models.CharField(max_length=255)
  52. authors = models.ManyToManyField(Author)
  53. goodreads_id = models.CharField(max_length=255, **BNULL)
  54. koreader_id = models.IntegerField(**BNULL)
  55. koreader_authors = models.CharField(max_length=255, **BNULL)
  56. koreader_md5 = models.CharField(max_length=255, **BNULL)
  57. isbn = models.CharField(max_length=255, **BNULL)
  58. pages = models.IntegerField(**BNULL)
  59. language = models.CharField(max_length=4, **BNULL)
  60. first_publish_year = models.IntegerField(**BNULL)
  61. first_sentence = models.CharField(max_length=255, **BNULL)
  62. openlibrary_id = models.CharField(max_length=255, **BNULL)
  63. cover = models.ImageField(upload_to="books/covers/", **BNULL)
  64. def __str__(self):
  65. return f"{self.title} by {self.author}"
  66. @property
  67. def subtitle(self):
  68. return f" by {self.author}"
  69. def get_start_url(self):
  70. return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
  71. def get_absolute_url(self):
  72. return reverse("books:book_detail", kwargs={"slug": self.uuid})
  73. def fix_metadata(self, force_update=False):
  74. if not self.openlibrary_id or force_update:
  75. book_dict = lookup_book_from_openlibrary(self.title, self.author)
  76. cover_url = book_dict.pop("cover_url", "")
  77. ol_author_id = book_dict.pop("ol_author_id", "")
  78. ol_author_name = book_dict.pop("ol_author_name", "")
  79. # Don't want to overwrite KoReader's pages if OL is ignorant
  80. if not book_dict.get("pages"):
  81. book_dict.pop("pages")
  82. ol_title = book_dict.get("title")
  83. if ol_title.lower() != self.title.lower():
  84. logger.warn(
  85. f"OL and KoReader disagree on this book title {self.title} != {ol_title}"
  86. )
  87. return
  88. Book.objects.filter(pk=self.id).update(**book_dict)
  89. self.refresh_from_db()
  90. # Process authors
  91. author = None
  92. author_created = False
  93. if ol_author_id:
  94. author, author_created = Author.objects.get_or_create(
  95. openlibrary_id=ol_author_id
  96. )
  97. if author_created or force_update:
  98. author.fix_metadata()
  99. if not author and ol_author_name:
  100. author, author_created = Author.objects.get_or_create(
  101. name=ol_author_name
  102. )
  103. self.authors.add(author)
  104. if cover_url:
  105. r = requests.get(cover_url)
  106. if r.status_code == 200:
  107. fname = f"{self.title}_{self.uuid}.jpg"
  108. self.cover.save(fname, ContentFile(r.content), save=True)
  109. if self.pages:
  110. self.run_time_seconds = self.pages * int(
  111. self.AVG_PAGE_READING_SECONDS
  112. )
  113. self.save()
  114. @property
  115. def author(self):
  116. return self.authors.first()
  117. @property
  118. def pages_for_completion(self) -> int:
  119. if not self.pages:
  120. logger.warn(f"{self} has no pages, no completion percentage")
  121. return 0
  122. return int(self.pages * (self.COMPLETION_PERCENT / 100))
  123. def update_long_play_seconds(self):
  124. """Check page timestamps and duration and update"""
  125. if self.page_set.all():
  126. ...
  127. def progress_for_user(self, user_id: int) -> int:
  128. """Used to keep track of whether the book is complete or not"""
  129. user = User.objects.get(id=user_id)
  130. last_scrobble = get_scrobbles_for_media(self, user).last()
  131. return int((last_scrobble.book_pages_read / self.pages) * 100)
  132. @classmethod
  133. def find_or_create(cls, data_dict: dict) -> "Game":
  134. from books.utils import update_or_create_book
  135. return update_or_create_book(
  136. data_dict.get("title"), data_dict.get("author")
  137. )
  138. class Page(TimeStampedModel):
  139. book = models.ForeignKey(Book, on_delete=models.CASCADE)
  140. number = models.IntegerField()
  141. start_time = models.DateTimeField(**BNULL)
  142. duration_seconds = models.IntegerField(**BNULL)
  143. class Meta:
  144. unique_together = (
  145. "book",
  146. "number",
  147. )
  148. def __str__(self):
  149. return f"Page {self.number} of {self.book.pages} in {self.book.title}"