Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 53 additions & 32 deletions src/integrations/imports/anilist.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import requests
from django.apps import apps
from django.conf import settings
from django.db import transaction
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
Expand Down Expand Up @@ -103,7 +104,7 @@ def __init__(self, token, user, mode, username):
username (str): AniList username to import from
token (str): Encrypted access token for private imports (optional)
user: Django user object to import data for
mode (str): Import mode ("new" or "overwrite")
mode (str): Import mode ("new", "update" or "overwrite")
"""
self.username = username
self.token = token
Expand All @@ -114,19 +115,19 @@ def __init__(self, token, user, mode, username):
if self.token is not None:
self.token = helpers.decrypt(self.token)

# Track existing media for "new" mode
# Track existing media for "new" and "update" mode
self.existing_media = helpers.get_existing_media(user)

# Track every entry of each media to sync the current one and add the
# missing repeats in "new" mode
# missing repeats in "new" and "update" mode
self.existing_entries = {}
if mode == "new":
if mode != "overwrite":
self.existing_entries = helpers.get_existing_entries(
user,
[MediaTypes.ANIME.value, MediaTypes.MANGA.value],
)

# Track existing media changed in "new" mode, by primary key
# Track existing media changed in "new" and "update" mode, by primary key
self.to_update = defaultdict(dict)

# Track media IDs to delete in overwrite mode
Expand Down Expand Up @@ -250,13 +251,16 @@ def import_data(self):
self._process_media_data(response["data"]["anime"], MediaTypes.ANIME.value)
self._process_media_data(response["data"]["manga"], MediaTypes.MANGA.value)

helpers.cleanup_existing_media(self.to_delete, self.user)
helpers.bulk_create_media(self.bulk_media, self.user)
helpers.bulk_update_media(
self.to_update,
helpers.EXISTING_MEDIA_UPDATE_FIELDS,
self.user,
)
# A finished entry and the repeats added after it are saved together,
# or a retry would find the repeats and leave the entry unfinished
with transaction.atomic():
helpers.cleanup_existing_media(self.to_delete, self.user)
helpers.bulk_create_media(self.bulk_media, self.user)
helpers.bulk_update_media(
self.to_update,
helpers.EXISTING_MEDIA_UPDATE_FIELDS,
self.user,
)

imported_counts = {
media_type: len(media_list)
Expand Down Expand Up @@ -319,6 +323,12 @@ def _process_entry(self, content, media_type):
"chapters",
)

updated_at = (
timezone.now()
if content["updatedAt"] == 0
else timezone.datetime.fromtimestamp(content["updatedAt"], tz=UTC)
)

repeats_count = content["repeat"]
if content["status"] == "REPEATING" and repeats_count == 0:
repeats_count = 1
Expand All @@ -331,6 +341,7 @@ def _process_entry(self, content, media_type):
current,
max_progress,
repeats_count + 1,
updated_at,
)
if not missing:
return
Expand All @@ -346,11 +357,6 @@ def _process_entry(self, content, media_type):
},
)
model = apps.get_model(app_label="app", model_name=media_type)
updated_at = (
timezone.now()
if content["updatedAt"] == 0
else timezone.datetime.fromtimestamp(content["updatedAt"], tz=UTC)
)

# Add completed repeats
for _count in range(repeats_count):
Expand All @@ -373,28 +379,43 @@ def _process_entry(self, content, media_type):
self.bulk_media[media_type].append(instance)

def _get_existing(self, media_type, source, media_id):
"""Get the current entry of media already in Yamtrack, only in "new" mode."""
if self.mode != "new":
"""Get the current entry of media already in Yamtrack, except in overwrite."""
if self.mode == "overwrite":
return None

entries = self.existing_entries.get((media_type, source, media_id))
return helpers.get_current_entry(entries) if entries else None

def _sync_existing(self, media_type, media, current, max_progress, entries):
def _sync_existing(
self,
media_type,
media,
current,
max_progress,
entries,
updated_at,
):
"""Sync existing media and return how many of its entries are missing."""
changed, missing = helpers.sync_existing_entries(
media,
current,
max_progress,
entries,
len(
self.existing_entries[
media_type,
media.item.source,
media.item.media_id,
],
),
existing_count = len(
self.existing_entries[media_type, media.item.source, media.item.media_id],
)
if self.mode == "update":
changed, missing = helpers.update_existing_entries(
media,
current,
max_progress,
entries,
existing_count,
updated_at,
)
else:
changed, missing = helpers.sync_existing_entries(
media,
current,
max_progress,
entries,
existing_count,
)
if changed:
self.to_update[media_type][media.pk] = media

Expand Down
67 changes: 67 additions & 0 deletions src/integrations/imports/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import logging
from collections import defaultdict
from decimal import Decimal

from cryptography.fernet import Fernet
from django.apps import apps
Expand Down Expand Up @@ -300,6 +301,72 @@ def sync_existing_entries(media, current, max_progress, entries, existing_count)
return changed, missing


def is_same_value(current, value):
"""Return whether a field of existing media already has an imported value."""
if isinstance(current, Decimal) and value is not None:
return current == Decimal(str(value))
return current == value


def update_existing_media(media, values, updated_at):
"""Update existing media to the values of an import, return whether it changed.

The history record is dated when the source changed the media. When Yamtrack
has a record as new or newer, the update is dated now, as the history shows
each record as a change from the one before it.
"""
changed = {
field: value
for field, value in values.items()
if not is_same_value(getattr(media, field), value)
}
if not changed:
return False

latest = media.history.first()
if latest and latest.history_date >= updated_at:
updated_at = timezone.now()

for field, value in changed.items():
setattr(media, field, value)
if "progress" in changed:
media.progressed_at = updated_at
media._history_date = updated_at
return True


def update_existing_entries(
media,
current,
max_progress,
entries,
existing_count,
updated_at,
):
"""Update the current entry of existing media from a list import.

When the source has entries Yamtrack is missing, the current entry becomes a
finished repeat. It keeps its dates, as the source only has the dates of its
newest entry.

Returns whether the media changed and how many entries are missing.
"""
missing = max(entries - existing_count, 0)

if not missing:
return update_existing_media(media, current, updated_at), missing

values = {
"score": current["score"],
"notes": current["notes"],
"status": Status.COMPLETED.value,
}
if max_progress:
values["progress"] = max_progress

return update_existing_media(media, values, updated_at), missing


def should_process_media(existing_media, to_delete, media_type, source, media_id, mode):
"""Determine if a media item should be processed based on mode."""
exists = media_id in existing_media[media_type][source]
Expand Down
5 changes: 5 additions & 0 deletions src/integrations/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

logger = logging.getLogger(__name__)
ERROR_TITLE = "\n\n\n Couldn't import the following media: \n\n"
UPDATE_MODE_IMPORTERS = {anilist.importer}


def format_media_type_display(count, media_type):
Expand Down Expand Up @@ -70,6 +71,10 @@ def import_media(
# Celery tasks run outside a request, so build the warnings, errors and
# summary in the user's language instead of LANGUAGE_CODE
with translation.override(user.language):
if mode == "update" and importer_func not in UPDATE_MODE_IMPORTERS:
msg = _("Update mode is only available for AniList imports.")
raise helpers.MediaImportError(msg)

with disable_fetch_releases():
if oauth_username is None:
imported_counts, warnings = importer_func(
Expand Down
Loading