diff --git a/autosync/.env.example b/autosync/.env.example deleted file mode 100644 index 90b614e7..00000000 --- a/autosync/.env.example +++ /dev/null @@ -1,12 +0,0 @@ -# vi: ft=sh -# shellcheck disable=SC2034 - -# RabbitMQ settings -# URL examples: https://docs.aio-pika.com/#url-examples -# This uses AIORMQ (https://github.com/mosquito/aiormq/) under the hood, and supports whatever the library supports. -# RABBITMQ_URL=ampqs://user:password@rabbitmq-server:1234/vhost?capath=/path/to/cacert.pem&certfile=/path/to/cert.pem&keyfile=/path/to/key.pem -# These values are ignored when the RABBITMQ_URL is set -RABBITMQ_HOST=rabbitmq -RABBITMQ_PORT=5672 -RABBITMQ_USER=guest -RABBITMQ_PASSWORD=guest diff --git a/autosync/.gitignore b/autosync/.gitignore deleted file mode 100644 index bee8a64b..00000000 --- a/autosync/.gitignore +++ /dev/null @@ -1 +0,0 @@ -__pycache__ diff --git a/autosync/Dockerfile b/autosync/Dockerfile deleted file mode 100644 index 86a14592..00000000 --- a/autosync/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM python:3.13 -WORKDIR /app - -COPY ./requirements.txt . -RUN pip3 install -r ./requirements.txt - -COPY . . -ENTRYPOINT ["python3", "-m", "autosync"] diff --git a/autosync/autosync/__init__.py b/autosync/autosync/__init__.py deleted file mode 100644 index 083dc892..00000000 --- a/autosync/autosync/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -async def main(): - import logging - from autosync.services.simkl import Simkl - from autosync.services.aggregate import Aggregate - from autosync.consumer import Consumer - - logging.basicConfig(level=logging.INFO) - - service = Aggregate([Simkl()]) - async with Consumer() as consumer: - await consumer.listen(service) diff --git a/autosync/autosync/__main__.py b/autosync/autosync/__main__.py deleted file mode 100644 index a410d7b2..00000000 --- a/autosync/autosync/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python - -import asyncio -import autosync - -asyncio.run(autosync.main()) diff --git a/autosync/autosync/consumer.py b/autosync/autosync/consumer.py deleted file mode 100644 index b2911eca..00000000 --- a/autosync/autosync/consumer.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -from msgspec import json -import os -from logging import getLogger -from aio_pika import ExchangeType, connect_robust -from aio_pika.abc import AbstractIncomingMessage - -from autosync.services.service import Service -from autosync.models.message import Message - - -logger = getLogger(__name__) - - -class Consumer: - QUEUE = "autosync" - - async def __aenter__(self): - self._con = await connect_robust( - os.environ.get("RABBITMQ_URL"), - host=os.environ.get("RABBITMQ_HOST", "rabbitmq"), - port=int(os.environ.get("RABBITMQ_PORT", "5672")), - login=os.environ.get("RABBITMQ_DEFAULT_USER", "guest"), - password=os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"), - ) - self._channel = await self._con.channel() - self._exchange = await self._channel.declare_exchange( - "events.watched", type=ExchangeType.TOPIC - ) - self._queue = await self._channel.declare_queue(self.QUEUE) - await self._queue.bind(self._exchange, routing_key="#") - return self - - async def __aexit__(self, exc_type, exc_value, exc_tb): - await self._con.close() - - async def listen(self, service: Service): - async def on_message(message: AbstractIncomingMessage): - try: - msg = json.decode(message.body, type=Message) - service.update(msg.value.user, msg.value.resource, msg.value) - await message.ack() - except Exception as e: - logger.exception("Unhandled error", exc_info=e) - await message.reject() - - # Allow up to 20 requests to run in parallel on the same listener. - # Since most work is calling API not doing that is a waste. - await self._channel.set_qos(prefetch_count=20) - await self._queue.consume(on_message) - logger.info("Listening for autosync.") - await asyncio.Future() diff --git a/autosync/autosync/models/episode.py b/autosync/autosync/models/episode.py deleted file mode 100644 index 0623ec52..00000000 --- a/autosync/autosync/models/episode.py +++ /dev/null @@ -1,12 +0,0 @@ -from msgspec import Struct -from autosync.models.show import Show - -from .metadataid import EpisodeID - - -class Episode(Struct, rename="camel", tag_field="kind", tag="episode"): - external_id: dict[str, EpisodeID] - show: Show - season_number: int - episode_number: int - absolute_number: int diff --git a/autosync/autosync/models/message.py b/autosync/autosync/models/message.py deleted file mode 100644 index 36d37d6c..00000000 --- a/autosync/autosync/models/message.py +++ /dev/null @@ -1,17 +0,0 @@ -from msgspec import Struct -from autosync.models.episode import Episode -from autosync.models.movie import Movie -from autosync.models.show import Show -from autosync.models.user import User -from autosync.models.watch_status import WatchStatus - - -class WatchStatusMessage(WatchStatus): - user: User - resource: Movie | Show | Episode - - -class Message(Struct, rename="camel"): - action: str - type: str - value: WatchStatusMessage diff --git a/autosync/autosync/models/metadataid.py b/autosync/autosync/models/metadataid.py deleted file mode 100644 index 13b67458..00000000 --- a/autosync/autosync/models/metadataid.py +++ /dev/null @@ -1,14 +0,0 @@ -from msgspec import Struct -from typing import Optional - - -class MetadataID(Struct, rename="camel"): - data_id: str - link: Optional[str] - - -class EpisodeID(Struct, rename="camel"): - show_id: str - season: Optional[int] - episode: int - link: Optional[str] diff --git a/autosync/autosync/models/movie.py b/autosync/autosync/models/movie.py deleted file mode 100644 index 873f1823..00000000 --- a/autosync/autosync/models/movie.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Optional -from datetime import date -from msgspec import Struct - -from .metadataid import MetadataID - - -class Movie(Struct, rename="camel", tag_field="kind", tag="movie"): - name: str - air_date: Optional[date] - external_id: dict[str, MetadataID] - - @property - def year(self): - return self.air_date.year if self.air_date is not None else None diff --git a/autosync/autosync/models/show.py b/autosync/autosync/models/show.py deleted file mode 100644 index 978b99b9..00000000 --- a/autosync/autosync/models/show.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Optional -from datetime import date -from msgspec import Struct - -from .metadataid import MetadataID - - -class Show(Struct, rename="camel", tag_field="kind", tag="show"): - name: str - start_air: Optional[date] - external_id: dict[str, MetadataID] - - @property - def year(self): - return self.start_air.year if self.start_air is not None else None diff --git a/autosync/autosync/models/user.py b/autosync/autosync/models/user.py deleted file mode 100644 index 51301a35..00000000 --- a/autosync/autosync/models/user.py +++ /dev/null @@ -1,26 +0,0 @@ -from msgspec import Struct -from datetime import datetime -from typing import Optional - - -class JwtToken(Struct): - token_type: str - access_token: str - refresh_token: Optional[str] - expire_at: datetime - - -class ExternalToken(Struct, rename="camel"): - id: str - username: str - profile_url: Optional[str] - token: JwtToken - - -class User(Struct, rename="camel", tag_field="kind", tag="user"): - id: str - username: str - email: str - permissions: list[str] - settings: dict[str, str] - external_id: dict[str, ExternalToken] diff --git a/autosync/autosync/models/watch_status.py b/autosync/autosync/models/watch_status.py deleted file mode 100644 index 815d8a66..00000000 --- a/autosync/autosync/models/watch_status.py +++ /dev/null @@ -1,21 +0,0 @@ -from datetime import datetime -from typing import Optional -from enum import Enum - -from msgspec import Struct - - -class Status(str, Enum): - COMPLETED = "Completed" - WATCHING = "Watching" - DROPED = "Droped" - PLANNED = "Planned" - DELETED = "Deleted" - - -class WatchStatus(Struct, rename="camel"): - added_date: datetime - played_date: Optional[datetime] - status: Status - watched_time: Optional[int] - watched_percent: Optional[int] diff --git a/autosync/autosync/services/aggregate.py b/autosync/autosync/services/aggregate.py deleted file mode 100644 index 2b5decb0..00000000 --- a/autosync/autosync/services/aggregate.py +++ /dev/null @@ -1,28 +0,0 @@ -from logging import getLogger -from autosync.services.service import Service -from ..models.user import User -from ..models.show import Show -from ..models.movie import Movie -from ..models.episode import Episode -from ..models.watch_status import WatchStatus - -logger = getLogger(__name__) - - -class Aggregate(Service): - def __init__(self, services: list[Service]): - self._services = [x for x in services if x.enabled] - logger.info("Autosync enabled with %s", [x.name for x in self._services]) - - @property - def name(self) -> str: - return "aggragate" - - def update(self, user: User, resource: Movie | Show | Episode, status: WatchStatus): - for service in self._services: - try: - service.update(user, resource, status) - except Exception as e: - logger.exception( - "Unhandled error on autosync %s:", service.name, exc_info=e - ) diff --git a/autosync/autosync/services/service.py b/autosync/autosync/services/service.py deleted file mode 100644 index e6371135..00000000 --- a/autosync/autosync/services/service.py +++ /dev/null @@ -1,21 +0,0 @@ -from abc import abstractmethod, abstractproperty - -from ..models.user import User -from ..models.show import Show -from ..models.movie import Movie -from ..models.episode import Episode -from ..models.watch_status import WatchStatus - - -class Service: - @abstractproperty - def name(self) -> str: - raise NotImplementedError - - @abstractproperty - def enabled(self) -> bool: - return True - - @abstractmethod - def update(self, user: User, resource: Movie | Show | Episode, status: WatchStatus): - raise NotImplementedError diff --git a/autosync/autosync/services/simkl.py b/autosync/autosync/services/simkl.py deleted file mode 100644 index b9183dd6..00000000 --- a/autosync/autosync/services/simkl.py +++ /dev/null @@ -1,117 +0,0 @@ -import os -import requests -from logging import getLogger -from autosync.models.metadataid import MetadataID - -from autosync.services.service import Service -from ..models.user import User -from ..models.show import Show -from ..models.movie import Movie -from ..models.episode import Episode -from ..models.watch_status import WatchStatus, Status - -logger = getLogger(__name__) - - -class Simkl(Service): - def __init__(self) -> None: - self._api_key = os.environ.get("OIDC_SIMKL_CLIENTID") - - @property - def name(self) -> str: - return "simkl" - - @property - def enabled(self) -> bool: - return self._api_key is not None - - def update(self, user: User, resource: Movie | Show | Episode, status: WatchStatus): - if "simkl" not in user.external_id or self._api_key is None: - return - - watch_date = status.played_date or status.added_date - - if isinstance(resource, Episode): - if status.status != Status.COMPLETED: - return - - resp = requests.post( - "https://api.simkl.com/sync/history", - json={ - "shows": [ - { - "watched_at": watch_date.isoformat(), - "title": resource.show.name, - "year": resource.show.year, - "ids": self._map_external_ids(resource.show.external_id), - "seasons": [ - { - "number": resource.season_number, - "episodes": [{"number": resource.episode_number}], - }, - { - "number": 1, - "episodes": [{"number": resource.absolute_number}], - }, - ], - } - ] - }, - headers={ - "Authorization": f"Bearer {user.external_id['simkl'].token.access_token}", - "simkl-api-key": self._api_key, - }, - ) - logger.info("Simkl response: %s %s", resp.status_code, resp.text) - return - - category = "movies" if isinstance(resource, Movie) else "shows" - - simkl_status = self._map_status(status.status) - if simkl_status is None: - return - - resp = requests.post( - "https://api.simkl.com/sync/add-to-list", - json={ - category: [ - { - "to": simkl_status, - "watched_at": watch_date.isoformat() - if status.status == Status.COMPLETED - else None, - "title": resource.name, - "year": resource.year, - "ids": self._map_external_ids(resource.external_id), - } - ] - }, - headers={ - "Authorization": f"Bearer {user.external_id['simkl'].token.access_token}", - "simkl-api-key": self._api_key, - }, - ) - logger.info("Simkl response: %s %s", resp.status_code, resp.text) - - def _map_status(self, status: Status): - match status: - case Status.COMPLETED: - return "completed" - case Status.WATCHING: - return "watching" - case Status.COMPLETED: - return "completed" - case Status.PLANNED: - return "plantowatch" - case Status.DELETED: - # do not delete items on simkl, most of deleted status are for a rewatch. - return None - case _: - return None - - def _map_external_ids(self, ids: dict[str, MetadataID]): - return {service: id.data_id for service, id in ids.items()} | { - "tmdb": int(ids["themoviedatabase"].data_id) - if "themoviedatabase" in ids - else None - } diff --git a/autosync/pyproject.toml b/autosync/pyproject.toml deleted file mode 100644 index 84e5d38b..00000000 --- a/autosync/pyproject.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tool.ruff.format] -indent-style = "tab" diff --git a/autosync/requirements.txt b/autosync/requirements.txt deleted file mode 100644 index dc75c80b..00000000 --- a/autosync/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -aio-pika -msgspec -requests