Fix KOReader imports to use pages for scrobbles
This commit is contained in:
@ -1,12 +1,17 @@
|
|||||||
|
import codecs
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Iterable, List
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
import requests
|
||||||
from books.models import Author, Book, Page
|
from books.models import Author, Book, Page
|
||||||
|
from pylast import httpx, tempfile
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
from stream_sqlite import stream_sqlite
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -34,54 +39,24 @@ class KoReaderPageStatColumn(Enum):
|
|||||||
TOTAL_PAGES = 4
|
TOTAL_PAGES = 4
|
||||||
|
|
||||||
|
|
||||||
def process_pages_for_book(book_id, sqlite_file_path):
|
def _sqlite_bytes(sqlite_url):
|
||||||
con = sqlite3.connect(sqlite_file_path)
|
with httpx.stream("GET", sqlite_url) as r:
|
||||||
cur = con.cursor()
|
yield from r.iter_bytes(chunk_size=65_536)
|
||||||
|
|
||||||
book = Book.objects.filter(koreader_id=book_id).first()
|
|
||||||
if not book:
|
|
||||||
logger.error(f"No book found with KoReader ID of {book_id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
page_table = cur.execute(
|
|
||||||
f"SELECT * FROM page_stat_data where id_book={book_id}"
|
|
||||||
)
|
|
||||||
new_pages = []
|
|
||||||
for page_row in page_table:
|
|
||||||
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
|
||||||
page, page_created = Page.objects.get_or_create(
|
|
||||||
book=book, number=page_number
|
|
||||||
)
|
|
||||||
if page_created:
|
|
||||||
ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
|
||||||
page.start_time = datetime.utcfromtimestamp(ts).replace(
|
|
||||||
tzinfo=pytz.utc
|
|
||||||
)
|
|
||||||
page.duration_seconds = page_row[
|
|
||||||
KoReaderPageStatColumn.DURATION.value
|
|
||||||
]
|
|
||||||
page.save()
|
|
||||||
new_pages.append(page)
|
|
||||||
logger.info(f"Added {len(new_pages)} for book {book}")
|
|
||||||
return new_pages
|
|
||||||
|
|
||||||
|
|
||||||
def process_koreader_sqlite_file(sqlite_file_path, user_id):
|
def get_book_map_from_sqlite(rows: Iterable) -> dict:
|
||||||
"""Given a sqlite file from KoReader, open the book table, iterate
|
"""Given an interable of sqlite rows from the books table, lookup existing
|
||||||
over rows creating scrobbles from each book found"""
|
books, create ones that don't exist, and return a mapping of koreader IDs to
|
||||||
# Create a SQL connection to our SQLite database
|
primary key IDs for page creation.
|
||||||
con = sqlite3.connect(sqlite_file_path)
|
|
||||||
cur = con.cursor()
|
|
||||||
|
|
||||||
# Return all results of query
|
"""
|
||||||
book_table = cur.execute("SELECT * FROM book")
|
book_id_map = {}
|
||||||
new_scrobbles = []
|
|
||||||
for book_row in book_table:
|
for book_row in rows:
|
||||||
authors = book_row[KoReaderBookColumn.AUTHORS.value].split("\n")
|
authors = book_row[KoReaderBookColumn.AUTHORS.value].split("\n")
|
||||||
author_list = []
|
author_list = []
|
||||||
for author_str in authors:
|
for author_str in authors:
|
||||||
logger.debug(f"Looking up author {author_str}")
|
logger.debug(f"Looking up author {author_str}")
|
||||||
|
|
||||||
if author_str == "N/A":
|
if author_str == "N/A":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -96,11 +71,11 @@ def process_koreader_sqlite_file(sqlite_file_path, user_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
pages = book_row[KoReaderBookColumn.PAGES.value]
|
total_pages = book_row[KoReaderBookColumn.PAGES.value]
|
||||||
run_time = pages * book.AVG_PAGE_READING_SECONDS
|
run_time = total_pages * book.AVG_PAGE_READING_SECONDS
|
||||||
book_dict = {
|
book_dict = {
|
||||||
"title": book_row[KoReaderBookColumn.TITLE.value],
|
"title": book_row[KoReaderBookColumn.TITLE.value],
|
||||||
"pages": book_row[KoReaderBookColumn.PAGES.value],
|
"pages": total_pages,
|
||||||
"koreader_md5": book_row[KoReaderBookColumn.MD5.value],
|
"koreader_md5": book_row[KoReaderBookColumn.MD5.value],
|
||||||
"koreader_id": int(book_row[KoReaderBookColumn.ID.value]),
|
"koreader_id": int(book_row[KoReaderBookColumn.ID.value]),
|
||||||
"koreader_authors": book_row[KoReaderBookColumn.AUTHORS.value],
|
"koreader_authors": book_row[KoReaderBookColumn.AUTHORS.value],
|
||||||
@ -117,6 +92,7 @@ def process_koreader_sqlite_file(sqlite_file_path, user_id):
|
|||||||
playback_position_seconds = book_row[
|
playback_position_seconds = book_row[
|
||||||
KoReaderBookColumn.TOTAL_READ_TIME.value
|
KoReaderBookColumn.TOTAL_READ_TIME.value
|
||||||
]
|
]
|
||||||
|
|
||||||
pages_read = 0
|
pages_read = 0
|
||||||
if book_row[KoReaderBookColumn.TOTAL_READ_PAGES.value]:
|
if book_row[KoReaderBookColumn.TOTAL_READ_PAGES.value]:
|
||||||
pages_read = int(
|
pages_read = int(
|
||||||
@ -125,37 +101,123 @@ def process_koreader_sqlite_file(sqlite_file_path, user_id):
|
|||||||
timestamp = datetime.utcfromtimestamp(
|
timestamp = datetime.utcfromtimestamp(
|
||||||
book_row[KoReaderBookColumn.LAST_OPEN.value]
|
book_row[KoReaderBookColumn.LAST_OPEN.value]
|
||||||
).replace(tzinfo=pytz.utc)
|
).replace(tzinfo=pytz.utc)
|
||||||
process_pages_for_book(book.koreader_id, sqlite_file_path)
|
book_id_map[book.koreader_id] = book.id
|
||||||
|
|
||||||
new_scrobble = Scrobble(
|
return book_id_map
|
||||||
book_id=book.id,
|
|
||||||
user_id=user_id,
|
|
||||||
source="KOReader",
|
def build_scrobbles_from_pages(
|
||||||
timestamp=timestamp,
|
rows: Iterable, book_id_map: dict, user_id: int
|
||||||
playback_position_seconds=playback_position_seconds,
|
) -> List[Scrobble]:
|
||||||
played_to_completion=True,
|
new_scrobbles = []
|
||||||
in_progress=False,
|
|
||||||
book_pages_read=pages_read,
|
new_scrobbles = []
|
||||||
|
for page_row in rows:
|
||||||
|
koreader_id = page_row[KoReaderPageStatColumn.ID_BOOK.value]
|
||||||
|
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
||||||
|
ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
||||||
|
book_id = book_id_map[koreader_id]
|
||||||
|
|
||||||
|
page, page_created = Page.objects.get_or_create(
|
||||||
|
book_id=book_id, number=page_number, user_id=user_id
|
||||||
)
|
)
|
||||||
|
if page_created:
|
||||||
|
page.start_time = datetime.utcfromtimestamp(ts).replace(
|
||||||
|
tzinfo=pytz.utc
|
||||||
|
)
|
||||||
|
page.duration_seconds = page_row[
|
||||||
|
KoReaderPageStatColumn.DURATION.value
|
||||||
|
]
|
||||||
|
page.save(update_fields=["start_time", "duration_seconds"])
|
||||||
|
page.refresh_from_db()
|
||||||
|
if page.is_scrobblable:
|
||||||
|
# Page number is a placeholder, we'll re-preocess this after creation
|
||||||
|
logger.debug(
|
||||||
|
f"Queueing scrobble for {page.book}, page {page.number}"
|
||||||
|
)
|
||||||
|
new_scrobble = Scrobble(
|
||||||
|
book_id=page.book_id,
|
||||||
|
user_id=user_id,
|
||||||
|
source="KOReader",
|
||||||
|
timestamp=page.start_time,
|
||||||
|
played_to_completion=True,
|
||||||
|
in_progress=False,
|
||||||
|
book_pages_read=page_number,
|
||||||
|
long_play_complete=False,
|
||||||
|
)
|
||||||
|
new_scrobbles.append(new_scrobble)
|
||||||
|
return new_scrobbles
|
||||||
|
|
||||||
existing = Scrobble.objects.filter(
|
|
||||||
timestamp=timestamp, book=book
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
logger.debug(f"Skipping existing scrobble {new_scrobble}")
|
|
||||||
continue
|
|
||||||
if book.progress_for_user(user_id) >= Book.COMPLETION_PERCENT:
|
|
||||||
new_scrobble.long_play_complete = True
|
|
||||||
|
|
||||||
logger.debug(f"Queued scrobble {new_scrobble} for creation")
|
def enrich_koreader_scrobbles(scrobbles: list) -> None:
|
||||||
new_scrobbles.append(new_scrobble)
|
"""Given a list of scrobbles, update pages read, long play seconds and check
|
||||||
|
for media completion"""
|
||||||
|
|
||||||
# Be sure to close the connection
|
for scrobble in scrobbles:
|
||||||
con.close()
|
if scrobble.next:
|
||||||
|
scrobble.book_pages_read = scrobble.next.book_pages_read - 1
|
||||||
|
scrobble.save(update_fields=["book_pages_read"])
|
||||||
|
else:
|
||||||
|
scrobble.book_pages_read = scrobble.book.page_set.last().number
|
||||||
|
scrobble.long_play_complete =
|
||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
scrobble.save(update_fields=["book_pages_read", "long_play_complete"])
|
||||||
logger.info(
|
|
||||||
f"Created {len(created)} scrobbles",
|
|
||||||
extra={"created_scrobbles": created},
|
def process_koreader_sqlite_url(file_url, user_id) -> list:
|
||||||
)
|
book_id_map = {}
|
||||||
|
new_scrobbles = []
|
||||||
|
|
||||||
|
for table_name, pragma_table_info, rows in stream_sqlite(
|
||||||
|
_sqlite_bytes(file_url), max_buffer_size=1_048_576
|
||||||
|
):
|
||||||
|
if table_name == "book":
|
||||||
|
book_id_map = get_book_map_from_sqlite(rows)
|
||||||
|
|
||||||
|
if table_name == "page":
|
||||||
|
new_scrobbles = build_scrobbles_from_pages(
|
||||||
|
rows, book_id_map, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
created = []
|
||||||
|
if new_scrobbles:
|
||||||
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
|
enrich_koreader_scrobbles(created)
|
||||||
|
logger.info(
|
||||||
|
f"Created {len(created)} scrobbles",
|
||||||
|
extra={"created_scrobbles": created},
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||||
|
"""Given a sqlite file from KoReader, open the book table, iterate
|
||||||
|
over rows creating scrobbles from each book found"""
|
||||||
|
# Create a SQL connection to our SQLite database
|
||||||
|
con = sqlite3.connect(file_path)
|
||||||
|
cur = con.cursor()
|
||||||
|
|
||||||
|
book_id_map = get_book_map_from_sqlite(cur.execute("SELECT * FROM book"))
|
||||||
|
new_scrobbles = build_scrobbles_from_pages(
|
||||||
|
cur.execute("SELECT * from page_stat_data"), book_id_map, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
created = []
|
||||||
|
if new_scrobbles:
|
||||||
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
|
enrich_koreader_scrobbles(created)
|
||||||
|
logger.info(
|
||||||
|
f"Created {len(created)} scrobbles",
|
||||||
|
extra={"created_scrobbles": created},
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def process_koreader_sqlite(file_path: str, user_id: int) -> list:
|
||||||
|
is_os_file = "https://" not in file_path
|
||||||
|
|
||||||
|
if is_os_file:
|
||||||
|
created = process_koreader_sqlite_file(file_path, user_id)
|
||||||
|
else:
|
||||||
|
created = process_koreader_sqlite_url(file_path, user_id)
|
||||||
return created
|
return created
|
||||||
|
|||||||
@ -126,6 +126,9 @@ class BaseFileImportMixin(TimeStampedModel):
|
|||||||
self.process_count = len(scrobbles)
|
self.process_count = len(scrobbles)
|
||||||
self.save(update_fields=["process_log", "process_count"])
|
self.save(update_fields=["process_log", "process_count"])
|
||||||
|
|
||||||
|
def upload_file_path(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class KoReaderImport(BaseFileImportMixin):
|
class KoReaderImport(BaseFileImportMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -144,10 +147,18 @@ class KoReaderImport(BaseFileImportMixin):
|
|||||||
uuid = instance.uuid
|
uuid = instance.uuid
|
||||||
return f"koreader-uploads/{uuid}.{extension}"
|
return f"koreader-uploads/{uuid}.{extension}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def upload_file_path(self) -> str:
|
||||||
|
if getattr(settings, "USE_S3_STORAGE"):
|
||||||
|
path = self.sqlite_file.url
|
||||||
|
else:
|
||||||
|
path = self.sqlite_file.path
|
||||||
|
return path
|
||||||
|
|
||||||
sqlite_file = models.FileField(upload_to=get_path, **BNULL)
|
sqlite_file = models.FileField(upload_to=get_path, **BNULL)
|
||||||
|
|
||||||
def process(self, force=False):
|
def process(self, force=False):
|
||||||
from books.koreader import process_koreader_sqlite_file
|
from books.koreader import process_koreader_sqlite
|
||||||
|
|
||||||
if self.processed_finished and not force:
|
if self.processed_finished and not force:
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -156,8 +167,8 @@ class KoReaderImport(BaseFileImportMixin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.mark_started()
|
self.mark_started()
|
||||||
scrobbles = process_koreader_sqlite_file(
|
scrobbles = process_koreader_sqlite(
|
||||||
self.sqlite_file.path, self.user.id
|
self.upload_file_path, self.user.id
|
||||||
)
|
)
|
||||||
self.record_log(scrobbles)
|
self.record_log(scrobbles)
|
||||||
self.mark_finished()
|
self.mark_finished()
|
||||||
@ -180,6 +191,13 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
|
|||||||
uuid = instance.uuid
|
uuid = instance.uuid
|
||||||
return f"audioscrobbler-uploads/{uuid}.{extension}"
|
return f"audioscrobbler-uploads/{uuid}.{extension}"
|
||||||
|
|
||||||
|
def upload_file_path(self):
|
||||||
|
if getattr(settings, "USE_S3_STORAGE"):
|
||||||
|
path = self.tsv_file.url
|
||||||
|
else:
|
||||||
|
path = self.tsv_file.path
|
||||||
|
return path
|
||||||
|
|
||||||
tsv_file = models.FileField(upload_to=get_path, **BNULL)
|
tsv_file = models.FileField(upload_to=get_path, **BNULL)
|
||||||
|
|
||||||
def process(self, force=False):
|
def process(self, force=False):
|
||||||
@ -198,12 +216,8 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
|
|||||||
if self.user:
|
if self.user:
|
||||||
user_id = self.user.id
|
user_id = self.user.id
|
||||||
tz = self.user.profile.tzinfo
|
tz = self.user.profile.tzinfo
|
||||||
if getattr(settings, "USE_S3_STORAGE"):
|
|
||||||
tsv_str = self.tsv_file.url
|
|
||||||
else:
|
|
||||||
tsv_str = self.tsv_file.path
|
|
||||||
scrobbles = process_audioscrobbler_tsv_file(
|
scrobbles = process_audioscrobbler_tsv_file(
|
||||||
tsv_str, user_id, user_tz=tz
|
self.upload_file_path, user_id, user_tz=tz
|
||||||
)
|
)
|
||||||
self.record_log(scrobbles)
|
self.record_log(scrobbles)
|
||||||
self.mark_finished()
|
self.mark_finished()
|
||||||
@ -457,13 +471,21 @@ class Scrobble(TimeStampedModel):
|
|||||||
return is_stale
|
return is_stale
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def previous(self):
|
def previous(self) -> "Scrobble":
|
||||||
return (
|
return (
|
||||||
self.media_obj.scrobble_set.order_by("-timestamp")
|
self.media_obj.scrobble_set.order_by("-timestamp")
|
||||||
.filter(timestamp__lt=self.timestamp)
|
.filter(timestamp__lt=self.timestamp)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def next(self) -> "Scrobble":
|
||||||
|
return (
|
||||||
|
self.media_obj.scrobble_set.order_by("timestamp")
|
||||||
|
.filter(timestamp__gt=self.timestamp)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session_pages_read(self) -> Optional[int]:
|
def session_pages_read(self) -> Optional[int]:
|
||||||
"""Look one scrobble back, if it isn't complete,"""
|
"""Look one scrobble back, if it isn't complete,"""
|
||||||
|
|||||||
Reference in New Issue
Block a user