diff --git a/requirements.txt b/requirements.txt index f531ea708..97ad55029 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,4 +24,4 @@ python-decouple==3.8 redis[hiredis]==7.1.0 requests==2.32.5 requests-ratelimiter==0.8.0 -unidecode==1.4.0 +unidecode==1.4.0 \ No newline at end of file diff --git a/src/integrations/imports/watcharr.py b/src/integrations/imports/watcharr.py new file mode 100644 index 000000000..8b1200fa2 --- /dev/null +++ b/src/integrations/imports/watcharr.py @@ -0,0 +1,145 @@ +import json +import logging +import re + +from integrations.imports.yamtrack import YamtrackImporter + +logger = logging.getLogger(__name__) + + +class UnknownStateError(Exception): + """Custom exception for unexpected state string.""" + + +def importer(file, user, mode): + """Import media from Watcharr JSON file resuing the YamtrackImporter.""" + csv_importer = WatcharrImporter(file, user, mode) + return csv_importer.import_data() + + +def get_state(state): + """Convert the Watcharr status to a Yamtrack status.""" + match state: + case "FINISHED": + return "Completed" + case "WATCHING": + return "In progress" + case "PLANNED": + return "Planning" + case "PAUSED": + return "Paused" + case "DROPPED": + return "Dropped" + case _: + error_msg = f"Unknown state: '{state}'" + raise UnknownStateError(error_msg) + + +def to_date(date_str): + """Convert date to ISO 8601 without Z and only 6 digits for fractional seconds.""" + match = re.fullmatch( + r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})", + date_str, + ) + if not match: + error_msg = f"Unsupported timestamp format: {date_str}" + raise ValueError(error_msg) + + base, fraction, tz = match.groups() + + if tz == "Z": + tz = "+00:00" + + fraction = "000000" if fraction is None else fraction[:6].ljust(6, "0") + + return f"{base}.{fraction}{tz}" + + +class WatcharrImporter(YamtrackImporter): + """Class to handle importing user data from JSON files.""" + + def __init__(self, file, user, mode): + """Initialize the importer with file, user, and mode. + + Args: + file: Uploaded CSV file object + user: Django user object to import data for + mode (str): Import mode ("new" or "overwrite") + """ + super().__init__(file, user, mode) + self._rows = [] + + def _add_entry(self, media_type, content_entry, state_entry, dict_entry): + """Add a single entry to the list of rows.""" + dict_entry["media_type"] = media_type + dict_entry["source"] = "tmdb" + # when testing, in integrations/imports/helpers.py::update_season_references() + # existing_tv uses strings as keys: + dict_entry["media_id"] = str(content_entry["content"]["tmdbId"]) + dict_entry["title"] = content_entry["content"]["title"] + + dict_entry["score"] = state_entry["rating"] + dict_entry["status"] = get_state(state_entry["status"]) + dict_entry["created_at"] = to_date(state_entry["createdAt"]) + dict_entry["progressed_at"] = to_date(state_entry["updatedAt"]) + dict_entry["start_date"] = to_date(state_entry["createdAt"]) + dict_entry["end_date"] = ( + to_date(state_entry["updatedAt"]) + if state_entry["status"] == "FINISHED" + else "" + ) + + dict_entry["image"] = "" + dict_entry["notes"] = "" + + dict_entry.setdefault("season_number", "") + dict_entry.setdefault("episode_number", "") + dict_entry.setdefault("progress", "") + + self._rows.append(dict_entry) + + def get_iterator(self): + """Process the JSON file and return an array for YamtrackImporter.""" + self._rows = [] + json_structure = json.load(self.file) + + for entry in json_structure: + try: + self._process_entry(entry) + except Exception as error: + error_msg = f"Error processing entry: {entry}" + logger.exception(error_msg) + self.warnings.append(f"{error_msg}. Error: {error}") + return self._rows + + def _process_entry(self, entry): + """Process a single entry from the main array in the JSON file.""" + match entry["content"]["type"]: + case "movie": + self._add_entry( + "movie", + entry, + entry, + {}, + ) + case "tv": + self._add_entry("tv", entry, entry, {}) + if "watchedSeasons" in entry: + for season in entry["watchedSeasons"]: + self._add_entry( + "season", + entry, + season, + {"season_number": season["seasonNumber"]}, + ) + if "watchedEpisodes" in entry: + for episode in entry["watchedEpisodes"]: + self._add_entry( + "episode", + entry, + episode, + { + "season_number": episode["seasonNumber"], + "episode_number": episode["episodeNumber"], + }, + ) diff --git a/src/integrations/imports/yamtrack.py b/src/integrations/imports/yamtrack.py index c62db93da..ae8a81610 100644 --- a/src/integrations/imports/yamtrack.py +++ b/src/integrations/imports/yamtrack.py @@ -7,6 +7,7 @@ from django.utils.dateparse import parse_datetime import app +import app.forms from app import config from app.models import MediaTypes, Sources from app.providers import services @@ -54,15 +55,19 @@ def __init__(self, file, user, mode): mode, ) - def import_data(self): - """Import all user data from the CSV file.""" + def get_iterator(self): + """Return an iterator for the CSV file. Exists to be overridden.""" try: decoded_file = self.file.read().decode("utf-8").splitlines() except UnicodeDecodeError as e: msg = "Invalid file format. Please upload a CSV file." raise MediaImportError(msg) from e - reader = DictReader(decoded_file) + return DictReader(decoded_file) + + def import_data(self): + """Import all user data from the CSV file.""" + reader = self.get_iterator() for row in reader: try: diff --git a/src/integrations/tasks.py b/src/integrations/tasks.py index 3523866d9..203cc7dce 100644 --- a/src/integrations/tasks.py +++ b/src/integrations/tasks.py @@ -18,6 +18,7 @@ simkl, steam, trakt, + watcharr, yamtrack, ) @@ -137,3 +138,9 @@ def import_imdb(file, user_id, mode): def import_goodreads(file, user_id, mode): """Celery task for importing media data from GoodReads.""" return import_media(goodreads.importer, file, user_id, mode) + + +@shared_task(name="Import from Watcharr") +def import_watcharr(file, user_id, mode): + """Celery task for importing the Watcharr JSON export.""" + return import_media(watcharr.importer, file, user_id, mode) diff --git a/src/integrations/tests/imports/test_watcharr.py b/src/integrations/tests/imports/test_watcharr.py new file mode 100644 index 000000000..80a4988a2 --- /dev/null +++ b/src/integrations/tests/imports/test_watcharr.py @@ -0,0 +1,57 @@ +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from app.models import ( + TV, + Episode, + Movie, + Season, + Status, +) +from integrations.imports import ( + watcharr, +) + +mock_path = Path(__file__).resolve().parent.parent / "mock_data" +app_mock_path = ( + Path(__file__).resolve().parent.parent.parent.parent / "app" / "tests" / "mock_data" +) + + +class ImportWatcharr(TestCase): + """Test importing media from Watcharr JSON.""" + + def setUp(self): + """Create user for the tests.""" + self.credentials = {"username": "test", "password": "12345"} + self.user = get_user_model().objects.create_user(**self.credentials) + with Path(mock_path / "import_watcharr.json").open("rb") as file: + self.import_results = watcharr.importer(file, self.user, "new") + + def test_import_counts(self): + """Test basic counts of imported media.""" + self.assertEqual(TV.objects.filter(user=self.user).count(), 1) + self.assertEqual(Movie.objects.filter(user=self.user).count(), 2) + self.assertEqual(Season.objects.filter(user=self.user).count(), 3) + self.assertEqual( + Episode.objects.filter(related_season__user=self.user).count(), + 34, + ) + + def test_import_records(self): + """Test basic records of imported media.""" + jojo = Movie.objects.get(item__title="Jojo Rabbit", user=self.user) + self.assertEqual(jojo.status, Status.COMPLETED.value) + self.assertEqual(jojo.score, 10) + + avatar = Movie.objects.get( + item__title="Avatar: The Way of Water", user=self.user + ) + self.assertEqual(avatar.status, Status.DROPPED.value) + self.assertEqual(avatar.score, 3) + + ted = TV.objects.get(item__title="Ted Lasso", user=self.user) + self.assertEqual(ted.status, Status.COMPLETED.value) + self.assertEqual(ted.score, 10) diff --git a/src/integrations/tests/mock_data/import_watcharr.json b/src/integrations/tests/mock_data/import_watcharr.json new file mode 100644 index 000000000..239406595 --- /dev/null +++ b/src/integrations/tests/mock_data/import_watcharr.json @@ -0,0 +1,466 @@ +[ + { + "id": 1, + "createdAt": "2024-06-08T12:51:09.869791Z", + "updatedAt": "2025-09-10T20:00:27.522541796Z", + "deletedAt": null, + "status": "FINISHED", + "rating": 10, + "thoughts": "", + "pinned": false, + "content": { + "id": 5, + "tmdbId": 515001, + "title": "Jojo Rabbit", + "poster_path": "/1mqL7VG4Ix8wmxwypmCA1HTHBky.jpg", + "overview": "A World War II satire that follows a lonely German boy whose world view is turned upside down when he discovers his single mother is hiding a young Jewish girl in their attic. Aided only by his idiotic imaginary friend, Adolf Hitler, Jojo must confront his blind nationalism.", + "type": "movie", + "release_date": "2019-10-18T00:00:00Z", + "popularity": 4.4158, + "vote_average": 8, + "vote_count": 9948, + "imdb_id": "tt2584384", + "status": "Released", + "budget": 14000000, + "revenue": 82468705, + "runtime": 108, + "numberOfEpisodes": 0, + "numberOfSeasons": 0 + }, + "activity": [], + "tags": [] + }, + { + "id": 2, + "createdAt": "2024-07-26T15:16:49.6934216Z", + "updatedAt": "2024-08-02T20:22:57.407627351Z", + "deletedAt": null, + "status": "DROPPED", + "rating": 3, + "thoughts": "", + "pinned": false, + "content": { + "id": 1, + "tmdbId": 76600, + "title": "Avatar: The Way of Water", + "poster_path": "/t6HIqrRAclMCA60NsSmeqe9RmNV.jpg", + "overview": "Set more than a decade after the events of the first film, learn the story of the Sully family (Jake, Neytiri, and their kids), the trouble that follows them, the lengths they go to keep each other safe, the battles they fight to stay alive, and the tragedies they endure.", + "type": "movie", + "release_date": "2022-12-14T00:00:00Z", + "popularity": 139.086, + "vote_average": 7.623, + "vote_count": 11536, + "imdb_id": "tt1630029", + "status": "Released", + "budget": 460000000, + "revenue": 2320250281, + "runtime": 192, + "numberOfEpisodes": 0, + "numberOfSeasons": 0 + }, + "activity": [] + }, + { + "id": 11, + "createdAt": "2024-08-02T20:16:19.05521003Z", + "updatedAt": "2025-09-10T20:01:05.900075622Z", + "deletedAt": null, + "status": "FINISHED", + "rating": 10, + "thoughts": "", + "pinned": false, + "content": { + "id": 11, + "tmdbId": 97546, + "title": "Ted Lasso", + "poster_path": "/5fhZdwP1DVJ0FyVH6vrFdHwpXIn.jpg", + "overview": "Ted Lasso, an American football coach, moves to England when he's hired to manage a soccer team—despite having no experience. With cynical players and a doubtful town, will he get them to see the Ted Lasso Way?", + "type": "tv", + "release_date": "2020-08-14T00:00:00Z", + "popularity": 28.0278, + "vote_average": 8.4, + "vote_count": 2034, + "imdb_id": "", + "status": "Returning Series", + "budget": 0, + "revenue": 0, + "runtime": 0, + "numberOfEpisodes": 34, + "numberOfSeasons": 4 + }, + "activity": [], + "watchedSeasons": [ + { + "id": 1, + "createdAt": "2024-08-02T20:16:19.096418729Z", + "updatedAt": "2026-03-08T20:26:05.684478107Z", + "deletedAt": null, + "seasonNumber": 1, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 2, + "createdAt": "2024-08-02T20:16:19.111684249Z", + "updatedAt": "2026-03-08T20:26:05.684478107Z", + "deletedAt": null, + "seasonNumber": 2, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 3, + "createdAt": "2024-08-02T20:16:19.133908067Z", + "updatedAt": "2026-03-08T20:26:05.684478107Z", + "deletedAt": null, + "seasonNumber": 3, + "status": "FINISHED", + "rating": 0 + } + ], + "watchedEpisodes": [ + { + "id": 1, + "createdAt": "2024-08-02T20:16:19.185472315Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 1, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 2, + "createdAt": "2024-08-02T20:16:19.372598826Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 2, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 3, + "createdAt": "2024-08-02T20:16:19.391737511Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 3, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 4, + "createdAt": "2024-08-02T20:16:19.410261374Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 4, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 5, + "createdAt": "2024-08-02T20:16:19.433514116Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 5, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 6, + "createdAt": "2024-08-02T20:16:19.456541477Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 6, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 7, + "createdAt": "2024-08-02T20:16:19.483789161Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 7, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 8, + "createdAt": "2024-08-02T20:16:19.49849878Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 8, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 9, + "createdAt": "2024-08-02T20:16:19.518962061Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 9, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 10, + "createdAt": "2024-08-02T20:16:19.540003199Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 1, + "episodeNumber": 10, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 11, + "createdAt": "2024-08-02T20:16:19.563343162Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 1, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 12, + "createdAt": "2024-08-02T20:16:19.951053018Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 2, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 13, + "createdAt": "2024-08-02T20:16:19.97090959Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 3, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 14, + "createdAt": "2024-08-02T20:16:19.991326232Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 4, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 15, + "createdAt": "2024-08-02T20:16:20.011634951Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 5, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 16, + "createdAt": "2024-08-02T20:16:20.034759693Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 6, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 17, + "createdAt": "2024-08-02T20:16:20.069461463Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 7, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 18, + "createdAt": "2024-08-02T20:16:20.083116932Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 8, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 19, + "createdAt": "2024-08-02T20:16:20.103281397Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 9, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 20, + "createdAt": "2024-08-02T20:16:20.132899563Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 10, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 21, + "createdAt": "2024-08-02T20:16:20.15920945Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 11, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 22, + "createdAt": "2024-08-02T20:16:20.192382822Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 2, + "episodeNumber": 12, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 23, + "createdAt": "2024-08-02T20:16:20.211423518Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 1, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 24, + "createdAt": "2024-08-02T20:16:20.37960353Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 2, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 25, + "createdAt": "2024-08-02T20:16:20.404377209Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 3, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 26, + "createdAt": "2024-08-02T20:16:20.427046633Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 4, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 27, + "createdAt": "2024-08-02T20:16:20.465634548Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 5, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 28, + "createdAt": "2024-08-02T20:16:20.493039534Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 6, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 29, + "createdAt": "2024-08-02T20:16:20.518709543Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 7, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 30, + "createdAt": "2024-08-02T20:16:20.540156397Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 8, + "status": "FINISHED", + "rating": 0 + }, + { + "id": 31, + "createdAt": "2024-08-02T20:16:20.565623572Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 9, + "status": "WATCHING", + "rating": 0 + }, + { + "id": 32, + "createdAt": "2024-08-02T20:16:20.599088399Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 10, + "status": "PLANNED", + "rating": 0 + }, + { + "id": 33, + "createdAt": "2024-08-02T20:16:20.739867172Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 11, + "status": "PLANNED", + "rating": 0 + }, + { + "id": 34, + "createdAt": "2024-08-02T20:16:20.758313075Z", + "updatedAt": "2026-03-08T20:26:06.424287939Z", + "deletedAt": null, + "seasonNumber": 3, + "episodeNumber": 12, + "status": "PAUSED", + "rating": 0 + } + ], + "tags": [], + "lastViewedSeason": 0 + } +] \ No newline at end of file diff --git a/src/integrations/urls.py b/src/integrations/urls.py index f9d3e74cc..f3c54f37d 100644 --- a/src/integrations/urls.py +++ b/src/integrations/urls.py @@ -34,6 +34,7 @@ path("import/steam", views.import_steam, name="import_steam"), path("import/imdb", views.import_imdb, name="import_imdb"), path("import/goodreads", views.import_goodreads, name="import_goodreads"), + path("import/watcharr", views.import_watcharr, name="import_watcharr"), path("export/csv", views.export_csv, name="export_csv"), path( "webhook/jellyfin/", diff --git a/src/integrations/views.py b/src/integrations/views.py index 8c13eb650..21ae84677 100644 --- a/src/integrations/views.py +++ b/src/integrations/views.py @@ -408,6 +408,28 @@ def import_goodreads(request): return redirect("import_data") +@require_POST +def import_watcharr(request): + """View for importing books data from Watcharr JSON.""" + file = request.FILES.get("watcharr_json") + + if not file: + messages.error(request, "Watcharr JSON file is required.") + return redirect("import_data") + + mode = request.POST["mode"] + tasks.import_watcharr.delay( + file=request.FILES["watcharr_json"], + user_id=request.user.id, + mode=mode, + ) + messages.info( + request, + "The task to import media from Watcharr JSON file has been queued.", + ) + return redirect("import_data") + + @require_GET def export_csv(request): """View for exporting all media data to a CSV file.""" diff --git a/src/static/img/watcharr-logo.png b/src/static/img/watcharr-logo.png new file mode 100644 index 000000000..511dd570d Binary files /dev/null and b/src/static/img/watcharr-logo.png differ diff --git a/src/templates/users/import_data.html b/src/templates/users/import_data.html index d2ff52c2e..94500dc38 100644 --- a/src/templates/users/import_data.html +++ b/src/templates/users/import_data.html @@ -393,6 +393,33 @@

Import Sources

+ {# Watcharr #} +
+
{% source_display "watcharr" %}
+

+

+
+ {% csrf_token %} + + + + +
+
+ diff --git a/src/users/models.py b/src/users/models.py index cd18e007b..a0a0aeedd 100644 --- a/src/users/models.py +++ b/src/users/models.py @@ -611,6 +611,7 @@ def get_import_tasks(self): "steam": "Import from Steam", "imdb": "Import from IMDB", "goodreads": "Import from GoodReads", + "watcharr": "Import from Watcharr", } # Reverse mapping to get source from task name diff --git a/src/users/templatetags/user_tags.py b/src/users/templatetags/user_tags.py index e33c726c2..d8afd601e 100644 --- a/src/users/templatetags/user_tags.py +++ b/src/users/templatetags/user_tags.py @@ -52,6 +52,10 @@ def get_attr(obj, attr): "name": "GoodReads", "logo": static("img/logo-goodreads.svg"), }, + "watcharr": { + "name": "Watcharr", + "logo": static("img/watcharr-logo.png"), + }, }