Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5830f5cd1 | |||
| c71b51fdb8 | |||
| 935d059a20 | |||
| 25776eb495 | |||
| 5ac4625af9 | |||
| a731427f6e | |||
| 410da163fe | |||
| a171192a6f | |||
| c16b61db40 | |||
| 29cb6a4991 | |||
| 25c28e8335 | |||
| 25626be3b6 | |||
| 0a880a2f2f | |||
| 248d3f2d3e | |||
| e243fec679 | |||
| de9b4ee9c1 | |||
| bf9a6a9679 | |||
| 709fed5cfe | |||
| b7df6299d0 | |||
| be16d513ef |
244
PROJECT.org
244
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/15] :vrobbler:project:personal:
|
||||
* Backlog [0/14] :vrobbler:project:personal:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||
@ -442,41 +442,6 @@ displayed in the template.
|
||||
|
||||
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
|
||||
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
|
||||
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:51]
|
||||
|
||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
:END:
|
||||
** TODO [#B] Fix koreader scrobble imports to use DST properly :bug:books:imports:
|
||||
:PROPERTIES:
|
||||
:ID: 79758cba-a440-48b6-a637-efb88827acf2
|
||||
:END:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:37] \\
|
||||
|
||||
This may already be fixed ... need to check.
|
||||
|
||||
- Note taken on [2025-02-25 12:34] \\
|
||||
|
||||
The page data has the canonical date something was read in it, but it seems
|
||||
to be an hour off. I traced this back to being off during DST, so we just need
|
||||
the importer to be aware of whether a user is using DST or not and roll back
|
||||
an hour for part of the year. Also, we'd need to adjust any old scrobbles that
|
||||
took place with DST off to roll them back by an hour.
|
||||
|
||||
|
||||
*** Description
|
||||
|
||||
This is a long-standing problem when daylight saving time takes effect. Time is
|
||||
manually set on a KoReader device (or at least, always saved in local time). So
|
||||
whatever time KoReader reports, we need to know, given the date and the user
|
||||
profile's historic timezone, how many hours to adjust the KoReader time to get
|
||||
to GMT to save it in the database.
|
||||
|
||||
** TODO [#B] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||
:PROPERTIES:
|
||||
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
|
||||
@ -534,23 +499,222 @@ async with the POST data stored in the log["raw_data"] and used by the celery en
|
||||
to go try to enrich the media instance. Should this enrichment fail, tag the scrobble as "enrichment-failed"
|
||||
log a warning and move on.
|
||||
|
||||
** TODO [#A] Allow updating all a user's scrobble visibility at once :scrobbles:sharing:feature:
|
||||
** TODO [#B] Allow browing a user's favorited media :favorites:feature:
|
||||
:PROPERTIES:
|
||||
:ID: 5c2cf004-d01f-4576-9bbb-974235e7408a
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
We should have a global view `/favorites/` that shows the logged in users's
|
||||
favorited media objects.
|
||||
|
||||
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:51]
|
||||
|
||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
:END:
|
||||
* Version 51.2 [2/2]
|
||||
** DONE [#A] Fix bug where last page of book gets separate scrobble :bug:books:importers:koreader:
|
||||
:PROPERTIES:
|
||||
:ID: e13e0b4c-461e-e5a9-c685-b972f4e262e5
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
The new KoReader code is working great to import books with the correct timezone
|
||||
and what-not. But it has a weird artifact of creating one extra scrobble for the
|
||||
last page read. Need to button that up.
|
||||
|
||||
** DONE [#B] Fix metadata scraping for books :books:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: ea416a69-a8a8-4d05-b7d4-0a3470820e34
|
||||
:END:
|
||||
|
||||
|
||||
* Version 51.1 [1/1]
|
||||
** DONE [#A] Fix scrobbling comic books :books:scrobbles:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 8dfbff19-3fa4-f3b8-21c7-7a416498000c
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
At some point logdata and log got confused, and now when you try
|
||||
to scrobble a comic book, it just throws errors. We should look
|
||||
into where the confusion happened and fix it.
|
||||
|
||||
|
||||
* Version 51.0 [3/3]
|
||||
** DONE [#B] Fix koreader scrobble imports to use DST properly :bug:books:imports:
|
||||
:PROPERTIES:
|
||||
:ID: 79758cba-a440-48b6-a637-efb88827acf2
|
||||
:END:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:37] \\
|
||||
|
||||
This may already be fixed ... need to check.
|
||||
|
||||
- Note taken on [2025-02-25 12:34] \\
|
||||
|
||||
The page data has the canonical date something was read in it, but it seems
|
||||
to be an hour off. I traced this back to being off during DST, so we just need
|
||||
the importer to be aware of whether a user is using DST or not and roll back
|
||||
an hour for part of the year. Also, we'd need to adjust any old scrobbles that
|
||||
took place with DST off to roll them back by an hour.
|
||||
|
||||
|
||||
*** Description
|
||||
|
||||
This is a long-standing problem when daylight saving time takes effect. Time is
|
||||
manually set on a KoReader device (or at least, always saved in local time). So
|
||||
whatever time KoReader reports, we need to know, given the date and the user
|
||||
profile's historic timezone, how many hours to adjust the KoReader time to get
|
||||
to GMT to save it in the database.
|
||||
|
||||
** DONE [#A] Fix book scrobbles where page_data is a list :bug:books:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 35b323fa-ccc0-4009-b227-8a0f12bbd469
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Comic scrobbling is currently kind of janky. Most of the problems boil down to them
|
||||
storing saved page data in a list of dicts rather than a dict keyed off of the page number that
|
||||
was read.
|
||||
|
||||
We need to adjust comic scrobbling to use a dict of pages keyed off the page number, and also
|
||||
write a migration script that runs as a data migration to update any book scrobbles that may
|
||||
have page_data as a list.
|
||||
|
||||
*** Example data
|
||||
#+begin_src python
|
||||
{"notes": null, "page_end": 27, "page_data": [{"notes": null, "end_ts": 1771815895, "duration": 14, "start_ts": 1771815881, "description": null, "page_number": "1"}, {"notes": null, "end_ts": 1771815908, "duration": 13, "start_ts": 1771815895, "description": null, "page_number": "1"}, {"notes": null, "end_ts": 1771815913, "duration": 5, "start_ts": 1771815908, "description": null, "page_number": "2"}, {"notes": null, "end_ts": 1771815933, "duration": 20, "start_ts": 1771815913, "description": null, "page_number": "3"}, {"notes": null, "end_ts": 1771815945, "duration": 12, "start_ts": 1771815933, "description": null, "page_number": "4"}, {"notes": null, "end_ts": 1771815983, "duration": 38, "start_ts": 1771815945, "description": null, "page_number": "5"}, {"notes": null, "end_ts": 1771816007, "duration": 24, "start_ts": 1771815983, "description": null, "page_number": "6"}, {"notes": null, "end_ts": 1771816011, "duration": 4, "start_ts": 1771816007, "description": null, "page_number": "7"}, {"notes": null, "end_ts": 1771816013, "duration": 2, "start_ts": 1771816011, "description": null, "page_number": "8"}, {"notes": null, "end_ts": 1771816052, "duration": 39, "start_ts": 1771816013, "description": null, "page_number": "7"}, {"notes": null, "end_ts": 1771816127, "duration": 75, "start_ts": 1771816052, "description": null, "page_number": "8"}, {"notes": null, "end_ts": 1771816134, "duration": 7, "start_ts": 1771816127, "description": null, "page_number": "9"}, {"notes": null, "end_ts": 1771816196, "duration": 62, "start_ts": 1771816134, "description": null, "page_number": "10"}, {"notes": null, "end_ts": 1771816262, "duration": 66, "start_ts": 1771816196, "description": null, "page_number": "11"}, {"notes": null, "end_ts": 1771816293, "duration": 31, "start_ts": 1771816262, "description": null, "page_number": "12"}, {"notes": null, "end_ts": 1771816322, "duration": 29, "start_ts": 1771816293, "description": null, "page_number": "13"}, {"notes": null, "end_ts": 1771816330, "duration": 8, "start_ts": 1771816322, "description": null, "page_number": "14"}, {"notes": null, "end_ts": 1771816368, "duration": 38, "start_ts": 1771816330, "description": null, "page_number": "15"}, {"notes": null, "end_ts": 1771816388, "duration": 20, "start_ts": 1771816368, "description": null, "page_number": "16"}, {"notes": null, "end_ts": 1771816482, "duration": 94, "start_ts": 1771816388, "description": null, "page_number": "17"}, {"notes": null, "end_ts": 1771816550, "duration": 68, "start_ts": 1771816482, "description": null, "page_number": "18"}, {"notes": null, "end_ts": 1771816567, "duration": 17, "start_ts": 1771816550, "description": null, "page_number": "19"}, {"notes": null, "end_ts": 1771816586, "duration": 19, "start_ts": 1771816567, "description": null, "page_number": "20"}, {"notes": null, "end_ts": 1771816597, "duration": 11, "start_ts": 1771816586, "description": null, "page_number": "21"}, {"notes": null, "end_ts": 1771816616, "duration": 19, "start_ts": 1771816597, "description": null, "page_number": "22"}, {"notes": null, "end_ts": 1771816640, "duration": 24, "start_ts": 1771816616, "description": null, "page_number": "23"}, {"notes": null, "end_ts": 1771816690, "duration": 50, "start_ts": 1771816640, "description": null, "page_number": "24"}, {"notes": null, "end_ts": 1771816702, "duration": 12, "start_ts": 1771816690, "description": null, "page_number": "25"}, {"notes": null, "end_ts": 1771816823, "duration": 121, "start_ts": 1771816702, "description": null, "page_number": "26"}, {"notes": null, "end_ts": null, "duration": null, "start_ts": 1771816823, "description": null, "page_number": "27"}], "page_start": 1, "pages_read": 27, "resume_url": null, "description": null, "koreader_hash": null, "long_play_complete": false}
|
||||
|
||||
#+end_src
|
||||
** DONE [#A] Lichess imports do not set default visbility :boardgames:bug:importers:lichess:
|
||||
:PROPERTIES:
|
||||
:ID: a78f7c72-a20a-8db2-cde0-d92a731d4fba
|
||||
:END:
|
||||
|
||||
* Version 50.2 [2/2]
|
||||
** DONE [#B] Koreader imports only import single-page scrobbles the next day :bug:books:importers:
|
||||
:PROPERTIES:
|
||||
:ID: b50141fd-cda6-4a3a-afd3-cd8499e7523e
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When you read a single page in a book in Koreader and try to import it, the scrobble is only
|
||||
created the day after, not on the day of the reading.
|
||||
|
||||
** DONE [#A] Fix bugs in celery tasks causing imports to fail :bug:celery:tasks:
|
||||
:PROPERTIES:
|
||||
:ID: d1171cb0-6413-44b8-a68a-019a4d2fb285
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Seems like all celery tasks are failing for different reasons except the chart
|
||||
updates.
|
||||
|
||||
*** Errors
|
||||
**** scrobbles.tasks.send_notification_for_in_progress
|
||||
#+begin_src bash
|
||||
KeyError: 'track'
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 89, in _execute
|
||||
return self.cursor.execute(sql, params)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
psycopg2.errors.UndefinedColumn: column music_track.artist_id does not exist
|
||||
LINE 1: ..."."title", "music_track"."base_run_time_seconds", "music_tra...
|
||||
^
|
||||
HINT: Perhaps you meant to reference the column "music_track.artist_fk_id".
|
||||
#+end_src
|
||||
|
||||
**** scrobbles.tasks.import_from_webdav_all_users
|
||||
#+begin_src bash
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/importers/webdav.py", line 166, in scan_webdav_for_koreader
|
||||
if last_import and last_import.webdav_etag and remote_etag:
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
AttributeError: 'KoReaderImport' object has no attribute 'webdav_etag'
|
||||
#+end_src
|
||||
|
||||
**** scrobbles.tasks.process_bgstats_import
|
||||
#+begin_src bash
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 89, in _execute
|
||||
return self.cursor.execute(sql, params)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
psycopg2.errors.NotNullViolation: null value in column "visibility" of relation "scrobbles_scrobble" violates not-null constraint
|
||||
DETAIL: Failing row contains (374463, 2026-06-11 13:27:06.528319+00, 2026-06-11 13:27:06.52834+00, 2026-06-11 13:17:34+00, 180, f, f, BG Stats, 1, null, t, {"players": [{"new": false, "win": false, "rank": 0, "role": "",..., null, null, null, 8e73ceec-b731-4623-9637-712bbf9f76ce, null, null, null, null, , null, BoardGame, 324, null, null, America/New_York, null, null, , null, null, , null, null, null, null, null, null, null, null, null, null, null).
|
||||
#+end_src
|
||||
|
||||
* Version 50.1 [1/1]
|
||||
** DONE [#B] Fix bug in charts where only #1 is displayed :charts:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 7136dffb-e6b7-184b-48ac-bb09bae0b0f0
|
||||
:END:
|
||||
|
||||
* Version 50.0 [2/2]
|
||||
** DONE [#A] Allow updating all a user's scrobble visibility at once :scrobbles:sharing:feature:
|
||||
:PROPERTIES:
|
||||
:ID: 9ed2ec65-bf69-4300-965c-6a7d3ef7ea03
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
We now have the ability to share or unshare scrobbles and create private links. We should add a toggle
|
||||
in the user's settings that will bulk make all their scrobbles public or private, so that a user
|
||||
can either share everything, or lock their account down.
|
||||
We now have the ability to share or unshare scrobbles and create private links.
|
||||
We should add a toggle in the user's settings that will bulk make all their
|
||||
scrobbles public or private, so that a user can either share everything, or lock
|
||||
their account down.
|
||||
|
||||
This should not affect scrobbles that are in the "Shared" visibility state.
|
||||
|
||||
And users should be able to also control whether all scrobbles of a specific
|
||||
type are shared or not. Maybe this could be a JSONField in profile that contains
|
||||
a media_type key with a visibility type for a value, and if it's not present,
|
||||
sharing defaults to private?
|
||||
|
||||
Additionally, users's should have links in their settings to see what scrobbles
|
||||
are either public, shared or private. Probably this could be done with a
|
||||
?visbility=<> filter on the /scrobbles/ page.
|
||||
|
||||
*** Changes
|
||||
|
||||
- Added `media_type_visibility` JSONField to UserProfile (migration 0038)
|
||||
- Created `BulkVisibilityView` at `/settings/visibility/` with:
|
||||
- Radio toggle to make all non-shared scrobbles Public or Private
|
||||
- Per-media-type dropdown for each of the 20 media types (inherit/public/shared/private)
|
||||
- Created `BulkVisibilityForm` with dynamic media_type fields
|
||||
- Created `profiles/visibility_settings.html` template with visibility stats + filter links
|
||||
- Added link from main settings page to visibility settings
|
||||
- Added `?visibility=` filter support to `ScrobbleListView` (public/shared/private)
|
||||
- Added filter indicator to `scrobble_all_list.html`
|
||||
- Updated `Scrobble.create()` to check `user.profile.media_type_visibility` for media-type-specific defaults before falling back to PRIVATE
|
||||
|
||||
|
||||
|
||||
** DONE [#A] Replace columsn of Top Artists, Tracks and Series with Maloja widget :templates:charts:
|
||||
:PROPERTIES:
|
||||
:ID: 3946afb1-932c-46fe-a188-f4c9add1a491
|
||||
:END:
|
||||
|
||||
|
||||
*** Description
|
||||
|
||||
The tables are fine, but Maloja widgets are better. We should drop the top track table, add top albums
|
||||
and replace top artists and top tv series with the Maloja style widgets.
|
||||
|
||||
|
||||
* Version 49.1 [1/1]
|
||||
** DONE [#A] Fix bug with missing default visbility for scrobbles :bug:scrobbles:sharing:
|
||||
:PROPERTIES:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "49.1"
|
||||
version = "51.2"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -103,6 +103,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
"source": "Lichess",
|
||||
"timezone": user.profile.timezone,
|
||||
"log": log_data,
|
||||
"visibility": "private",
|
||||
}
|
||||
if commit:
|
||||
Scrobble.objects.create(**scrobble_dict)
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
from books.constants import BOOKS_TITLES_TO_IGNORE
|
||||
@ -174,7 +173,7 @@ def build_book_map(rows) -> dict:
|
||||
return book_id_map
|
||||
|
||||
|
||||
def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
||||
def build_page_data(page_rows: list, book_map: dict) -> dict:
|
||||
"""Given rows of page data from KoReader, parse each row and build
|
||||
scrobbles for our user, loading the page data into the page_data
|
||||
field on the scrobble instance.
|
||||
@ -187,18 +186,20 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
||||
book_ids_not_found.append(koreader_book_id)
|
||||
continue
|
||||
|
||||
if "pages" not in book_map[koreader_book_id].keys():
|
||||
book_map[koreader_book_id]["pages"] = {}
|
||||
book_map[koreader_book_id].setdefault("pages", [])
|
||||
|
||||
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
||||
duration = page_row[KoReaderPageStatColumn.DURATION.value]
|
||||
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
||||
|
||||
book_map[koreader_book_id]["pages"][page_number] = {
|
||||
"duration": duration,
|
||||
"start_ts": start_ts,
|
||||
"end_ts": start_ts + duration,
|
||||
}
|
||||
book_map[koreader_book_id]["pages"].append(
|
||||
{
|
||||
"page_number": page_number,
|
||||
"duration": duration,
|
||||
"start_ts": start_ts,
|
||||
"end_ts": start_ts + duration,
|
||||
}
|
||||
)
|
||||
if book_ids_not_found:
|
||||
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
|
||||
return book_map
|
||||
@ -216,7 +217,6 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
pages_not_found.append(book_id)
|
||||
continue
|
||||
|
||||
should_create_scrobble = False
|
||||
scrobble_page_data = {}
|
||||
playback_position_seconds = 0
|
||||
prev_page_stats = {}
|
||||
@ -225,11 +225,12 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
pages_processed = 0
|
||||
total_pages_read = len(book_map[koreader_book_id]["pages"])
|
||||
ordered_pages = sorted(
|
||||
book_map[koreader_book_id]["pages"].items(),
|
||||
key=lambda x: x[1]["start_ts"],
|
||||
book_map[koreader_book_id]["pages"],
|
||||
key=lambda x: x["start_ts"],
|
||||
)
|
||||
|
||||
for cur_page_number, stats in ordered_pages:
|
||||
for stats in ordered_pages:
|
||||
page_number = stats["page_number"]
|
||||
pages_processed += 1
|
||||
|
||||
seconds_from_last_page = 0
|
||||
@ -243,46 +244,52 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
)
|
||||
|
||||
end_of_reading = pages_processed == total_pages_read
|
||||
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
|
||||
big_jump_to_this_page = (page_number - last_page_number) > 10
|
||||
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
||||
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
|
||||
should_create_scrobble = True
|
||||
should_create_scrobble = (
|
||||
is_session_gap and not big_jump_to_this_page
|
||||
) or end_of_reading
|
||||
|
||||
# Always accumulate the current page first
|
||||
scrobble_page_data[page_number] = stats
|
||||
|
||||
if should_create_scrobble:
|
||||
# For end-of-reading, the current page is already accumulated
|
||||
# and belongs in this scrobble. For a session gap, remove the
|
||||
# current page from this scrobble — it starts a new session.
|
||||
if is_session_gap and not end_of_reading:
|
||||
del scrobble_page_data[page_number]
|
||||
|
||||
scrobble_page_data = dict(
|
||||
sorted(
|
||||
scrobble_page_data.items(),
|
||||
key=lambda x: x[1]["start_ts"],
|
||||
)
|
||||
)
|
||||
try:
|
||||
first_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[0]
|
||||
)
|
||||
last_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[-1]
|
||||
)
|
||||
except IndexError:
|
||||
|
||||
if not scrobble_page_data:
|
||||
logger.error(
|
||||
"Could not process book, no page data found",
|
||||
extra={"scrobble_page_data": scrobble_page_data},
|
||||
)
|
||||
continue
|
||||
|
||||
first_page = next(iter(scrobble_page_data.values()))
|
||||
last_page = scrobble_page_data[
|
||||
list(scrobble_page_data.keys())[-1]
|
||||
]
|
||||
|
||||
timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(int(first_page.get("start_ts")))
|
||||
datetime.fromtimestamp(
|
||||
int(first_page.get("start_ts"))
|
||||
)
|
||||
)
|
||||
stop_timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(int(last_page.get("end_ts")))
|
||||
datetime.fromtimestamp(
|
||||
int(last_page.get("end_ts"))
|
||||
)
|
||||
)
|
||||
|
||||
# Adjust for Daylight Saving Time
|
||||
# if timestamp.dst() == timedelta(
|
||||
# 0
|
||||
# ) or stop_timestamp.dst() == timedelta(0):
|
||||
# timestamp = timestamp - timedelta(hours=1)
|
||||
# stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||
|
||||
scrobble = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
book_id=book_id,
|
||||
@ -291,7 +298,7 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
|
||||
if not scrobble:
|
||||
logger.info(
|
||||
f"Queueing scrobble for {book_id}, page {cur_page_number}"
|
||||
f"Queueing scrobble for {book_id}, page {page_number}"
|
||||
)
|
||||
log_data = {
|
||||
"koreader_hash": book_dict.get("hash"),
|
||||
@ -318,16 +325,18 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
timezone=tz,
|
||||
)
|
||||
)
|
||||
# Then start over
|
||||
should_create_scrobble = False
|
||||
# Then start over for the next session
|
||||
playback_position_seconds = 0
|
||||
scrobble_page_data = {}
|
||||
|
||||
# We accumulate pages for the scrobble until we should create a new one
|
||||
scrobble_page_data[cur_page_number] = stats
|
||||
# For session gaps, re-add the current page as the
|
||||
# beginning of the next session's accumulation
|
||||
if is_session_gap and not end_of_reading:
|
||||
scrobble_page_data[page_number] = stats
|
||||
|
||||
last_page_number = cur_page_number
|
||||
last_page_number = page_number
|
||||
prev_page_stats = stats
|
||||
|
||||
if pages_not_found:
|
||||
logger.info(f"Pages not found for books: {set(pages_not_found)}")
|
||||
return scrobbles_to_create
|
||||
@ -357,9 +366,6 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
|
||||
new_scrobbles = []
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
tz = ZoneInfo("UTC")
|
||||
if user:
|
||||
tz = user.profile.tzinfo
|
||||
|
||||
is_os_file = "https://" not in file_path
|
||||
if is_os_file:
|
||||
@ -375,7 +381,6 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
book_map = build_page_data(
|
||||
cur.execute("SELECT * from page_stat_data ORDER BY id_book, start_time"),
|
||||
book_map,
|
||||
tz,
|
||||
)
|
||||
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||
else:
|
||||
@ -392,7 +397,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
_sqlite_bytes(file_path), max_buffer_size=1_048_576
|
||||
):
|
||||
if table_name == "page_stat_data":
|
||||
book_map = build_page_data(rows, book_map, tz)
|
||||
book_map = build_page_data(rows, book_map)
|
||||
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||
|
||||
logger.info(f"Creating {len(new_scrobbles)} new scrobbles")
|
||||
|
||||
282
vrobbler/apps/books/management/commands/cleanup_book_metadata.py
Normal file
282
vrobbler/apps/books/management/commands/cleanup_book_metadata.py
Normal file
@ -0,0 +1,282 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from books.constants import READCOMICSONLINE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MISSING_ALL = [
|
||||
"cover",
|
||||
"summary",
|
||||
"isbn",
|
||||
"pages",
|
||||
"language",
|
||||
"publisher",
|
||||
"publish_year",
|
||||
]
|
||||
|
||||
MISSING_GROUPS = {
|
||||
"cover": lambda b: not bool(b.cover),
|
||||
"summary": lambda b: not b.summary,
|
||||
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
|
||||
"pages": lambda b: b.pages is None,
|
||||
"language": lambda b: not b.language,
|
||||
"publisher": lambda b: not b.publisher,
|
||||
"publish_year": lambda b: b.first_publish_year is None,
|
||||
}
|
||||
|
||||
|
||||
def _book_matches(book, flags):
|
||||
if not flags:
|
||||
return False
|
||||
for flag in flags:
|
||||
fn = MISSING_GROUPS.get(flag)
|
||||
if fn and fn(book):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill missing metadata on books from Google Books, OpenLibrary, and ComicVine"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of books to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Seconds to sleep between API calls (default: 0.5)",
|
||||
)
|
||||
for flag in MISSING_ALL:
|
||||
parser.add_argument(
|
||||
f"--missing-{flag}",
|
||||
dest="missing_flags",
|
||||
action="append_const",
|
||||
const=flag,
|
||||
help=f"Process books missing {flag}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--comics-only",
|
||||
action="store_true",
|
||||
help="Only process books with a readcomicsonline.ru URL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
dest="all_missing",
|
||||
help="Process books missing any metadata field",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from books.models import Book
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
sleep_secs = options["sleep"]
|
||||
flags = options.get("missing_flags") or []
|
||||
comics_only = options["comics_only"]
|
||||
all_missing = options["all_missing"]
|
||||
|
||||
if all_missing:
|
||||
flags = MISSING_ALL
|
||||
|
||||
if not flags and not comics_only:
|
||||
self.stdout.write(
|
||||
"No filters specified. Use --all, --missing-*, or --comics-only."
|
||||
)
|
||||
return
|
||||
|
||||
qs = Book.objects.all()
|
||||
if comics_only:
|
||||
qs = qs.filter(readcomics_url__isnull=False)
|
||||
|
||||
if flags:
|
||||
qs = [b for b in qs.iterator() if _book_matches(b, flags)]
|
||||
else:
|
||||
qs = list(qs)
|
||||
|
||||
total = len(qs)
|
||||
self.stdout.write(f"Found {total} books to process")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no API calls will be made. Use --commit to run lookups."
|
||||
)
|
||||
return
|
||||
|
||||
enriched = 0
|
||||
skipped = 0
|
||||
stats = {
|
||||
"cover_fixed": 0,
|
||||
"summary_fixed": 0,
|
||||
"isbn_fixed": 0,
|
||||
"pages_fixed": 0,
|
||||
"language_fixed": 0,
|
||||
"publisher_fixed": 0,
|
||||
"publish_year_fixed": 0,
|
||||
}
|
||||
|
||||
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
|
||||
batch = qs[offset : offset + batch_size]
|
||||
for book in batch:
|
||||
result = self._enrich_book(book, sleep_secs)
|
||||
if result:
|
||||
enriched += 1
|
||||
for key in stats:
|
||||
if result.get(key):
|
||||
stats[key] += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
||||
f"enriched: {enriched}, skipped: {skipped}"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Books enriched: {enriched}\n"
|
||||
f" Books skipped: {skipped}\n"
|
||||
f" Covers fixed: {stats['cover_fixed']}\n"
|
||||
f" Summaries fixed:{stats['summary_fixed']}\n"
|
||||
f" ISBNs fixed: {stats['isbn_fixed']}\n"
|
||||
f" Pages fixed: {stats['pages_fixed']}\n"
|
||||
f" Languages fixed:{stats['language_fixed']}\n"
|
||||
f" Publishers fixed:{stats['publisher_fixed']}\n"
|
||||
f" Publish yrs fixed: {stats['publish_year_fixed']}"
|
||||
)
|
||||
|
||||
def _enrich_book(self, book, sleep_secs):
|
||||
from books.sources.comicvine import lookup_comic_from_comicvine
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.openlibrary import lookup_book_from_openlibrary as lookup_book_from_ol
|
||||
|
||||
title = book.original_title or book.title
|
||||
author_name = book.author.name if book.author else None
|
||||
book_dict = {}
|
||||
|
||||
is_comic = bool(book.readcomics_url) or (
|
||||
book.issue_number is not None or book.volume_number is not None
|
||||
)
|
||||
if is_comic and READCOMICSONLINE_URL in (book.readcomics_url or ""):
|
||||
cv_data = lookup_comic_from_comicvine(title)
|
||||
if cv_data:
|
||||
book_dict.update(cv_data)
|
||||
|
||||
ol_data = lookup_book_from_ol(title, author=author_name)
|
||||
time.sleep(sleep_secs)
|
||||
google_data = lookup_book_from_google(title)
|
||||
|
||||
if ol_data:
|
||||
for k, v in ol_data.items():
|
||||
book_dict.setdefault(k, v)
|
||||
|
||||
if google_data:
|
||||
for k, v in google_data.items():
|
||||
if v:
|
||||
book_dict.setdefault(k, v)
|
||||
|
||||
if not book_dict:
|
||||
return None
|
||||
|
||||
changed = self._apply(book, book_dict, title)
|
||||
return changed
|
||||
|
||||
def _apply(self, book, data, title):
|
||||
changed = {
|
||||
"cover_fixed": False,
|
||||
"summary_fixed": False,
|
||||
"isbn_fixed": False,
|
||||
"pages_fixed": False,
|
||||
"language_fixed": False,
|
||||
"publisher_fixed": False,
|
||||
"publish_year_fixed": False,
|
||||
}
|
||||
|
||||
update_fields = []
|
||||
|
||||
cover_url = data.pop("cover_url", "")
|
||||
|
||||
if data.get("summary") and not book.summary:
|
||||
book.summary = data["summary"]
|
||||
update_fields.append("summary")
|
||||
changed["summary_fixed"] = True
|
||||
|
||||
if data.get("isbn_13") and not book.isbn_13:
|
||||
book.isbn_13 = data["isbn_13"]
|
||||
update_fields.append("isbn_13")
|
||||
changed["isbn_fixed"] = True
|
||||
|
||||
if data.get("isbn_10") and not book.isbn_10:
|
||||
book.isbn_10 = data["isbn_10"]
|
||||
update_fields.append("isbn_10")
|
||||
changed["isbn_fixed"] = True
|
||||
|
||||
if data.get("pages") and book.pages is None:
|
||||
book.pages = data["pages"]
|
||||
update_fields.append("pages")
|
||||
changed["pages_fixed"] = True
|
||||
|
||||
if data.get("language") and not book.language:
|
||||
book.language = data["language"]
|
||||
update_fields.append("language")
|
||||
changed["language_fixed"] = True
|
||||
|
||||
if data.get("publisher") and not book.publisher:
|
||||
book.publisher = data["publisher"]
|
||||
update_fields.append("publisher")
|
||||
changed["publisher_fixed"] = True
|
||||
|
||||
if data.get("first_publish_year") and book.first_publish_year is None:
|
||||
book.first_publish_year = data["first_publish_year"]
|
||||
update_fields.append("first_publish_year")
|
||||
changed["publish_year_fixed"] = True
|
||||
|
||||
if data.get("openlibrary_id") and not book.openlibrary_id:
|
||||
book.openlibrary_id = data["openlibrary_id"]
|
||||
update_fields.append("openlibrary_id")
|
||||
|
||||
if data.get("comicvine_id") and not book.comicvine_id:
|
||||
book.comicvine_id = data["comicvine_id"]
|
||||
update_fields.append("comicvine_id")
|
||||
|
||||
if data.get("issue_number") and book.issue_number is None:
|
||||
book.issue_number = data["issue_number"]
|
||||
update_fields.append("issue_number")
|
||||
|
||||
if data.get("volume_number") and book.volume_number is None:
|
||||
book.volume_number = data["volume_number"]
|
||||
update_fields.append("volume_number")
|
||||
|
||||
if update_fields:
|
||||
book.save(update_fields=update_fields)
|
||||
self.stdout.write(f" [ENRICHED] {book} — {', '.join(update_fields)}")
|
||||
|
||||
if cover_url and not book.cover:
|
||||
book.save_image_from_url(cover_url)
|
||||
if book.cover:
|
||||
changed["cover_fixed"] = True
|
||||
self.stdout.write(f" [COVER] {book} — cover saved from source")
|
||||
|
||||
genres = data.pop("genres", data.pop("generes", []))
|
||||
if genres:
|
||||
existing = set(book.genre.names())
|
||||
new_genres = [g for g in genres if g not in existing]
|
||||
if new_genres:
|
||||
book.genre.add(*new_genres)
|
||||
self.stdout.write(f" [GENRES] {book} — added {len(new_genres)} genres")
|
||||
|
||||
return changed if any(changed.values()) else None
|
||||
@ -0,0 +1,130 @@
|
||||
import logging
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from books.koreader import SESSION_GAP_SECONDS, fix_long_play_stats_for_scrobbles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SESSION_GAP = timedelta(seconds=SESSION_GAP_SECONDS)
|
||||
|
||||
|
||||
def _page_data_keys(pages):
|
||||
return sorted(int(k) for k in (pages or {}))
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Merge orphaned 1-page KOReader scrobbles into the preceding scrobble"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
commit = options["commit"]
|
||||
|
||||
qs = Scrobble.objects.filter(
|
||||
media_type="Book", source="KOReader"
|
||||
).order_by("book_id", "timestamp")
|
||||
|
||||
if not qs.exists():
|
||||
self.stdout.write("No KOReader book scrobbles found.")
|
||||
return
|
||||
|
||||
merged = 0
|
||||
affected_books = set()
|
||||
|
||||
# Group by book_id manually since we're iterating in order
|
||||
book_scrobbles = {}
|
||||
for s in qs:
|
||||
book_scrobbles.setdefault(s.book_id, []).append(s)
|
||||
|
||||
if not commit:
|
||||
self.stdout.write("Dry run — no changes will be saved. Use --commit to apply.")
|
||||
|
||||
for book_id, scrobbles in book_scrobbles.items():
|
||||
batch_merged = 0
|
||||
i = 0
|
||||
while i < len(scrobbles) - 1:
|
||||
current = scrobbles[i]
|
||||
orphan = scrobbles[i + 1]
|
||||
|
||||
orphan_pages = orphan.logdata.page_data if orphan.logdata else {}
|
||||
orphan_keys = _page_data_keys(orphan_pages)
|
||||
if len(orphan_keys) != 1:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
current_pages = current.logdata.page_data if current.logdata else {}
|
||||
current_keys = _page_data_keys(current_pages)
|
||||
if not current_keys:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
orphan_page_num = orphan_keys[0]
|
||||
current_last_page = current_keys[-1]
|
||||
|
||||
if orphan_page_num != current_last_page + 1:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check that the orphan is close enough in time
|
||||
gap = orphan.timestamp - current.stop_timestamp
|
||||
if gap > SESSION_GAP:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Merge orphan into current
|
||||
current_pages[str(orphan_page_num)] = orphan_pages[str(orphan_page_num)]
|
||||
current.log["page_data"] = current_pages
|
||||
current.log["pages_read"] = len(current_pages)
|
||||
current.stop_timestamp = orphan.stop_timestamp
|
||||
current.playback_position_seconds += orphan.playback_position_seconds
|
||||
|
||||
affected_books.add(book_id)
|
||||
|
||||
if commit:
|
||||
with transaction.atomic():
|
||||
current.save(
|
||||
update_fields=[
|
||||
"log",
|
||||
"stop_timestamp",
|
||||
"playback_position_seconds",
|
||||
]
|
||||
)
|
||||
orphan.delete()
|
||||
|
||||
merged += 1
|
||||
batch_merged += 1
|
||||
scrobbles.pop(i + 1)
|
||||
|
||||
if batch_merged:
|
||||
self.stdout.write(
|
||||
f" Book {book_id}: merged {batch_merged} orphan scrobble(s)"
|
||||
)
|
||||
|
||||
self.stdout.write(f"\nTotal orphans merged: {merged}")
|
||||
|
||||
if commit and affected_books:
|
||||
self.stdout.write("Recalculating long_play_stats for affected books...")
|
||||
for book_id in affected_books:
|
||||
scrobbles_to_fix = (
|
||||
Scrobble.objects.filter(book_id=book_id, source="KOReader")
|
||||
.order_by("timestamp")
|
||||
)
|
||||
fix_long_play_stats_for_scrobbles(list(scrobbles_to_fix))
|
||||
|
||||
self.stdout.write(f"Fixed stats for {len(affected_books)} books.")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
f"\nWould merge {merged} orphan scrobble(s) across "
|
||||
f"{len(affected_books)} book(s)."
|
||||
)
|
||||
@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
from books.constants import MediaSourceTag, READCOMICSONLINE_URL
|
||||
@ -71,7 +72,7 @@ class BookLogData(BaseLogData, LongPlayLogData):
|
||||
_excluded_fields = {"koreader_hash", "page_data"}
|
||||
|
||||
def avg_seconds_per_page(self):
|
||||
if self.page_data:
|
||||
if self.page_data and isinstance(self.page_data, dict):
|
||||
total_duration = 0
|
||||
for page_num, stats in self.page_data.items():
|
||||
total_duration += stats.get("duration", 0)
|
||||
@ -173,6 +174,9 @@ class Book(LongPlayScrobblableMixin):
|
||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||
|
||||
def __str__(self) -> str:
|
||||
if not self.subtitle:
|
||||
return self.title
|
||||
|
||||
return f"{self.title} - {self.subtitle}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@ -184,12 +188,18 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
subtitle = self.author
|
||||
subtitle_parts = []
|
||||
if self.author:
|
||||
subtitle_parts.append(self.author.name)
|
||||
if self.issue_number and "Issue" not in str(self.title):
|
||||
subtitle += " - Issue {self.issue_number}"
|
||||
subtitle_parts.append(f"Issue {self.issue_number}")
|
||||
if self.volume_number and "Volume" not in str(self.title):
|
||||
subtitle += " - Volume {self.volume_number}"
|
||||
return subtitle
|
||||
subtitle_parts.append(f"Volume {self.volume_number}")
|
||||
if len(subtitle_parts) > 1:
|
||||
return " / ".join(subtitle_parts)
|
||||
if len(subtitle_parts) == 1:
|
||||
return subtitle_parts[0]
|
||||
return ""
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
@ -219,36 +229,20 @@ class Book(LongPlayScrobblableMixin):
|
||||
) -> "Book":
|
||||
book, created = cls.objects.get_or_create(title=title)
|
||||
|
||||
if not created:
|
||||
if not created and not overwrite:
|
||||
return book
|
||||
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
if not book_dict:
|
||||
return book
|
||||
|
||||
if created or overwrite:
|
||||
author_list = []
|
||||
author_dicts = book_dict.pop("author_dicts")
|
||||
if author_dicts:
|
||||
for author_dict in author_dicts:
|
||||
if author_dict.get("authorId"):
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
semantic_id=author_dict.get("authorId")
|
||||
)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
author.name = author_dict.get("name")
|
||||
author.save()
|
||||
# TODO enrich author?
|
||||
...
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
book.save()
|
||||
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
book.save()
|
||||
|
||||
if author_list:
|
||||
book.authors.add(*author_list)
|
||||
genres = book_dict.pop("genres", [])
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
genres = book_dict.get("genres", [])
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
return book
|
||||
|
||||
@classmethod
|
||||
@ -286,20 +280,27 @@ class Book(LongPlayScrobblableMixin):
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
if book_dict:
|
||||
source_tag = MediaSourceTag.COMICVINE
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
book_dict["next_readcomics_url"] = next_url_if_exists(
|
||||
book_dict["readcomics_url"]
|
||||
)
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
book_dict["next_readcomics_url"] = next_url_if_exists(
|
||||
book_dict["readcomics_url"]
|
||||
)
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_ol(title, author=author)
|
||||
if book_dict:
|
||||
book_dict = {}
|
||||
ol_data = lookup_book_from_ol(title, author=author)
|
||||
google_data = lookup_book_from_google(title)
|
||||
|
||||
if ol_data:
|
||||
book_dict.update(ol_data)
|
||||
source_tag = MediaSourceTag.OPENLIBRARY
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_google(title)
|
||||
if book_dict:
|
||||
if google_data:
|
||||
for k, v in google_data.items():
|
||||
if v:
|
||||
book_dict.setdefault(k, v)
|
||||
source_tag = MediaSourceTag.GOOGLE_BOOKS
|
||||
if ol_data and ol_data.get("cover_url"):
|
||||
book_dict["cover_url"] = ol_data["cover_url"]
|
||||
|
||||
if not book_dict:
|
||||
logger.warning(
|
||||
@ -368,7 +369,6 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
if not data and COMICVINE_API_KEY:
|
||||
logger.warn(f"Checking ComicVine for {self.title}")
|
||||
cv_client = ComicVineClient(api_key=COMICVINE_API_KEY)
|
||||
data = lookup_comic_from_comicvine(str(self.title))
|
||||
|
||||
if not data:
|
||||
@ -463,8 +463,11 @@ class Book(LongPlayScrobblableMixin):
|
||||
if scrobble.logdata.page_data:
|
||||
for page, data in scrobble.logdata.page_data.items():
|
||||
if convert_timestamps:
|
||||
data["start_ts"] = datetime.fromtimestamp(data["start_ts"])
|
||||
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
|
||||
tz = None
|
||||
if scrobble.timezone:
|
||||
tz = ZoneInfo(scrobble.timezone)
|
||||
data["start_ts"] = datetime.fromtimestamp(data["start_ts"], tz=tz)
|
||||
data["end_ts"] = datetime.fromtimestamp(data["end_ts"], tz=tz)
|
||||
pages[page] = data
|
||||
sorted_pages = OrderedDict(
|
||||
sorted(pages.items(), key=lambda x: x[1]["start_ts"])
|
||||
|
||||
@ -18,7 +18,7 @@ class ComicVineClient(object):
|
||||
"""
|
||||
|
||||
# All API requests made by this client will be made to this URL.
|
||||
API_URL = "https://www.comicvine.com/api/search/"
|
||||
API_URL = "https://comicvine.gamespot.com/api/search/"
|
||||
|
||||
# A valid User-Agent header must be set in order for our API requests to
|
||||
# be accepted, otherwise our request will be rejected with a
|
||||
@ -41,15 +41,12 @@ class ComicVineClient(object):
|
||||
"volume",
|
||||
}
|
||||
|
||||
def __init__(self, api_key, expire_after=300):
|
||||
def __init__(self, api_key):
|
||||
"""
|
||||
Store the API key in a class variable, and install the requests cache,
|
||||
configuring it using the ``expire_after`` parameter.
|
||||
Store the API key in a class variable.
|
||||
|
||||
:param api_key: Your personal ComicVine API key.
|
||||
:type api_key: str
|
||||
:param expire_after: The number of seconds to retain an entry in cache.
|
||||
:type expire_after: int or None
|
||||
"""
|
||||
|
||||
self.api_key = api_key
|
||||
@ -109,14 +106,17 @@ class ComicVineClient(object):
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
return {
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
"limit": min(10, limit), # hard limit of 10
|
||||
"offset": max(0, offset), # cannot provide negative offset
|
||||
"query": query,
|
||||
"resources": self._validate_resources(resources),
|
||||
}
|
||||
validated = self._validate_resources(resources)
|
||||
if validated:
|
||||
params["resources"] = validated
|
||||
return params
|
||||
|
||||
def _validate_resources(self, resources):
|
||||
"""
|
||||
@ -141,33 +141,35 @@ class ComicVineClient(object):
|
||||
def _query_api(self, params):
|
||||
"""
|
||||
Query the ComicVine API's ``search`` resource, providing the required
|
||||
headers and parameters with the request. Optionally allow the caller
|
||||
of the function to disable the request cache.
|
||||
headers and parameters with the request.
|
||||
|
||||
If an error occurs during the request, handle it accordingly. Upon
|
||||
success, return the JSON from the response.
|
||||
|
||||
:param params: Parameters to include with the request.
|
||||
:type params: dict
|
||||
:param use_cache: Toggle the use of requests_cache.
|
||||
:type use_cache: bool
|
||||
|
||||
:return: The JSON contained in the response.
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
# Since we're performing the identical action regardless of whether
|
||||
# or not the request cache is to be used, store the procedure in a
|
||||
# local function to avoid repetition.
|
||||
def __httpget():
|
||||
response = requests.get(self.API_URL, headers=self.HEADERS, params=params)
|
||||
response = requests.get(self.API_URL, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
return response.json()
|
||||
json_data = response.json()
|
||||
|
||||
return __httpget()
|
||||
if json_data.get("status_code") != 1:
|
||||
error_msg = json_data.get("error", "Unknown ComicVine API error")
|
||||
logger.error(
|
||||
"ComicVine API returned status_code %s: %s",
|
||||
json_data.get("status_code"),
|
||||
error_msg,
|
||||
)
|
||||
return {}
|
||||
|
||||
return json_data
|
||||
|
||||
def _handle_http_error(self, response):
|
||||
"""
|
||||
@ -200,10 +202,8 @@ def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
original_title = title
|
||||
|
||||
issue_number = None
|
||||
volume_nubmer = None
|
||||
resource_type = "issue"
|
||||
if "Issue " in title:
|
||||
resource_type = "issue"
|
||||
issue_number = title.split("Issue ")[1]
|
||||
volume_number = None
|
||||
if "Volume " in title:
|
||||
@ -215,48 +215,49 @@ def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
logger.warning("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
client = ComicVineClient(api_key=getattr(settings, "COMICVINE_API_KEY", None))
|
||||
client = ComicVineClient(api_key=api_key)
|
||||
|
||||
raw_results = client.search(title).get("results")
|
||||
results = [r for r in raw_results if r.get("resource_type") == resource_type]
|
||||
raw_results = client.search(title)
|
||||
if not raw_results:
|
||||
return {}
|
||||
results = raw_results.get("results", [])
|
||||
results = [r for r in results if r.get("resource_type") == resource_type]
|
||||
if not results:
|
||||
logger.warning("No comic found on ComicVine")
|
||||
return {}
|
||||
|
||||
found_result = None
|
||||
for result in results:
|
||||
if result.get("issue_number") == str(issue_number):
|
||||
if issue_number is not None and result.get("issue_number") == str(issue_number):
|
||||
found_result = result
|
||||
break
|
||||
if result.get("volume_number") == str(volume_number):
|
||||
if volume_number is not None and result.get("volume_number") == str(volume_number):
|
||||
found_result = result
|
||||
break
|
||||
|
||||
if not found_result:
|
||||
found_result = results[0]
|
||||
|
||||
logger.info("ComicVine results", extra={"results": results})
|
||||
|
||||
if not found_result:
|
||||
logger.warning("No matches found on ComicVine")
|
||||
return {}
|
||||
|
||||
title = found_result.get("name")
|
||||
|
||||
if found_result.get("volume"):
|
||||
title = found_result.get("volume").get("name")
|
||||
|
||||
cover_url = None
|
||||
if found_result.get("image"):
|
||||
cover_url = found_result["image"].get("original_url")
|
||||
|
||||
data_dict = {
|
||||
"title": title,
|
||||
"original_title": original_title,
|
||||
"issue_number": found_result.get("issue_number"),
|
||||
"volume_number": found_result.get("volume_number"),
|
||||
"cover_url": found_result.get("image").get("original_url"),
|
||||
"cover_url": cover_url,
|
||||
"comicvine_id": found_result.get("id"),
|
||||
"comicvine_data": found_result,
|
||||
"summary": found_result.get("description"),
|
||||
"publish_date": found_result.get("cover_date"),
|
||||
"first_publish_year": found_result.get("cover_date", "")[:4],
|
||||
"first_publish_year": (found_result.get("cover_date") or "")[:4],
|
||||
}
|
||||
|
||||
return data_dict
|
||||
|
||||
@ -26,8 +26,6 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
if not google_result:
|
||||
return {}
|
||||
|
||||
publish_date = pendulum.parse(google_result.get("publishedDate"))
|
||||
|
||||
isbn_13 = ""
|
||||
isbn_10 = ""
|
||||
for ident in google_result.get("industryIdentifiers", []):
|
||||
@ -35,25 +33,25 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
isbn_13 = ident.get("identifier")
|
||||
if ident.get("type") == "ISBN_10":
|
||||
isbn_10 = ident.get("identifier")
|
||||
# TODO this may lead to issues with the first get if Google changes our title
|
||||
# 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_dict["subtitle"] = google_result.get("subtitle")
|
||||
book_dict["authors"] = google_result.get("authors")
|
||||
book_dict["publisher"] = google_result.get("publisher")
|
||||
book_dict["first_publish_year"] = publish_date.year
|
||||
book_dict["pages"] = google_result.get("pageCount")
|
||||
book_dict["isbn_13"] = isbn_13
|
||||
book_dict["isbn_10"] = isbn_10
|
||||
book_dict["publish_date"] = google_result.get("publishedDate")
|
||||
if len(book_dict["publish_date"]) == 4:
|
||||
book_dict["publish_date"] = f"{book_dict['publish_date']}-1-1"
|
||||
book_dict["language"] = google_result.get("language")
|
||||
book_dict["summary"] = google_result.get("description")
|
||||
book_dict["genres"] = google_result.get("categories")
|
||||
|
||||
raw_date = google_result.get("publishedDate")
|
||||
if raw_date:
|
||||
try:
|
||||
publish_date = pendulum.parse(raw_date)
|
||||
book_dict["first_publish_year"] = publish_date.year
|
||||
except Exception:
|
||||
pass
|
||||
book_dict["publish_date"] = raw_date
|
||||
if len(raw_date) == 4:
|
||||
book_dict["publish_date"] = f"{raw_date}-1-1"
|
||||
book_dict["cover_url"] = (
|
||||
google_result.get("imageLinks", {})
|
||||
.get("thumbnail", "")
|
||||
|
||||
@ -32,8 +32,6 @@ class KoReaderBookRows:
|
||||
DEFAULT_STR = "N/A"
|
||||
DEFAULT_INT = 0
|
||||
DEFAULT_TIME = 1703800469
|
||||
BOOK_ROWS = []
|
||||
PAGE_STATS_ROWS = []
|
||||
|
||||
def _gen_random_row(self, i):
|
||||
wiggle = random.randrange(15)
|
||||
@ -110,6 +108,8 @@ class KoReaderBookRows:
|
||||
end_session = True
|
||||
|
||||
def __init__(self, book_count=0, **kwargs):
|
||||
self.BOOK_ROWS = []
|
||||
self.PAGE_STATS_ROWS = []
|
||||
self._generate_random_book_rows(book_count)
|
||||
self._generate_custom_book_row(**kwargs)
|
||||
self._generate_random_page_stats_rows()
|
||||
|
||||
@ -44,7 +44,10 @@ def test_build_scrobbles_from_pages(get_mock, koreader_rows, demo_user, valid_re
|
||||
book_map = build_page_data(koreader_rows.PAGE_STATS_ROWS, book_map)
|
||||
|
||||
scrobbles = build_scrobbles_from_book_map(book_map, demo_user)
|
||||
# Corresponds to number of sessions per book ( 20 pages per session, 120 +/- 15 pages read )
|
||||
# The test data generator adds the session-gap 3600s AFTER the trigger page
|
||||
# (not before), so the first session includes 21 pages (1-21), and each
|
||||
# subsequent session has 20 until the last. The last page is now included
|
||||
# in the final scrobble instead of being orphaned.
|
||||
expected_scrobbles = 6 * len(book_map.keys())
|
||||
assert len(scrobbles) == expected_scrobbles
|
||||
assert len(scrobbles[0].logdata.page_data.keys()) == 21
|
||||
@ -52,7 +55,7 @@ def test_build_scrobbles_from_pages(get_mock, koreader_rows, demo_user, valid_re
|
||||
assert len(scrobbles[2].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[3].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[4].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[5].logdata.page_data.keys()) == 18
|
||||
assert len(scrobbles[5].logdata.page_data.keys()) == 19
|
||||
|
||||
|
||||
def test_get_author_str_from_row():
|
||||
|
||||
@ -7,7 +7,10 @@ register = template.Library()
|
||||
def get_item(dictionary, key):
|
||||
if isinstance(dictionary, dict):
|
||||
return dictionary.get(key)
|
||||
return None
|
||||
try:
|
||||
return dictionary[int(key)]
|
||||
except (IndexError, KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
|
||||
@ -114,108 +114,106 @@ class ChartRecordView(TemplateView):
|
||||
context["current_week"] = current_week
|
||||
context["current_day"] = current_day
|
||||
|
||||
if chart_type == "maloja":
|
||||
context["chart_keys"] = {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month",
|
||||
"year": "This Year",
|
||||
"all": "All Time",
|
||||
}
|
||||
context["chart_keys"] = {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month",
|
||||
"year": "This Year",
|
||||
"all": "All Time",
|
||||
}
|
||||
|
||||
context["maloja_charts"] = {
|
||||
"artist": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "artist", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "artist")),
|
||||
},
|
||||
"track": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"track",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user, "track", year=current_year, week=current_week
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"track",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "track", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "track")),
|
||||
},
|
||||
"tv_series": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "tv_series", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "tv_series")),
|
||||
},
|
||||
}
|
||||
return context
|
||||
context["maloja_charts"] = {
|
||||
"artist": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "artist", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "artist")),
|
||||
},
|
||||
"album": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user, "album", year=current_year, week=current_week
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "album", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "album")),
|
||||
},
|
||||
"tv_series": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "tv_series", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "tv_series")),
|
||||
},
|
||||
}
|
||||
|
||||
if not date_param:
|
||||
context["period"] = "current"
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from django import forms
|
||||
|
||||
from profiles.models import UserProfile
|
||||
from scrobbles.constants import Visibility
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@ -45,3 +47,51 @@ class UserProfileForm(forms.ModelForm):
|
||||
"archivebox_password": forms.PasswordInput(render_value=True),
|
||||
"webdav_pass": forms.PasswordInput(render_value=True),
|
||||
}
|
||||
|
||||
|
||||
MEDIA_TYPE_LABELS = {
|
||||
mt.value: mt.label for mt in Scrobble.MediaType
|
||||
}
|
||||
|
||||
INHERIT = ""
|
||||
|
||||
|
||||
class BulkVisibilityForm(forms.Form):
|
||||
bulk_action = forms.ChoiceField(
|
||||
choices=[
|
||||
(Visibility.PUBLIC, "Public"),
|
||||
(Visibility.PRIVATE, "Private"),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
required=False,
|
||||
label="Set all non-shared scrobbles to",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.profile = kwargs.pop("profile")
|
||||
super().__init__(*args, **kwargs)
|
||||
media_types = Scrobble.MediaType.values
|
||||
choices = [
|
||||
(Visibility.PUBLIC, "Public"),
|
||||
(Visibility.SHARED, "Shared"),
|
||||
(Visibility.PRIVATE, "Private"),
|
||||
]
|
||||
existing_overrides = self.profile.media_type_visibility or {}
|
||||
for mt in sorted(media_types):
|
||||
label = MEDIA_TYPE_LABELS.get(mt, mt)
|
||||
self.fields[f"media_type_{mt}"] = forms.ChoiceField(
|
||||
choices=choices,
|
||||
required=False,
|
||||
label=label,
|
||||
initial=existing_overrides.get(mt, Visibility.PRIVATE),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
overrides = {}
|
||||
for mt in Scrobble.MediaType.values:
|
||||
val = cleaned.get(f"media_type_{mt}")
|
||||
if val:
|
||||
overrides[mt] = val
|
||||
cleaned["media_type_visibility"] = overrides
|
||||
return cleaned
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0037_alter_userprofile_default_scrobble_visibility"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="media_type_visibility",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -90,6 +90,12 @@ class UserProfile(TimeStampedModel):
|
||||
default="private",
|
||||
)
|
||||
|
||||
media_type_visibility = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}",
|
||||
)
|
||||
|
||||
home_scrobble_limit = models.IntegerField(default=20)
|
||||
|
||||
weigh_in_units = models.CharField(
|
||||
@ -134,6 +140,11 @@ class UserProfile(TimeStampedModel):
|
||||
return history
|
||||
|
||||
def get_timestamp_with_tz(self, timestamp):
|
||||
from django.conf import settings
|
||||
|
||||
server_tz = ZoneInfo(settings.TIME_ZONE)
|
||||
ref_dt = timestamp if timestamp.tzinfo is not None else timestamp.replace(tzinfo=server_tz)
|
||||
|
||||
timezone = self.tzinfo
|
||||
if self.timezone_change_log:
|
||||
change_list = self.historic_timezone_changes
|
||||
@ -144,13 +155,13 @@ class UserProfile(TimeStampedModel):
|
||||
end = None
|
||||
|
||||
if end:
|
||||
if start <= timestamp.replace(tzinfo=end.timezone) <= end:
|
||||
if start <= ref_dt <= end:
|
||||
timezone = start.timezone
|
||||
else:
|
||||
if start <= timestamp.replace(tzinfo=start.timezone):
|
||||
if start <= ref_dt:
|
||||
timezone = start.timezone
|
||||
|
||||
return timestamp.replace(tzinfo=timezone)
|
||||
return ref_dt.astimezone(timezone)
|
||||
|
||||
def adjust_timezone_of_scrobbles(self, commit=False):
|
||||
current_dt = None
|
||||
|
||||
@ -6,4 +6,9 @@ app_name = "profiles"
|
||||
|
||||
urlpatterns = [
|
||||
path("settings/", views.ProfileFormView.as_view(), name="profile_settings"),
|
||||
path(
|
||||
"settings/visibility/",
|
||||
views.BulkVisibilityView.as_view(),
|
||||
name="bulk_visibility",
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Count, Q
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import FormView
|
||||
from profiles.forms import UserProfileForm
|
||||
from profiles.forms import BulkVisibilityForm, UserProfileForm
|
||||
from scrobbles.constants import Visibility
|
||||
from scrobbles.models import Scrobble
|
||||
from tasks.todoist import generate_todoist_oauth_url
|
||||
|
||||
|
||||
@ -30,3 +34,46 @@ class ProfileFormView(LoginRequiredMixin, FormView):
|
||||
context["profile"] = self.request.user.profile
|
||||
context["todoist_oauth_url"] = generate_todoist_oauth_url(self.request.user.id)
|
||||
return context
|
||||
|
||||
|
||||
class BulkVisibilityView(LoginRequiredMixin, FormView):
|
||||
template_name = "profiles/visibility_settings.html"
|
||||
form_class = BulkVisibilityForm
|
||||
success_url = reverse_lazy("profiles:bulk_visibility")
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["profile"] = self.request.user.profile
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
request = self.request
|
||||
profile = request.user.profile
|
||||
|
||||
bulk_action = form.cleaned_data.get("bulk_action")
|
||||
if bulk_action:
|
||||
qs = Scrobble.objects.filter(
|
||||
user=request.user,
|
||||
).exclude(visibility=Visibility.SHARED)
|
||||
total = qs.count()
|
||||
qs.update(visibility=bulk_action)
|
||||
messages.success(
|
||||
request,
|
||||
f"Updated {total} scrobble(s) to {bulk_action}.",
|
||||
)
|
||||
|
||||
profile.media_type_visibility = form.cleaned_data["media_type_visibility"]
|
||||
profile.save(update_fields=["media_type_visibility"])
|
||||
messages.success(request, "Per-media-type visibility overrides saved.")
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
profile = self.request.user.profile
|
||||
qs = Scrobble.objects.filter(user=self.request.user)
|
||||
ctx["scrobble_count"] = qs.count()
|
||||
ctx["visibility_counts"] = qs.values("visibility").annotate(
|
||||
count=Count("id")
|
||||
)
|
||||
ctx["profile"] = profile
|
||||
return ctx
|
||||
|
||||
@ -153,7 +153,7 @@ def scan_webdav_for_koreader(
|
||||
)
|
||||
|
||||
if update_etag_only:
|
||||
if last_import and remote_etag:
|
||||
if last_import and remote_etag and hasattr(last_import, "webdav_etag"):
|
||||
last_import.webdav_etag = remote_etag
|
||||
last_import.save(update_fields=["webdav_etag"])
|
||||
logger.info(
|
||||
@ -163,7 +163,7 @@ def scan_webdav_for_koreader(
|
||||
)
|
||||
return 0
|
||||
|
||||
if last_import and last_import.webdav_etag and remote_etag:
|
||||
if last_import and getattr(last_import, "webdav_etag", None) and remote_etag:
|
||||
if last_import.webdav_etag == remote_etag:
|
||||
logger.info(
|
||||
"koreader stats file unchanged (ETag match)",
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def convert_page_data_to_dict(apps, schema_editor):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
for scrobble in Scrobble.objects.filter(media_type="Book").exclude(
|
||||
log__page_data=None
|
||||
):
|
||||
page_data = scrobble.log.get("page_data")
|
||||
if isinstance(page_data, list):
|
||||
new_page_data = {}
|
||||
for entry in page_data:
|
||||
page_num = entry.get("page_number")
|
||||
if page_num is not None:
|
||||
try:
|
||||
page_num = int(page_num)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
new_page_data[page_num] = entry
|
||||
scrobble.log["page_data"] = new_page_data
|
||||
scrobble.save(update_fields=["log"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0095_backfill_null_visibility"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
convert_page_data_to_dict,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -225,7 +225,9 @@ class KoReaderImport(BaseFileImportMixin):
|
||||
|
||||
self.mark_started()
|
||||
try:
|
||||
scrobbles = process_koreader_sqlite_file(self.upload_file_path, self.user.id)
|
||||
scrobbles = process_koreader_sqlite_file(
|
||||
self.upload_file_path, self.user.id
|
||||
)
|
||||
self.record_log(scrobbles)
|
||||
except Exception as e:
|
||||
self.record_error(f"Import failed: {e}")
|
||||
@ -272,7 +274,9 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
|
||||
|
||||
self.mark_started()
|
||||
try:
|
||||
scrobbles = import_audioscrobbler_tsv_file(self.upload_file_path, self.user.id)
|
||||
scrobbles = import_audioscrobbler_tsv_file(
|
||||
self.upload_file_path, self.user.id
|
||||
)
|
||||
self.record_log(scrobbles)
|
||||
except Exception as e:
|
||||
self.record_error(f"Import failed: {e}")
|
||||
@ -936,6 +940,8 @@ class Scrobble(TimeStampedModel):
|
||||
logdata_cls = logdata.BaseLogData
|
||||
|
||||
log_dict = self.log
|
||||
if isinstance(log_dict, logdata.BaseLogData):
|
||||
log_dict = log_dict.asdict
|
||||
if isinstance(self.log, str):
|
||||
# There's nothing stopping django from saving a string in a JSONField :(
|
||||
logger.warning(
|
||||
@ -1412,20 +1418,18 @@ class Scrobble(TimeStampedModel):
|
||||
or scrobble_data["playback_status"] == "stopped"
|
||||
):
|
||||
if read_log_page:
|
||||
page_list = scrobble.log.get("page_data", [])
|
||||
if page_list:
|
||||
for page in page_list:
|
||||
page_data = scrobble.log.get("page_data", {})
|
||||
if page_data:
|
||||
for page_num, page in page_data.items():
|
||||
if not page.get("end_ts", None):
|
||||
page["end_ts"] = int(timezone.now().timestamp())
|
||||
page["duration"] = page["end_ts"] - page.get("start_ts")
|
||||
|
||||
page_list.append(
|
||||
BookPageLogData(
|
||||
page_number=read_log_page,
|
||||
start_ts=int(timezone.now().timestamp()),
|
||||
)
|
||||
page_data[read_log_page] = BookPageLogData(
|
||||
page_number=read_log_page,
|
||||
start_ts=int(timezone.now().timestamp()),
|
||||
)
|
||||
scrobble.log["page_data"] = page_list
|
||||
scrobble.log["page_data"] = page_data
|
||||
scrobble.save(update_fields=["log"])
|
||||
elif "log" in scrobble_data.keys() and scrobble.log:
|
||||
scrobble_data["log"] = scrobble.log | scrobble_data["log"]
|
||||
@ -1436,13 +1440,13 @@ class Scrobble(TimeStampedModel):
|
||||
|
||||
if read_log_page:
|
||||
scrobble_data["log"] = BookLogData(
|
||||
page_data=[
|
||||
BookPageLogData(
|
||||
page_data={
|
||||
read_log_page: BookPageLogData(
|
||||
page_number=read_log_page,
|
||||
start_ts=int(timezone.now().timestamp()),
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
).asdict
|
||||
|
||||
logger.info(
|
||||
f"[scrobbling] creating new scrobble",
|
||||
@ -1457,7 +1461,7 @@ class Scrobble(TimeStampedModel):
|
||||
"calories", None
|
||||
):
|
||||
if media.calories:
|
||||
scrobble_data["log"] = FoodLogData(calories=media.calories)
|
||||
scrobble_data["log"] = FoodLogData(calories=media.calories).asdict
|
||||
|
||||
scrobble = cls.create(scrobble_data)
|
||||
return scrobble
|
||||
@ -1613,7 +1617,17 @@ class Scrobble(TimeStampedModel):
|
||||
scrobble_data: dict,
|
||||
) -> "Scrobble":
|
||||
if "visibility" not in scrobble_data:
|
||||
scrobble_data["visibility"] = Visibility.PRIVATE
|
||||
user = scrobble_data.get("user")
|
||||
media_type = scrobble_data.get("media_type")
|
||||
override = None
|
||||
if user and media_type:
|
||||
try:
|
||||
profile = user.profile
|
||||
overrides = profile.media_type_visibility or {}
|
||||
override = overrides.get(media_type)
|
||||
except user.__class__.profile.RelatedObjectDoesNotExist:
|
||||
pass
|
||||
scrobble_data["visibility"] = override or Visibility.PRIVATE
|
||||
scrobble = cls.objects.create(**scrobble_data)
|
||||
if not (
|
||||
scrobble.media_type == cls.MediaType.GEO_LOCATION
|
||||
@ -1770,33 +1784,27 @@ class Scrobble(TimeStampedModel):
|
||||
return False
|
||||
|
||||
def calculate_reading_stats(self, commit=True):
|
||||
page_data = self.log.get("page_data")
|
||||
page_data = self.logdata.page_data
|
||||
|
||||
if page_data:
|
||||
# --- Sort safely by numeric page_number ---
|
||||
def safe_page_number(entry):
|
||||
try:
|
||||
return int(getattr("page_number", entry), 0)
|
||||
except (ValueError, TypeError):
|
||||
return float("inf")
|
||||
|
||||
if isinstance(page_data, dict):
|
||||
logger.warning("Page data is dict, migrate koreader data")
|
||||
return
|
||||
valid_pages = sorted(int(k) for k in page_data.keys())
|
||||
if valid_pages:
|
||||
self.log["page_start"] = min(valid_pages)
|
||||
self.log["page_end"] = max(valid_pages)
|
||||
self.log["pages_read"] = len(set(valid_pages))
|
||||
elif isinstance(page_data, list):
|
||||
valid_pages = []
|
||||
for page in page_data:
|
||||
try:
|
||||
valid_pages.append(int(page["page_number"]))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
page_data.sort(key=safe_page_number)
|
||||
|
||||
valid_pages = []
|
||||
for page in page_data:
|
||||
try:
|
||||
valid_pages.append(int(page["page_number"]))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if valid_pages:
|
||||
self.log["page_start"] = min(valid_pages)
|
||||
self.log["page_end"] = max(valid_pages)
|
||||
self.log["pages_read"] = len(set(valid_pages))
|
||||
if valid_pages:
|
||||
self.log["page_start"] = min(valid_pages)
|
||||
self.log["page_end"] = max(valid_pages)
|
||||
self.log["pages_read"] = len(set(valid_pages))
|
||||
else:
|
||||
page_start = self.log.get("page_start")
|
||||
page_end = self.log.get("page_end")
|
||||
@ -1836,9 +1844,7 @@ class FavoriteMedia(TimeStampedModel):
|
||||
birding_location = models.ForeignKey(
|
||||
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
||||
)
|
||||
media_type = models.CharField(
|
||||
max_length=20, choices=Scrobble.MediaType.choices
|
||||
)
|
||||
media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
|
||||
sent_to_mopidy = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -357,10 +357,7 @@ def manual_scrobble_book(
|
||||
|
||||
if action == "stop":
|
||||
if url:
|
||||
if isinstance(scrobble.log, "BookLogData"):
|
||||
scrobble.log.resume_url = next_url_if_exists(url)
|
||||
else:
|
||||
scrobble.log["resume_url"] = next_url_if_exists(url)
|
||||
scrobble.log["resume_url"] = next_url_if_exists(url)
|
||||
scrobble.save(update_fields=["log"])
|
||||
scrobble.stop(force_finish=True)
|
||||
|
||||
@ -540,6 +537,7 @@ def email_scrobble_board_game(
|
||||
"playback_position_seconds": duration_seconds,
|
||||
"source": "BG Stats",
|
||||
"log": log_data,
|
||||
"visibility": "private",
|
||||
}
|
||||
|
||||
scrobble = None
|
||||
@ -1152,7 +1150,7 @@ def web_scrobbler_scrobble_video_or_song(
|
||||
artist_name = data_dict.get("artist")
|
||||
track_name = data_dict.get("track")
|
||||
tracks = Track.objects.filter(
|
||||
artist__name=data_dict.get("artist"), title=data_dict.get("track")
|
||||
artist_fk__name=data_dict.get("artist"), title=data_dict.get("track")
|
||||
)
|
||||
if tracks.count() > 1:
|
||||
logger.warning(
|
||||
|
||||
@ -371,6 +371,9 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
||||
qs = qs.filter(id__in=matching_ids).distinct()
|
||||
else:
|
||||
tag_list = []
|
||||
visibility_param = self.request.GET.get("visibility", "")
|
||||
if visibility_param in ("public", "shared", "private"):
|
||||
qs = qs.filter(visibility=visibility_param)
|
||||
self.tag_list = tag_list
|
||||
self._full_queryset = qs
|
||||
return qs
|
||||
|
||||
@ -145,7 +145,7 @@ CELERY_BEAT_SCHEDULE = {
|
||||
},
|
||||
"import-from-webdav": {
|
||||
"task": "scrobbles.tasks.import_from_webdav_all_users",
|
||||
"schedule": crontab(minute="*/3"),
|
||||
"schedule": crontab(minute="*/2"),
|
||||
},
|
||||
# Deprecated: BG Stats files now picked up from WebDAV var/bgstats/
|
||||
# "import-from-imap": {
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block grid_view_button %}
|
||||
<div class="btn-group me-2">
|
||||
{% if view == 'grid' %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"><a href="?{% urlreplace view='list' %}">List View</a>
|
||||
@ -22,6 +23,7 @@
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"><a href="?{% urlreplace view='grid' %}">Grid View</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block charts_button %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Charts{% if period_str %} - {{ period_str }}{% endif %}{% endblock %}
|
||||
|
||||
@ -12,11 +13,46 @@
|
||||
.nav-tabs {
|
||||
cursor: pointer;
|
||||
}
|
||||
.image-wrapper {
|
||||
contain: content;
|
||||
}
|
||||
.image-wrapper :hover {
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
.caption {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 3px;
|
||||
font-size: 90%;
|
||||
color: white;
|
||||
background: rgba(0,0,0,0.4);
|
||||
}
|
||||
.caption-medium {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 3px;
|
||||
font-size: 75%;
|
||||
color: white;
|
||||
background: rgba(0,0,0,0.4);
|
||||
}
|
||||
.caption-small {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 3px;
|
||||
font-size: 60%;
|
||||
color: white;
|
||||
background: rgba(0,0,0,0.4);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
|
||||
{% block grid_view_button %}{% endblock %}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
||||
@ -24,10 +60,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if chart_type == "maloja" %}
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
{% else %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="btn-group mb-3" role="group">
|
||||
@ -88,43 +120,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
|
||||
<div class="row">
|
||||
{% if charts.artist %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>🎤 Top Artists</h3>
|
||||
<ul class="list-group">
|
||||
{% for chart in charts.artist|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||
<a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a>
|
||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'charts:chart-detail' 'artist' %}">View all »</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.album %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>💿 Top Albums</h3>
|
||||
<ul class="list-group">
|
||||
{% for chart in charts.album|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||
<a href="{{chart.album.get_absolute_url}}">{{chart.album.name}}</a>
|
||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'charts:chart-detail' 'album' %}">View all »</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.track %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>🎵 Top Tracks</h3>
|
||||
@ -143,24 +141,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.tv_series %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>📺 Top TV Series</h3>
|
||||
<ul class="list-group">
|
||||
{% for chart in charts.tv_series|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||
<a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a>
|
||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'charts:chart-detail' 'tv_series' %}">View all »</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.video %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>🎬 Top Videos</h3>
|
||||
@ -314,6 +294,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -106,6 +106,7 @@
|
||||
{% block title %}Settings{% endblock %}
|
||||
{% block details %}
|
||||
<p class="settings-link"><a href="{% url 'people:person_form' %}">Manage People</a></p>
|
||||
<p class="settings-link"><a href="{% url 'profiles:bulk_visibility' %}">Scrobble Visibility Settings</a></p>
|
||||
<form method="post" class="settings-form">{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{% if field.name == "enable_public_widgets" %}
|
||||
|
||||
117
vrobbler/templates/profiles/visibility_settings.html
Normal file
117
vrobbler/templates/profiles/visibility_settings.html
Normal file
@ -0,0 +1,117 @@
|
||||
{% extends "base_detail.html" %}
|
||||
|
||||
{% block title %}Scrobble Visibility Settings{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.vis-form {
|
||||
max-width: 700px;
|
||||
}
|
||||
.vis-form h3 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.vis-form h4 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.vis-form label {
|
||||
font-weight: 600;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.vis-form .bulk-radio-group {
|
||||
margin: 8px 0 16px 0;
|
||||
}
|
||||
.vis-form .bulk-radio-group label {
|
||||
font-weight: normal;
|
||||
display: block;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.vis-form .media-type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 6px 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vis-form .media-type-row:nth-child(even) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.vis-form .media-type-row label {
|
||||
font-weight: normal;
|
||||
min-width: 130px;
|
||||
}
|
||||
.vis-form select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vis-form input[type="submit"] {
|
||||
margin-top: 20px;
|
||||
padding: 10px 24px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.vis-form input[type="submit"]:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.vis-stats {
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vis-stats strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vis-stats ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
<p><a href="{% url 'profiles:profile_settings' %}">« Back to settings</a></p>
|
||||
|
||||
<div class="vis-stats">
|
||||
<strong>Current scrobble visibility ({{ scrobble_count }} total)</strong>
|
||||
<ul>
|
||||
{% for item in visibility_counts %}
|
||||
<li><a href="{% url 'scrobbles:scrobble-list' %}?visibility={{ item.visibility }}">{{ item.visibility|title }}</a>: {{ item.count }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="post" class="vis-form">{% csrf_token %}
|
||||
<h3>Bulk Update</h3>
|
||||
<p>Choose a visibility to apply to all scrobbles that are <strong>not</strong> currently "Shared".</p>
|
||||
<div class="bulk-radio-group">
|
||||
{% for radio in form.bulk_action %}
|
||||
<label>{{ radio.tag }} {{ radio.choice_label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><h3 style="display:inline">Per-Media-Type Defaults</h3></summary>
|
||||
<p>Override default visibility for new scrobbles of specific types.</p>
|
||||
{% for field in form %}
|
||||
{% if field.name != "bulk_action" %}
|
||||
<div class="media-type-row">
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
<input type="submit" value="Save Visibility Settings">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@ -76,44 +76,44 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<h2>🎵 Top Tracks</h2>
|
||||
<ul class="nav nav-tabs" id="trackTab" role="tablist">
|
||||
<h2>💿 Top Albums</h2>
|
||||
<ul class="nav nav-tabs" id="albumTab" role="tablist">
|
||||
{% for key, name in chart_keys.items %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
|
||||
id="track-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#track-{{key}}"
|
||||
id="album-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#album-{{key}}"
|
||||
type="button" role="tab">{{name}}</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="trackTabContent" class="maloja-chart">
|
||||
<div class="tab-content" id="albumTabContent" class="maloja-chart">
|
||||
{% for key, name in chart_keys.items %}
|
||||
{% with maloja_charts.track|get_item:key as tracks %}
|
||||
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="track-{{key}}" role="tabpanel">
|
||||
{% if tracks.0 %}
|
||||
{% with maloja_charts.album|get_item:key as albums %}
|
||||
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="album-{{key}}" role="tabpanel">
|
||||
{% if albums.0 %}
|
||||
<div style="display:block">
|
||||
<div style="float:left;">
|
||||
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
|
||||
<div class="caption">#1 {{tracks.0.track.title}}</div>
|
||||
{% if tracks.0.track.album.cover_image %}
|
||||
<a href="{{tracks.0.track.get_absolute_url}}"><img src="{{tracks.0.track.album.cover_image_medium.url}}" width="300px"></a>
|
||||
<div class="caption">#1 {{albums.0.album.title}}</div>
|
||||
{% if albums.0.album.cover_image %}
|
||||
<a href="{{albums.0.album.get_absolute_url}}"><img src="{{albums.0.album.cover_image_medium.url}}" width="300px"></a>
|
||||
{% else %}
|
||||
<a href="{{tracks.0.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="300px"></a>
|
||||
<a href="{{albums.0.album.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="300px"></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "2345" %}
|
||||
{% with tracks|get_item:forloop.counter as track %}
|
||||
{% if track %}
|
||||
{% with albums|get_item:forloop.counter as album %}
|
||||
{% if album %}
|
||||
<div class="image-wrapper" style="width:50%">
|
||||
<div class="caption-medium">#{{forloop.counter|add:1}} {{track.track.title}}</div>
|
||||
{% if track.track.album.cover_image %}
|
||||
<a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="150px"></a>
|
||||
<div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.title}}</div>
|
||||
{% if album.album.cover_image %}
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="150px"></a>
|
||||
{% else %}
|
||||
<a href="{{track.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -124,14 +124,14 @@
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "67891011121314" %}
|
||||
{% with tracks|get_item:forloop.counter|add:5 as track %}
|
||||
{% if track %}
|
||||
{% with albums|get_item:forloop.counter|add:5 as album %}
|
||||
{% if album %}
|
||||
<div class="image-wrapper" style="width:33%">
|
||||
<div class="caption-small">#{{forloop.counter|add:6}} {{track.track.title}}</div>
|
||||
{% if track.track.album.cover_image %}
|
||||
<a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="100px"></a>
|
||||
<div class="caption-small">#{{forloop.counter|add:6}} {{album.album.title}}</div>
|
||||
{% if album.album.cover_image %}
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="100px"></a>
|
||||
{% else %}
|
||||
<a href="{{track.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -13,6 +13,9 @@
|
||||
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if request.GET.visibility %}
|
||||
<h6 class="text-muted">Filter: {{ request.GET.visibility|title }} scrobbles only</h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user