diff --git a/README.md b/README.md index 44bb5ab8..a763331d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ Alternatively, if you need a PostgreSQL database, you can use the `docker-compos | TMDB_NSFW | Bool | Default to false, set to true to include adult content in tv and movie searches | | TMDB_LANG | String | TMDB metadata language, uses a Language code in ISO 639-1 e.g "en", for more specific results a country code in ISO 3166-1 can be added e.g "en-US" | | MAL_API | String | MyAnimeList API key, for anime and manga, a default key is provided | -| MAL_NSFW | Bool | Default to false, set to true to include adult content in anime and manga searches | +| MAL_NSFW | Bool | Default to false, set to true to include adult content in anime and manga searches from MyAnimeList | +| MU_NSFW | Bool | Default to false, set to true to include adult content in manga searches from MangaUpdates | | IGDB_ID | String | IGDB API key for games, a default key is provided but it's recommended to get your own as it has a low rate limit. | | IGDB_SECRET | String | IGDB API secret for games, a default value is provided but it's recommended to get your own as it has a low rate limit. | | IGDB_NSFW | Bool | Default to false, set to true to include adult content in game searches | diff --git a/src/app/models.py b/src/app/models.py index f06c78bc..b7bb7079 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -184,6 +184,7 @@ class Media(models.Model): max_progress = services.get_media_metadata( self.item.media_type, self.item.media_id, + self.item.source, )["max_progress"] if max_progress: @@ -202,6 +203,7 @@ class Media(models.Model): max_progress = services.get_media_metadata( self.item.media_type, self.item.media_id, + self.item.source, )["max_progress"] if max_progress: @@ -230,6 +232,7 @@ class Media(models.Model): media_metadata = services.get_media_metadata( self.item.media_type, self.item.media_id, + self.item.source, ) response = {"item": self.item} max_progress = media_metadata["max_progress"] @@ -513,6 +516,7 @@ class Season(Media): media_metadata = services.get_media_metadata( self.item.media_type, self.item.media_id, + self.item.source, self.item.season_number, ) response = {"item": self.item} diff --git a/src/app/providers/mangaupdates.py b/src/app/providers/mangaupdates.py index 48cfa19d..83b2e1e8 100644 --- a/src/app/providers/mangaupdates.py +++ b/src/app/providers/mangaupdates.py @@ -1,3 +1,8 @@ +import asyncio +import re + +import aiohttp +import requests from django.conf import settings from django.core.cache import cache @@ -48,6 +53,120 @@ def search(query): return data +def manga(media_id): + """Get metadata for a manga from MangaUpdates.""" + return asyncio.run(async_manga(media_id)) + + +async def async_manga(media_id): + """Asynchronous implementation of manga metadata retrieval.""" + data = cache.get(f"mangaupdates_manga_{media_id}") + + if data is None: + url = f"{base_url}/series/{media_id}" + response = services.api_request("MANGAUPDATES", "GET", url) + + num_chapters = response["latest_chapter"] + + # Run related_manga and recommendations concurrently + related_task = asyncio.create_task( + get_related_series(response["related_series"]), + ) + recommendations_task = asyncio.create_task( + get_recommendations(response["recommendations"]), + ) + + data = { + "media_id": media_id, + "source": "mangaupdates", + "media_type": "manga", + "title": response["title"], + "image": get_image_url(response), + "synopsis": response["description"], + "max_progress": num_chapters, + "details": { + "format": response["type"], + "authors": get_authors(response["authors"]), + "year": response["year"], + "status": get_status(response["status"]), + "number_of_chapters": num_chapters, + "genres": get_genres(response["genres"]), + }, + "related": { + "related_manga": await related_task, + "recommendations": await recommendations_task, + }, + } + + cache.set(f"mangaupdates_manga_{media_id}", data) + + return data + + def get_image_url(response): """Get the image URL for a media item.""" - return response["image"]["url"]["original"] + # when no image, value from response is null + url = response["image"]["url"]["original"] + return url if url else settings.IMG_NONE + + +def get_genres(genres): + """Return the genres for the media.""" + return ", ".join(item["genre"] for item in genres) + + +def get_authors(authors): + """Get the authors for a media item.""" + return ", ".join(item["name"] for item in authors) + + +def get_status(status): + """Return the status of the media.""" + # e.g berserk 51239621230 needs parsing + pattern = r"(\d+\s+Volumes\s+\([^)]+\))" + match = re.search(pattern, status) + if match: + return match.group(1) + return status + + +async def get_related_series(related): + """Return list of related media for the selected media asynchronously.""" + async with aiohttp.ClientSession() as session: + tasks = [ + fetch_series_data( + session, + f"{base_url}/series/{item['related_series_id']}", + item, + ) + for item in related + if item["related_series_name"] + ] + results = await asyncio.gather(*tasks) + return [item for item in results if item is not None] + + +async def get_recommendations(recommendations): + """Return list of recommended media for the selected media asynchronously.""" + async with aiohttp.ClientSession() as session: + tasks = [ + fetch_series_data(session, f"{base_url}/series/{item['series_id']}", item) + for item in recommendations + if item["series_name"] + ] + results = await asyncio.gather(*tasks) + return [item for item in results if item is not None] + + +async def fetch_series_data(session, url, item): + """Fetch series data asynchronously.""" + async with session.get(url) as response: + if response.status == requests.codes.ok: + data = await response.json() + image = get_image_url(data) + return { + "media_id": item.get("related_series_id") or item.get("series_id"), + "title": item.get("related_series_name") or item.get("series_name"), + "image": image, + } + return None diff --git a/src/app/providers/services.py b/src/app/providers/services.py index 4ec7ffb8..ac389474 100644 --- a/src/app/providers/services.py +++ b/src/app/providers/services.py @@ -8,7 +8,7 @@ from pyrate_limiter import RedisBucket from redis import ConnectionPool from requests_ratelimiter import LimiterSession -from app.providers import igdb, mal, tmdb +from app.providers import igdb, mal, mangaupdates, tmdb logger = logging.getLogger(__name__) @@ -121,13 +121,16 @@ def request_error_handling(error, *args): raise error # re-raise the error if it's not handled -def get_media_metadata(media_type, media_id, season_number=None): +def get_media_metadata(media_type, media_id, source, season_number=None): """Return the metadata for the selected media.""" match media_type: case "anime": media_metadata = mal.anime(media_id) case "manga": - media_metadata = mal.manga(media_id) + if source == "mangaupdates": + media_metadata = mangaupdates.manga(media_id) + else: + media_metadata = mal.manga(media_id) case "tv": media_metadata = tmdb.tv(media_id) case "season": diff --git a/src/app/views.py b/src/app/views.py index 36b0e88a..e5eeb7ad 100644 --- a/src/app/views.py +++ b/src/app/views.py @@ -114,7 +114,8 @@ def media_search(request): @require_GET def media_details(request, media_type, media_id, title): # noqa: ARG001 title for URL """Return the details page for a media item.""" - media_metadata = services.get_media_metadata(media_type, media_id) + source = request.GET.get("source") + media_metadata = services.get_media_metadata(media_type, media_id, source) context = {"media": media_metadata} return render(request, "app/media_details.html", context) @@ -289,18 +290,18 @@ def episode_handler(request): @require_GET def history(request): """Return the history page for a media item.""" - media_id = request.GET["media_id"] - source = request.GET["source"] media_type = request.GET["media_type"] - season_number = request.GET.get("season_number") - episode_number = request.GET.get("episode_number") - item = Item.objects.get( - media_id=media_id, - source=source, + item, _ = Item.objects.get_or_create( + media_id=request.GET["media_id"], + source=request.GET["source"], media_type=media_type, - season_number=season_number, - episode_number=episode_number, + season_number=request.GET.get("season_number"), + episode_number=request.GET.get("episode_number"), + defaults={ + "title": request.GET["title"], + "image": request.GET["image"], + }, ) media = database.get_media(media_type, item, request.user) diff --git a/src/events/tasks.py b/src/events/tasks.py index aad12892..cf4d3296 100644 --- a/src/events/tasks.py +++ b/src/events/tasks.py @@ -72,7 +72,11 @@ def process_item(item, events_bulk): metadata = tmdb.season(item.media_id, item.season_number) reloaded = process_season(item, metadata, events_bulk) else: - metadata = services.get_media_metadata(item.media_type, item.media_id) + metadata = services.get_media_metadata( + item.media_type, + item.media_id, + item.source, + ) reloaded = process_other(item, metadata, events_bulk) return reloaded diff --git a/src/integrations/imports/tmdb.py b/src/integrations/imports/tmdb.py index ba3b1179..e64d4642 100644 --- a/src/integrations/imports/tmdb.py +++ b/src/integrations/imports/tmdb.py @@ -27,7 +27,7 @@ def importer(file, user, status): # if movie or tv show (not episode) if media_type == "movie" or (media_type == "tv" and episode_number == ""): - media_metadata = services.get_media_metadata(media_type, media_id) + media_metadata = services.get_media_metadata(media_type, media_id, "tmdb") item, _ = models.Item.objects.get_or_create( media_id=media_metadata["media_id"], diff --git a/src/integrations/views.py b/src/integrations/views.py index 51bf010e..39716968 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -1,7 +1,9 @@ """Contains views for importing and exporting media data from various sources.""" import logging +from datetime import datetime +from django.conf import settings from django.contrib import messages from django.http import HttpResponse from django.shortcuts import redirect @@ -92,10 +94,11 @@ def import_yamtrack(request): @require_GET def export_csv(request): """View for exporting all media data to a CSV file.""" + today = datetime.now(tz=settings.TZ).strftime("%Y-%m-%d") # Create the HttpResponse object with the appropriate CSV header. response = HttpResponse( content_type="text/csv", - headers={"Content-Disposition": 'attachment; filename="yamtrack.csv"'}, + headers={"Content-Disposition": f'attachment; filename="yamtrack_{today}.csv"'}, ) response = exports.db_to_csv(response, request.user) diff --git a/src/templates/app/components/media_description.html b/src/templates/app/components/media_description.html index fc856b18..cbc7376a 100644 --- a/src/templates/app/components/media_description.html +++ b/src/templates/app/components/media_description.html @@ -17,7 +17,7 @@ {% endif %}
{{ media.synopsis }}
+{{ media.synopsis|safe }}