diff --git a/.env.example b/.env.example index 8611f813..a1089c44 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,7 @@ THEMOVIEDB_APIKEY= # The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance. PUBLIC_URL=http://localhost:5000 -# Use a builtin oidc service (google or discord): +# Use a builtin oidc service (google, discord, or simkl): # When you create a client_id, secret combo you may be asked for a redirect url. You need to specify https://YOUR-PUBLIC-URL/api/auth/logged/YOUR-SERVICE-NAME OIDC_DISCORD_CLIENTID= OIDC_DISCORD_SECRET= @@ -73,3 +73,7 @@ POSTGRES_PORT=5432 MEILI_HOST="http://meilisearch:7700" MEILI_MASTER_KEY="ghvjkgisbgkbgskegblfqbgjkebbhgwkjfb" + +RABBITMQ_HOST=rabbitmq +RABBITMQ_DEFAULT_USER=kyoo +RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml index 7c7ddb55..25e15b0c 100644 --- a/.github/workflows/coding-style.yml +++ b/.github/workflows/coding-style.yml @@ -39,11 +39,8 @@ jobs: run: yarn lint && yarn format scanner: - name: "Lint scanner" + name: "Lint scanner/autosync" runs-on: ubuntu-latest - defaults: - run: - working-directory: ./scanner steps: - uses: actions/checkout@v4 diff --git a/autosync/.gitignore b/autosync/.gitignore new file mode 100644 index 00000000..bee8a64b --- /dev/null +++ b/autosync/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/autosync/Dockerfile b/autosync/Dockerfile new file mode 100644 index 00000000..5cde2bfe --- /dev/null +++ b/autosync/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12 +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 new file mode 100644 index 00000000..e6e44ec2 --- /dev/null +++ b/autosync/autosync/__init__.py @@ -0,0 +1,66 @@ +import logging +import os + +import dataclasses_json +from datetime import datetime +from marshmallow import fields + +dataclasses_json.cfg.global_config.encoders[datetime] = datetime.isoformat +dataclasses_json.cfg.global_config.decoders[datetime] = datetime.fromisoformat +dataclasses_json.cfg.global_config.mm_fields[datetime] = fields.DateTime(format="iso") +dataclasses_json.cfg.global_config.encoders[datetime | None] = datetime.isoformat +dataclasses_json.cfg.global_config.decoders[datetime | None] = datetime.fromisoformat +dataclasses_json.cfg.global_config.mm_fields[datetime | None] = fields.DateTime( + format="iso" +) + +import pika +from pika import spec +from pika.adapters.blocking_connection import BlockingChannel +import pika.credentials +from autosync.models.message import Message +from autosync.services.aggregate import Aggregate + +from autosync.services.simkl import Simkl + + +logging.basicConfig(level=logging.INFO) +service = Aggregate([Simkl()]) + + +def on_message( + ch: BlockingChannel, + method: spec.Basic.Deliver, + properties: spec.BasicProperties, + body: bytes, +): + try: + message = Message.from_json(body) # type: Message + service.update(message.value.user, message.value.resource, message.value) + except Exception as e: + logging.exception("Error processing message.", exc_info=e) + logging.exception("Body: %s", body) + + +def main(): + connection = pika.BlockingConnection( + pika.ConnectionParameters( + host=os.environ.get("RABBITMQ_HOST", "rabbitmq"), + credentials=pika.credentials.PlainCredentials( + os.environ.get("RABBITMQ_DEFAULT_USER", "guest"), + os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"), + ), + ) + ) + channel = connection.channel() + + channel.exchange_declare(exchange="events.watched", exchange_type="topic") + result = channel.queue_declare("", exclusive=True) + queue_name = result.method.queue + channel.queue_bind(exchange="events.watched", queue=queue_name, routing_key="#") + + channel.basic_consume( + queue=queue_name, on_message_callback=on_message, auto_ack=True + ) + logging.info("Listening for autosync.") + channel.start_consuming() diff --git a/autosync/autosync/__main__.py b/autosync/autosync/__main__.py new file mode 100644 index 00000000..85d7bc52 --- /dev/null +++ b/autosync/autosync/__main__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +import autosync + +autosync.main() diff --git a/autosync/autosync/models/episode.py b/autosync/autosync/models/episode.py new file mode 100644 index 00000000..6378e148 --- /dev/null +++ b/autosync/autosync/models/episode.py @@ -0,0 +1,18 @@ +from typing import Literal +from dataclasses import dataclass +from dataclasses_json import dataclass_json, LetterCase + +from autosync.models.show import Show + +from .metadataid import MetadataID + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class Episode: + external_id: dict[str, MetadataID] + show: Show + season_number: int + episode_number: int + absolute_number: int + kind: Literal["episode"] diff --git a/autosync/autosync/models/message.py b/autosync/autosync/models/message.py new file mode 100644 index 00000000..94b3312e --- /dev/null +++ b/autosync/autosync/models/message.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from dataclasses_json import dataclass_json, LetterCase + +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 + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class WatchStatusMessage(WatchStatus): + user: User + resource: Movie | Show | Episode + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class Message: + action: str + type: str + value: WatchStatusMessage diff --git a/autosync/autosync/models/metadataid.py b/autosync/autosync/models/metadataid.py new file mode 100644 index 00000000..a9ec2267 --- /dev/null +++ b/autosync/autosync/models/metadataid.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from dataclasses_json import dataclass_json, LetterCase +from typing import Optional + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class MetadataID: + data_id: str + link: Optional[str] diff --git a/autosync/autosync/models/movie.py b/autosync/autosync/models/movie.py new file mode 100644 index 00000000..dd109ec6 --- /dev/null +++ b/autosync/autosync/models/movie.py @@ -0,0 +1,19 @@ +from typing import Literal, Optional +from datetime import datetime +from dataclasses import dataclass +from dataclasses_json import dataclass_json, LetterCase + +from .metadataid import MetadataID + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class Movie: + name: str + air_date: Optional[datetime] + external_id: dict[str, MetadataID] + kind: Literal["movie"] + + @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 new file mode 100644 index 00000000..261d215c --- /dev/null +++ b/autosync/autosync/models/show.py @@ -0,0 +1,19 @@ +from typing import Literal, Optional +from datetime import datetime +from dataclasses import dataclass +from dataclasses_json import dataclass_json, LetterCase + +from .metadataid import MetadataID + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class Show: + name: str + start_air: Optional[datetime] + external_id: dict[str, MetadataID] + kind: Literal["show"] + + @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 new file mode 100644 index 00000000..fe393499 --- /dev/null +++ b/autosync/autosync/models/user.py @@ -0,0 +1,34 @@ +from datetime import datetime, time +from dataclasses import dataclass +from dataclasses_json import dataclass_json, LetterCase +from typing import Optional + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class JwtToken: + token_type: str + access_token: str + refresh_token: Optional[str] + expire_in: time + expire_at: datetime + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class ExternalToken: + id: str + username: str + profileUrl: Optional[str] + token: JwtToken + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class 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 new file mode 100644 index 00000000..2e85b542 --- /dev/null +++ b/autosync/autosync/models/watch_status.py @@ -0,0 +1,23 @@ +from datetime import datetime +from dataclasses import dataclass +from dataclasses_json import dataclass_json, LetterCase +from typing import Optional +from enum import Enum + + +class Status(str, Enum): + COMPLETED = "Completed" + WATCHING = "Watching" + DROPED = "Droped" + PLANNED = "Planned" + DELETED = "Deleted" + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class WatchStatus: + 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 new file mode 100644 index 00000000..6044ed46 --- /dev/null +++ b/autosync/autosync/services/aggregate.py @@ -0,0 +1,26 @@ +import logging +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 + + +class Aggregate(Service): + def __init__(self, services: list[Service]): + self._services = [x for x in services if x.enabled] + logging.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: + logging.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 new file mode 100644 index 00000000..e6371135 --- /dev/null +++ b/autosync/autosync/services/service.py @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..c78319d9 --- /dev/null +++ b/autosync/autosync/services/simkl.py @@ -0,0 +1,115 @@ +import os +import requests +import logging +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 + + +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 resource.kind == "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, + }, + ) + logging.info("Simkl response: %s %s", resp.status_code, resp.text) + return + + category = "movies" if resource.kind == "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, + }, + ) + logging.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 new file mode 100644 index 00000000..84e5d38b --- /dev/null +++ b/autosync/pyproject.toml @@ -0,0 +1,2 @@ +[tool.ruff.format] +indent-style = "tab" diff --git a/autosync/requirements.txt b/autosync/requirements.txt new file mode 100644 index 00000000..0976c85c --- /dev/null +++ b/autosync/requirements.txt @@ -0,0 +1,3 @@ +pika +requests +dataclasses-json diff --git a/back/Dockerfile b/back/Dockerfile index 862dbbfe..aee9f87e 100644 --- a/back/Dockerfile +++ b/back/Dockerfile @@ -11,6 +11,7 @@ COPY src/Kyoo.Core/Kyoo.Core.csproj src/Kyoo.Core/Kyoo.Core.csproj COPY src/Kyoo.Host/Kyoo.Host.csproj src/Kyoo.Host/Kyoo.Host.csproj COPY src/Kyoo.Postgresql/Kyoo.Postgresql.csproj src/Kyoo.Postgresql/Kyoo.Postgresql.csproj COPY src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj +COPY src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj COPY src/Kyoo.Swagger/Kyoo.Swagger.csproj src/Kyoo.Swagger/Kyoo.Swagger.csproj RUN dotnet restore -a $TARGETARCH diff --git a/back/Dockerfile.dev b/back/Dockerfile.dev index 53213cbb..19be75df 100644 --- a/back/Dockerfile.dev +++ b/back/Dockerfile.dev @@ -11,6 +11,7 @@ COPY src/Kyoo.Core/Kyoo.Core.csproj src/Kyoo.Core/Kyoo.Core.csproj COPY src/Kyoo.Host/Kyoo.Host.csproj src/Kyoo.Host/Kyoo.Host.csproj COPY src/Kyoo.Postgresql/Kyoo.Postgresql.csproj src/Kyoo.Postgresql/Kyoo.Postgresql.csproj COPY src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj +COPY src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj COPY src/Kyoo.Swagger/Kyoo.Swagger.csproj src/Kyoo.Swagger/Kyoo.Swagger.csproj RUN dotnet restore @@ -19,4 +20,4 @@ EXPOSE 5000 ENV DOTNET_USE_POLLING_FILE_WATCHER 1 # HEALTHCHECK --interval=5s CMD curl --fail http://localhost:5000/health || exit HEALTHCHECK CMD true -ENTRYPOINT ["dotnet", "watch", "run", "--no-restore", "--project", "/app/src/Kyoo.Host"] +ENTRYPOINT ["dotnet", "watch", "--non-interactive", "run", "--no-restore", "--project", "/app/src/Kyoo.Host"] diff --git a/back/Kyoo.sln b/back/Kyoo.sln index db805de4..d8aac686 100644 --- a/back/Kyoo.sln +++ b/back/Kyoo.sln @@ -1,4 +1,5 @@ Microsoft Visual Studio Solution File, Format Version 12.00 +# Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kyoo.Core", "src\Kyoo.Core\Kyoo.Core.csproj", "{0F8275B6-C7DD-42DF-A168-755C81B1C329}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Abstractions", "src\Kyoo.Abstractions\Kyoo.Abstractions.csproj", "{BAB2CAE1-AC28-4509-AA3E-8DC75BD59220}" @@ -13,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Host", "src\Kyoo.Host\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Meilisearch", "src\Kyoo.Meilisearch\Kyoo.Meilisearch.csproj", "{F8E6018A-FD51-40EB-99FF-A26BA59F2762}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.RabbitMq", "src\Kyoo.RabbitMq\Kyoo.RabbitMq.csproj", "{B97AD4A8-E6E6-41CD-87DF-5F1326FD7198}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,5 +62,9 @@ Global {F8E6018A-FD51-40EB-99FF-A26BA59F2762}.Debug|Any CPU.Build.0 = Debug|Any CPU {F8E6018A-FD51-40EB-99FF-A26BA59F2762}.Release|Any CPU.ActiveCfg = Release|Any CPU {F8E6018A-FD51-40EB-99FF-A26BA59F2762}.Release|Any CPU.Build.0 = Release|Any CPU + {B97AD4A8-E6E6-41CD-87DF-5F1326FD7198}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B97AD4A8-E6E6-41CD-87DF-5F1326FD7198}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B97AD4A8-E6E6-41CD-87DF-5F1326FD7198}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B97AD4A8-E6E6-41CD-87DF-5F1326FD7198}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs b/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs index e21806cf..17ebe40c 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs @@ -29,12 +29,7 @@ namespace Kyoo.Abstractions.Controllers; /// public interface IWatchStatusRepository { - // /// - // /// The event handler type for all events of this repository. - // /// - // /// The resource created/modified/deleted - // /// A representing the asynchronous operation. - // public delegate Task ResourceEventHandler(T resource); + public delegate Task ResourceEventHandler(T resource); Task> GetAll( Filter? filter = default, @@ -52,12 +47,20 @@ public interface IWatchStatusRepository int? percent ); + static event ResourceEventHandler> OnMovieStatusChangedHandler; + protected static Task OnMovieStatusChanged(WatchStatus obj) => + OnMovieStatusChangedHandler?.Invoke(obj) ?? Task.CompletedTask; + Task DeleteMovieStatus(Guid movieId, Guid userId); Task GetShowStatus(Guid showId, Guid userId); Task SetShowStatus(Guid showId, Guid userId, WatchStatus status); + static event ResourceEventHandler> OnShowStatusChangedHandler; + protected static Task OnShowStatusChanged(WatchStatus obj) => + OnShowStatusChangedHandler?.Invoke(obj) ?? Task.CompletedTask; + Task DeleteShowStatus(Guid showId, Guid userId); Task GetEpisodeStatus(Guid episodeId, Guid userId); @@ -72,5 +75,9 @@ public interface IWatchStatusRepository int? percent ); + static event ResourceEventHandler> OnEpisodeStatusChangedHandler; + protected static Task OnEpisodeStatusChanged(WatchStatus obj) => + OnEpisodeStatusChangedHandler?.Invoke(obj) ?? Task.CompletedTask; + Task DeleteEpisodeStatus(Guid episodeId, Guid userId); } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs b/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs index 928cd640..d6576f5e 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs @@ -46,6 +46,11 @@ public enum WatchStatus /// The user has not started watching this but plans to. /// Planned, + + /// + /// The watch status was deleted and can not be retrived again. + /// + Deleted, } /// @@ -230,3 +235,45 @@ public class ShowWatchStatus : IAddedDate /// public int? WatchedPercent { get; set; } } + +public class WatchStatus : IAddedDate +{ + /// + /// Has the user started watching, is it planned? + /// + public required WatchStatus Status { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The date at which this item was played. + /// + public DateTime? PlayedDate { get; set; } + + /// + /// Where the player has stopped watching the episode (in seconds). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedTime { get; set; } + + /// + /// Where the player has stopped watching the episode (in percentage between 0 and 100). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedPercent { get; set; } + + /// + /// The user that started watching this episode. + /// + public required User User { get; set; } + + /// + /// The episode/show/movie whose status changed + /// + public required T Resource { get; set; } +} diff --git a/back/src/Kyoo.Core/Views/Helper/Serializers/WithKindResolver.cs b/back/src/Kyoo.Abstractions/Utility/JsonKindResolver.cs similarity index 75% rename from back/src/Kyoo.Core/Views/Helper/Serializers/WithKindResolver.cs rename to back/src/Kyoo.Abstractions/Utility/JsonKindResolver.cs index 0cf61131..45cdfdaa 100644 --- a/back/src/Kyoo.Core/Views/Helper/Serializers/WithKindResolver.cs +++ b/back/src/Kyoo.Abstractions/Utility/JsonKindResolver.cs @@ -28,9 +28,9 @@ using Kyoo.Abstractions.Models.Attributes; using Microsoft.AspNetCore.Http; using static System.Text.Json.JsonNamingPolicy; -namespace Kyoo.Core.Api; +namespace Kyoo.Utils; -public class WithKindResolver : DefaultJsonTypeInfoResolver +public class JsonKindResolver : DefaultJsonTypeInfoResolver { public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) { @@ -76,24 +76,4 @@ public class WithKindResolver : DefaultJsonTypeInfoResolver return jsonTypeInfo; } - - private static readonly IHttpContextAccessor _accessor = new HttpContextAccessor(); - - public static void HandleLoadableFields(JsonTypeInfo info) - { - foreach (JsonPropertyInfo prop in info.Properties) - { - object[] attributes = - prop.AttributeProvider?.GetCustomAttributes(typeof(LoadableRelationAttribute), true) - ?? Array.Empty(); - if (attributes.FirstOrDefault() is not LoadableRelationAttribute relation) - continue; - prop.ShouldSerialize = (_, _) => - { - if (_accessor?.HttpContext?.Items["fields"] is not ICollection fields) - return false; - return fields.Contains(prop.Name, StringComparer.InvariantCultureIgnoreCase); - }; - } - } } diff --git a/back/src/Kyoo.Abstractions/Utility/Utility.cs b/back/src/Kyoo.Abstractions/Utility/Utility.cs index a964d1e4..422d673d 100644 --- a/back/src/Kyoo.Abstractions/Utility/Utility.cs +++ b/back/src/Kyoo.Abstractions/Utility/Utility.cs @@ -23,6 +23,8 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace Kyoo.Utils; @@ -32,6 +34,14 @@ namespace Kyoo.Utils; /// public static class Utility { + public static readonly JsonSerializerOptions JsonOptions = + new() + { + TypeInfoResolver = new JsonKindResolver(), + Converters = { new JsonStringEnumConverter() }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + /// /// Convert a string to snake case. Stollen from /// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs diff --git a/back/src/Kyoo.Authentication/Controllers/OidcController.cs b/back/src/Kyoo.Authentication/Controllers/OidcController.cs index 8e9b9eec..d5909256 100644 --- a/back/src/Kyoo.Authentication/Controllers/OidcController.cs +++ b/back/src/Kyoo.Authentication/Controllers/OidcController.cs @@ -22,6 +22,7 @@ using System.ComponentModel.DataAnnotations; using System.Net.Http; using System.Net.Http.Json; using System.Text; +using System.Text.Json; using System.Threading.Tasks; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; @@ -46,21 +47,18 @@ public class OidcController( Encoding.UTF8.GetBytes($"{prov.ClientId}:{prov.Secret}") ); client.DefaultRequestHeaders.Add("Authorization", $"Basic {auth}"); - - HttpResponseMessage resp = await client.PostAsync( - prov.TokenUrl, - new FormUrlEncodedContent( - new Dictionary() - { - ["code"] = code, - ["client_id"] = prov.ClientId, - ["client_secret"] = prov.Secret, - ["redirect_uri"] = - $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", - ["grant_type"] = "authorization_code", - } - ) - ); + Dictionary data = + new() + { + ["code"] = code, + ["client_id"] = prov.ClientId, + ["client_secret"] = prov.Secret, + ["redirect_uri"] = $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", + ["grant_type"] = "authorization_code", + }; + HttpResponseMessage resp = prov.TokenUseJsonBody + ? await client.PostAsJsonAsync(prov.TokenUrl, data) + : await client.PostAsync(prov.TokenUrl, new FormUrlEncodedContent(data)); if (!resp.IsSuccessStatusCode) throw new ValidationException( $"Invalid code or configuration. {resp.StatusCode}: {await resp.Content.ReadAsStringAsync()}" @@ -71,22 +69,36 @@ public class OidcController( client.DefaultRequestHeaders.Remove("Authorization"); client.DefaultRequestHeaders.Add("Authorization", $"{token.TokenType} {token.AccessToken}"); + Dictionary? extraHeaders = prov.GetExtraHeaders?.Invoke(prov); + if (extraHeaders is not null) + { + foreach ((string key, string value) in extraHeaders) + client.DefaultRequestHeaders.Add(key, value); + } + JwtProfile? profile = await client.GetFromJsonAsync(prov.ProfileUrl); if (profile is null || profile.Sub is null) - throw new ValidationException("Missing sub on user object"); - ExternalToken extToken = new() { Id = profile.Sub, Token = token, }; + throw new ValidationException( + $"Missing sub on user object. Got: {JsonSerializer.Serialize(profile)}" + ); + ExternalToken extToken = + new() + { + Id = profile.Sub, + Token = token, + ProfileUrl = prov.GetProfileUrl?.Invoke(profile), + }; User newUser = new(); if (profile.Email is not null) newUser.Email = profile.Email; - string? username = profile.Username ?? profile.Name; - if (username is null) + if (profile.Username is null) { throw new ValidationException( $"Could not find a username for the user. You may need to add more scopes. Fields: {string.Join(',', profile.Extra)}" ); } - extToken.Username = username; - newUser.Username = username; + extToken.Username = profile.Username; + newUser.Username = profile.Username; newUser.Slug = Utils.Utility.ToSlug(newUser.Username); newUser.ExternalId.Add(provider, extToken); return (newUser, extToken); diff --git a/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs b/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs index cba47a5e..85272f8b 100644 --- a/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs +++ b/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs @@ -17,30 +17,57 @@ // along with Kyoo. If not, see . using System.Collections.Generic; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace Kyoo.Authentication.Models.DTO; public class JwtProfile { - public string Sub { get; set; } - public string Uid + public string? Sub { get; set; } + public string? Uid { - set => Sub = value; + set => Sub ??= value; } - public string Id + public string? Id { - set => Sub = value; + set => Sub ??= value; } - public string Guid + public string? Guid { - set => Sub = value; + set => Sub ??= value; } - public string? Name { get; set; } public string? Username { get; set; } + public string? Name + { + set => Username ??= value; + } + public string? Email { get; set; } + public JsonObject? Account + { + set + { + if (value is null) + return; + // simkl store their ids there. + Sub ??= value["id"]?.ToString(); + } + } + + public JsonObject? User + { + set + { + if (value is null) + return; + // simkl store their name there. + Username ??= value["name"]?.ToString(); + } + } + [JsonExtensionData] public Dictionary Extra { get; set; } } diff --git a/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs b/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs index 5dd06ecd..cd41c0bb 100644 --- a/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs +++ b/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs @@ -20,6 +20,7 @@ using System; using System.Collections.Generic; using System.Linq; using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Authentication.Models.DTO; namespace Kyoo.Authentication.Models; @@ -72,11 +73,20 @@ public class OidcProvider public string? LogoUrl { get; set; } public string AuthorizationUrl { get; set; } public string TokenUrl { get; set; } + + /// + /// Some token endpoints do net respect the spec and require a json body instead of a form url encoded. + /// + public bool TokenUseJsonBody { get; set; } + public string ProfileUrl { get; set; } public string? Scope { get; set; } public string ClientId { get; set; } public string Secret { get; set; } + public Func? GetProfileUrl { get; init; } + public Func>? GetExtraHeaders { get; init; } + public bool Enabled => AuthorizationUrl != null && TokenUrl != null @@ -97,6 +107,9 @@ public class OidcProvider Scope = KnownProviders[provider].Scope; ClientId = KnownProviders[provider].ClientId; Secret = KnownProviders[provider].Secret; + TokenUseJsonBody = KnownProviders[provider].TokenUseJsonBody; + GetProfileUrl = KnownProviders[provider].GetProfileUrl; + GetExtraHeaders = KnownProviders[provider].GetExtraHeaders; } } @@ -120,6 +133,20 @@ public class OidcProvider TokenUrl = "https://discord.com/api/oauth2/token", ProfileUrl = "https://discord.com/api/users/@me", Scope = "email+identify", - } + }, + ["simkl"] = new("simkl") + { + DisplayName = "Simkl", + LogoUrl = "https://logo.clearbit.com/simkl.com", + AuthorizationUrl = "https://simkl.com/oauth/authorize", + TokenUrl = "https://api.simkl.com/oauth/token", + ProfileUrl = "https://api.simkl.com/users/settings", + // does not seems to have scopes + Scope = null, + TokenUseJsonBody = true, + GetProfileUrl = (profile) => $"https://simkl.com/{profile.Sub}/dashboard/", + GetExtraHeaders = (OidcProvider self) => + new() { ["simkl-api-key"] = self.ClientId }, + }, }; } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs index e5a12cfc..40162a38 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs @@ -33,7 +33,15 @@ using Microsoft.Extensions.DependencyInjection; namespace Kyoo.Core.Controllers; -public class WatchStatusRepository : IWatchStatusRepository +public class WatchStatusRepository( + DatabaseContext database, + IRepository movies, + IRepository shows, + IRepository episodes, + IRepository users, + DbConnection db, + SqlVariableContext context +) : IWatchStatusRepository { /// /// If the watch percent is below this value, don't consider the item started. @@ -55,11 +63,6 @@ public class WatchStatusRepository : IWatchStatusRepository private WatchStatus Completed = WatchStatus.Completed; private WatchStatus Planned = WatchStatus.Planned; - private readonly DatabaseContext _database; - private readonly IRepository _movies; - private readonly DbConnection _db; - private readonly SqlVariableContext _context; - static WatchStatusRepository() { IRepository.OnCreated += async (ep) => @@ -74,23 +77,15 @@ public class WatchStatusRepository : IWatchStatusRepository .Select(x => x.UserId) .ToListAsync(); foreach (Guid userId in users) - await repo._SetShowStatus(ep.ShowId, userId, WatchStatus.Watching, true); + await repo._SetShowStatus( + ep.ShowId, + userId, + WatchStatus.Watching, + newEpisode: true + ); }; } - public WatchStatusRepository( - DatabaseContext database, - IRepository movies, - DbConnection db, - SqlVariableContext context - ) - { - _database = database; - _movies = movies; - _db = db; - _context = context; - } - // language=PostgreSQL protected FormattableString Sql => $""" @@ -169,11 +164,11 @@ public class WatchStatusRepository : IWatchStatusRepository /// public Task GetOrDefault(Guid id, Include? include = null) { - return _db.QuerySingle( + return db.QuerySingle( Sql, Config, Mapper, - _context, + context, include, new Filter.Eq(nameof(IResource.Id), id) ); @@ -208,12 +203,12 @@ public class WatchStatusRepository : IWatchStatusRepository limit.AfterID = null; } - return await _db.Query( + return await db.Query( Sql, Config, Mapper, (id) => Get(id), - _context, + context, include, filter, null, @@ -224,7 +219,7 @@ public class WatchStatusRepository : IWatchStatusRepository /// public Task GetMovieStatus(Guid movieId, Guid userId) { - return _database.MovieWatchStatus.FirstOrDefaultAsync(x => + return database.MovieWatchStatus.FirstOrDefaultAsync(x => x.MovieId == movieId && x.UserId == userId ); } @@ -238,7 +233,7 @@ public class WatchStatusRepository : IWatchStatusRepository int? percent ) { - Movie movie = await _movies.Get(movieId); + Movie movie = await movies.Get(movieId); if (percent == null && watchedTime != null && movie.Runtime > 0) percent = (int)Math.Round(watchedTime.Value / (movie.Runtime.Value * 60f) * 100f); @@ -274,25 +269,46 @@ public class WatchStatusRepository : IWatchStatusRepository AddedDate = DateTime.UtcNow, PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, }; - await _database + await database .MovieWatchStatus.Upsert(ret) .UpdateIf(x => status != Watching || x.Status != Completed) .RunAsync(); + await IWatchStatusRepository.OnMovieStatusChanged( + new() + { + User = await users.Get(ret.UserId), + Resource = await movies.Get(ret.MovieId), + Status = ret.Status, + WatchedTime = ret.WatchedTime, + WatchedPercent = ret.WatchedPercent, + AddedDate = ret.AddedDate, + PlayedDate = ret.PlayedDate, + } + ); return ret; } /// public async Task DeleteMovieStatus(Guid movieId, Guid userId) { - await _database + await database .MovieWatchStatus.Where(x => x.MovieId == movieId && x.UserId == userId) .ExecuteDeleteAsync(); + await IWatchStatusRepository.OnMovieStatusChanged( + new() + { + User = await users.Get(userId), + Resource = await movies.Get(movieId), + AddedDate = DateTime.UtcNow, + Status = WatchStatus.Deleted, + } + ); } /// public Task GetShowStatus(Guid showId, Guid userId) { - return _database.ShowWatchStatus.FirstOrDefaultAsync(x => + return database.ShowWatchStatus.FirstOrDefaultAsync(x => x.ShowId == showId && x.UserId == userId ); } @@ -305,12 +321,13 @@ public class WatchStatusRepository : IWatchStatusRepository Guid showId, Guid userId, WatchStatus status, - bool newEpisode = false + bool newEpisode = false, + bool skipStatusUpdate = false ) { int unseenEpisodeCount = status != WatchStatus.Completed - ? await _database + ? await database .Episodes.Where(x => x.ShowId == showId) .Where(x => x.Watched!.First(x => x.UserId == userId)!.Status != WatchStatus.Completed @@ -324,7 +341,7 @@ public class WatchStatusRepository : IWatchStatusRepository Guid? nextEpisodeId = null; if (status == WatchStatus.Watching) { - var cursor = await _database + var cursor = await database .Episodes.IgnoreQueryFilters() .Where(x => x.ShowId == showId) .OrderByDescending(x => x.AbsoluteNumber) @@ -346,7 +363,7 @@ public class WatchStatusRepository : IWatchStatusRepository nextEpisodeId = cursor?.Status.Status == WatchStatus.Watching ? cursor.Id - : await _database + : await database .Episodes.IgnoreQueryFilters() .Where(x => x.ShowId == showId) .OrderBy(x => x.AbsoluteNumber) @@ -374,11 +391,11 @@ public class WatchStatusRepository : IWatchStatusRepository } else if (status == WatchStatus.Completed) { - List episodes = await _database + List episodes = await database .Episodes.Where(x => x.ShowId == showId) .Select(x => x.Id) .ToListAsync(); - await _database + await database .EpisodeWatchStatus.UpsertRange( episodes.Select(episodeId => new EpisodeWatchStatus { @@ -412,29 +429,53 @@ public class WatchStatusRepository : IWatchStatusRepository UnseenEpisodesCount = unseenEpisodeCount, PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, }; - await _database + await database .ShowWatchStatus.Upsert(ret) .UpdateIf(x => status != Watching || x.Status != Completed || newEpisode) .RunAsync(); + if (!skipStatusUpdate) + { + await IWatchStatusRepository.OnShowStatusChanged( + new() + { + User = await users.Get(ret.UserId), + Resource = await shows.Get(ret.ShowId), + Status = ret.Status, + WatchedTime = ret.WatchedTime, + WatchedPercent = ret.WatchedPercent, + AddedDate = ret.AddedDate, + PlayedDate = ret.PlayedDate, + } + ); + } return ret; } /// public async Task DeleteShowStatus(Guid showId, Guid userId) { - await _database + await database .ShowWatchStatus.IgnoreAutoIncludes() .Where(x => x.ShowId == showId && x.UserId == userId) .ExecuteDeleteAsync(); - await _database + await database .EpisodeWatchStatus.Where(x => x.Episode.ShowId == showId && x.UserId == userId) .ExecuteDeleteAsync(); + await IWatchStatusRepository.OnShowStatusChanged( + new() + { + User = await users.Get(userId), + Resource = await shows.Get(showId), + AddedDate = DateTime.UtcNow, + Status = WatchStatus.Deleted, + } + ); } /// public Task GetEpisodeStatus(Guid episodeId, Guid userId) { - return _database.EpisodeWatchStatus.FirstOrDefaultAsync(x => + return database.EpisodeWatchStatus.FirstOrDefaultAsync(x => x.EpisodeId == episodeId && x.UserId == userId ); } @@ -448,7 +489,7 @@ public class WatchStatusRepository : IWatchStatusRepository int? percent ) { - Episode episode = await _database.Episodes.FirstAsync(x => x.Id == episodeId); + Episode episode = await database.Episodes.FirstAsync(x => x.Id == episodeId); if (percent == null && watchedTime != null && episode.Runtime > 0) percent = (int)Math.Round(watchedTime.Value / (episode.Runtime.Value * 60f) * 100f); @@ -484,19 +525,40 @@ public class WatchStatusRepository : IWatchStatusRepository AddedDate = DateTime.UtcNow, PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, }; - await _database + await database .EpisodeWatchStatus.Upsert(ret) .UpdateIf(x => status != Watching || x.Status != Completed) .RunAsync(); - await SetShowStatus(episode.ShowId, userId, WatchStatus.Watching); + await IWatchStatusRepository.OnEpisodeStatusChanged( + new() + { + User = await users.Get(ret.UserId), + Resource = await episodes.Get(episodeId, new(nameof(Episode.Show))), + Status = ret.Status, + WatchedTime = ret.WatchedTime, + WatchedPercent = ret.WatchedPercent, + AddedDate = ret.AddedDate, + PlayedDate = ret.PlayedDate, + } + ); + await _SetShowStatus(episode.ShowId, userId, WatchStatus.Watching, skipStatusUpdate: true); return ret; } /// public async Task DeleteEpisodeStatus(Guid episodeId, Guid userId) { - await _database + await database .EpisodeWatchStatus.Where(x => x.EpisodeId == episodeId && x.UserId == userId) .ExecuteDeleteAsync(); + await IWatchStatusRepository.OnEpisodeStatusChanged( + new() + { + User = await users.Get(userId), + Resource = await episodes.Get(episodeId, new(nameof(Episode.Show))), + AddedDate = DateTime.UtcNow, + Status = WatchStatus.Deleted, + } + ); } } diff --git a/back/src/Kyoo.Core/CoreModule.cs b/back/src/Kyoo.Core/CoreModule.cs index e7b27081..8d683b80 100644 --- a/back/src/Kyoo.Core/CoreModule.cs +++ b/back/src/Kyoo.Core/CoreModule.cs @@ -28,6 +28,7 @@ using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models.Utils; using Kyoo.Core.Api; using Kyoo.Core.Controllers; +using Kyoo.Utils; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; @@ -95,9 +96,9 @@ public class CoreModule : IPlugin }) .AddJsonOptions(x => { - x.JsonSerializerOptions.TypeInfoResolver = new WithKindResolver() + x.JsonSerializerOptions.TypeInfoResolver = new JsonKindResolver() { - Modifiers = { WithKindResolver.HandleLoadableFields } + Modifiers = { IncludeBinder.HandleLoadableFields } }; x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); x.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; diff --git a/back/src/Kyoo.Core/Views/Helper/IncludeBinder.cs b/back/src/Kyoo.Core/Views/Helper/IncludeBinder.cs index 3ddab91b..fd783946 100644 --- a/back/src/Kyoo.Core/Views/Helper/IncludeBinder.cs +++ b/back/src/Kyoo.Core/Views/Helper/IncludeBinder.cs @@ -17,9 +17,14 @@ // along with Kyoo. If not, see . using System; +using System.Collections.Generic; +using System.Linq; using System.Reflection; +using System.Text.Json.Serialization.Metadata; using System.Threading.Tasks; +using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; @@ -49,6 +54,26 @@ public class IncludeBinder : IModelBinder } } + private static readonly IHttpContextAccessor _accessor = new HttpContextAccessor(); + + public static void HandleLoadableFields(JsonTypeInfo info) + { + foreach (JsonPropertyInfo prop in info.Properties) + { + object[] attributes = + prop.AttributeProvider?.GetCustomAttributes(typeof(LoadableRelationAttribute), true) + ?? []; + if (attributes.FirstOrDefault() is not LoadableRelationAttribute relation) + continue; + prop.ShouldSerialize = (_, _) => + { + if (_accessor?.HttpContext?.Items["fields"] is not ICollection fields) + return false; + return fields.Contains(prop.Name, StringComparer.InvariantCultureIgnoreCase); + }; + } + } + public class Provider : IModelBinderProvider { public IModelBinder GetBinder(ModelBinderProviderContext context) diff --git a/back/src/Kyoo.Core/Views/Helper/Serializers/JsonSerializerContract.cs b/back/src/Kyoo.Core/Views/Helper/Serializers/JsonSerializerContract.cs deleted file mode 100644 index b3f5b0c7..00000000 --- a/back/src/Kyoo.Core/Views/Helper/Serializers/JsonSerializerContract.cs +++ /dev/null @@ -1,69 +0,0 @@ -// // Kyoo - A portable and vast media library solution. -// // Copyright (c) Kyoo. -// // -// // See AUTHORS.md and LICENSE file in the project root for full license information. -// // -// // Kyoo is free software: you can redistribute it and/or modify -// // it under the terms of the GNU General Public License as published by -// // the Free Software Foundation, either version 3 of the License, or -// // any later version. -// // -// // Kyoo is distributed in the hope that it will be useful, -// // but WITHOUT ANY WARRANTY; without even the implied warranty of -// // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// // GNU General Public License for more details. -// // -// // You should have received a copy of the GNU General Public License -// // along with Kyoo. If not, see . -// -// using System; -// using System.Collections.Generic; -// using System.Linq; -// using System.Net.Http.Formatting; -// using System.Reflection; -// using Kyoo.Abstractions.Models; -// using Kyoo.Abstractions.Models.Attributes; -// using Microsoft.AspNetCore.Http; -// using static System.Text.Json.JsonNamingPolicy; -// -// namespace Kyoo.Core.Api -// { -// /// -// /// A custom json serializer that respects and -// /// . It also handle via the -// /// fields query parameter and items. -// /// -// public class JsonSerializerContract(IHttpContextAccessor? httpContextAccessor, MediaTypeFormatter formatter) -// : JsonContractResolver(formatter) -// { -// /// -// protected override JsonProperty CreateProperty( -// MemberInfo member, -// MemberSerialization memberSerialization -// ) -// { -// JsonProperty property = base.CreateProperty(member, memberSerialization); -// -// LoadableRelationAttribute? relation = -// member.GetCustomAttribute(); -// if (relation != null) -// { -// if (httpContextAccessor != null) -// { -// property.ShouldSerialize = _ => -// { -// if ( -// httpContextAccessor.HttpContext!.Items["fields"] -// is not ICollection fields -// ) -// return false; -// return fields.Contains(member.Name); -// }; -// } -// else -// property.ShouldSerialize = _ => true; -// } -// return property; -// } -// } -// } diff --git a/back/src/Kyoo.Host/Kyoo.Host.csproj b/back/src/Kyoo.Host/Kyoo.Host.csproj index dfed95e7..418d06f9 100644 --- a/back/src/Kyoo.Host/Kyoo.Host.csproj +++ b/back/src/Kyoo.Host/Kyoo.Host.csproj @@ -26,6 +26,7 @@ + diff --git a/back/src/Kyoo.Host/PluginsStartup.cs b/back/src/Kyoo.Host/PluginsStartup.cs index ddd6a57b..40f4c3d1 100644 --- a/back/src/Kyoo.Host/PluginsStartup.cs +++ b/back/src/Kyoo.Host/PluginsStartup.cs @@ -27,6 +27,7 @@ using Kyoo.Core; using Kyoo.Host.Controllers; using Kyoo.Meiliseach; using Kyoo.Postgresql; +using Kyoo.RabbitMq; using Kyoo.Swagger; using Kyoo.Utils; using Microsoft.AspNetCore.Builder; @@ -66,6 +67,7 @@ public class PluginsStartup typeof(AuthenticationModule), typeof(PostgresModule), typeof(MeilisearchModule), + typeof(RabbitMqModule), typeof(SwaggerModule) ); } diff --git a/back/src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj b/back/src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj new file mode 100644 index 00000000..382b5b7c --- /dev/null +++ b/back/src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj @@ -0,0 +1,16 @@ + + + enable + enable + Kyoo.RabbitMq + + + + + + + + + + + diff --git a/back/src/Kyoo.RabbitMq/Message.cs b/back/src/Kyoo.RabbitMq/Message.cs new file mode 100644 index 00000000..72fa2f37 --- /dev/null +++ b/back/src/Kyoo.RabbitMq/Message.cs @@ -0,0 +1,40 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Text; +using System.Text.Json; +using Kyoo.Utils; + +namespace Kyoo.RabbitMq; + +public class Message +{ + public string Action { get; set; } + public string Type { get; set; } + public T Value { get; set; } + + public string AsRoutingKey() + { + return $"{Type}.{Action}"; + } + + public byte[] AsBytes() + { + return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(this, Utility.JsonOptions)); + } +} diff --git a/back/src/Kyoo.RabbitMq/RabbitMqModule.cs b/back/src/Kyoo.RabbitMq/RabbitMqModule.cs new file mode 100644 index 00000000..6aaacb55 --- /dev/null +++ b/back/src/Kyoo.RabbitMq/RabbitMqModule.cs @@ -0,0 +1,54 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Autofac; +using Kyoo.Abstractions.Controllers; +using Microsoft.Extensions.Configuration; +using RabbitMQ.Client; + +namespace Kyoo.RabbitMq; + +public class RabbitMqModule(IConfiguration configuration) : IPlugin +{ + /// + public string Name => "RabbitMq"; + + /// + public void Configure(ContainerBuilder builder) + { + builder + .Register( + (_) => + { + ConnectionFactory factory = + new() + { + UserName = configuration.GetValue("RABBITMQ_DEFAULT_USER", "guest"), + Password = configuration.GetValue("RABBITMQ_DEFAULT_PASS", "guest"), + HostName = configuration.GetValue("RABBITMQ_HOST", "rabbitmq"), + Port = 5672, + }; + + return factory.CreateConnection(); + } + ) + .AsSelf() + .SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance().AutoActivate(); + } +} diff --git a/back/src/Kyoo.RabbitMq/RabbitProducer.cs b/back/src/Kyoo.RabbitMq/RabbitProducer.cs new file mode 100644 index 00000000..f3809dfd --- /dev/null +++ b/back/src/Kyoo.RabbitMq/RabbitProducer.cs @@ -0,0 +1,106 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using RabbitMQ.Client; + +namespace Kyoo.RabbitMq; + +public class RabbitProducer +{ + private readonly IModel _channel; + + public RabbitProducer(IConnection rabbitConnection) + { + _channel = rabbitConnection.CreateModel(); + + _channel.ExchangeDeclare("events.resource", ExchangeType.Topic); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + + _channel.ExchangeDeclare("events.watched", ExchangeType.Topic); + IWatchStatusRepository.OnMovieStatusChangedHandler += _PublishWatchStatus("movie"); + IWatchStatusRepository.OnShowStatusChangedHandler += _PublishWatchStatus("show"); + IWatchStatusRepository.OnEpisodeStatusChangedHandler += _PublishWatchStatus( + "episode" + ); + } + + private void _ListenResourceEvents(string exchange) + where T : IResource, IQuery + { + string type = typeof(T).Name.ToLowerInvariant(); + + IRepository.OnCreated += _Publish(exchange, type, "created"); + IRepository.OnEdited += _Publish(exchange, type, "edited"); + IRepository.OnDeleted += _Publish(exchange, type, "deleted"); + } + + private IRepository.ResourceEventHandler _Publish( + string exchange, + string type, + string action + ) + where T : IResource, IQuery + { + return (T resource) => + { + Message message = + new() + { + Action = action, + Type = type, + Value = resource, + }; + _channel.BasicPublish( + exchange, + routingKey: message.AsRoutingKey(), + body: message.AsBytes() + ); + return Task.CompletedTask; + }; + } + + private IWatchStatusRepository.ResourceEventHandler> _PublishWatchStatus( + string resource + ) + { + return (status) => + { + Message> message = + new() + { + Type = resource, + Action = status.Status.ToString().ToLowerInvariant(), + Value = status, + }; + _channel.BasicPublish( + exchange: "events.watched", + routingKey: message.AsRoutingKey(), + body: message.AsBytes() + ); + return Task.CompletedTask; + }; + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2956226f..2fd6fc39 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,5 +1,3 @@ -version: "3.8" - x-transcoder: &transcoder-base build: context: ./transcoder @@ -35,6 +33,8 @@ services: condition: service_healthy meilisearch: condition: service_healthy + rabbitmq: + condition: service_healthy volumes: - ./back:/app - /app/out/ @@ -71,6 +71,15 @@ services: volumes: - ${LIBRARY_ROOT}:/video:ro + autosync: + build: ./autosync + restart: on-failure + depends_on: + rabbitmq: + condition: service_healthy + env_file: + - ./.env + transcoder: <<: *transcoder-base profiles: [''] @@ -147,10 +156,26 @@ services: - .env healthcheck: test: ["CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health"] - interval: 10s + interval: 30s timeout: 5s retries: 5 + rabbitmq: + image: rabbitmq:3-management-alpine + restart: on-failure + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} + ports: + - 5672:5672 + - 15672:15672 + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + volumes: kyoo: db: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6701b3de..f01dcabe 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,3 @@ -version: "3.8" - x-transcoder: &transcoder-base image: zoriya/kyoo_transcoder:edge networks: @@ -26,6 +24,8 @@ services: condition: service_healthy meilisearch: condition: service_healthy + rabbitmq: + condition: service_healthy volumes: - kyoo:/kyoo @@ -48,6 +48,15 @@ services: volumes: - ${LIBRARY_ROOT}:/video:ro + autosync: + build: ./autosync + restart: on-failure + depends_on: + rabbitmq: + condition: service_healthy + env_file: + - ./.env + transcoder: <<: *transcoder-base profiles: [''] @@ -120,10 +129,25 @@ services: - .env healthcheck: test: ["CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health"] - interval: 10s + interval: 30s timeout: 5s retries: 5 + rabbitmq: + image: rabbitmq:3-alpine + restart: on-failure + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} + ports: + - 5672:5672 + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + volumes: kyoo: db: diff --git a/docker-compose.yml b/docker-compose.yml index c30ed0f8..6d9abfda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - x-transcoder: &transcoder-base build: ./transcoder networks: @@ -25,6 +23,8 @@ services: condition: service_healthy meilisearch: condition: service_healthy + rabbitmq: + condition: service_healthy volumes: - kyoo:/kyoo @@ -47,6 +47,15 @@ services: volumes: - ${LIBRARY_ROOT}:/video:ro + autosync: + build: ./autosync + restart: on-failure + depends_on: + rabbitmq: + condition: service_healthy + env_file: + - ./.env + transcoder: <<: *transcoder-base profiles: [''] @@ -119,10 +128,25 @@ services: - .env healthcheck: test: ["CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health"] - interval: 10s + interval: 30s timeout: 5s retries: 5 + rabbitmq: + image: rabbitmq:3-alpine + restart: on-failure + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS} + ports: + - 5672:5672 + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + volumes: kyoo: db: diff --git a/front/packages/ui/src/settings/oidc.tsx b/front/packages/ui/src/settings/oidc.tsx index 8ee5bbd0..7d75180a 100644 --- a/front/packages/ui/src/settings/oidc.tsx +++ b/front/packages/ui/src/settings/oidc.tsx @@ -101,7 +101,6 @@ export const OidcSettings = () => { text={t("settings.oidc.link")} as={Link} href={x.link} - target="_blank" {...css({ minWidth: rem(6) })} /> )} diff --git a/scanner/pyproject.toml b/scanner/pyproject.toml index 84e5d38b..ce8becbf 100644 --- a/scanner/pyproject.toml +++ b/scanner/pyproject.toml @@ -1,2 +1,5 @@ [tool.ruff.format] indent-style = "tab" + +[tool.pyright] +reportAbstractUsage = false diff --git a/shell.nix b/shell.nix index 09055872..7c1b81ee 100644 --- a/shell.nix +++ b/shell.nix @@ -5,6 +5,9 @@ aiohttp jsons watchfiles + pika + requests + dataclasses-json ]); dotnet = with pkgs.dotnetCorePackages; combinePackages [