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
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 [