mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Add simkl automatic syncing (#347)
This commit is contained in:
commit
1cd3704bc3
@ -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
|
||||
|
5
.github/workflows/coding-style.yml
vendored
5
.github/workflows/coding-style.yml
vendored
@ -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
|
||||
|
||||
|
1
autosync/.gitignore
vendored
Normal file
1
autosync/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
__pycache__
|
8
autosync/Dockerfile
Normal file
8
autosync/Dockerfile
Normal file
@ -0,0 +1,8 @@
|
||||
FROM python:3.12
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./requirements.txt .
|
||||
RUN pip3 install -r ./requirements.txt
|
||||
|
||||
COPY . .
|
||||
ENTRYPOINT ["python3", "-m", "autosync"]
|
66
autosync/autosync/__init__.py
Normal file
66
autosync/autosync/__init__.py
Normal file
@ -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()
|
4
autosync/autosync/__main__.py
Normal file
4
autosync/autosync/__main__.py
Normal file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
import autosync
|
||||
|
||||
autosync.main()
|
18
autosync/autosync/models/episode.py
Normal file
18
autosync/autosync/models/episode.py
Normal file
@ -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"]
|
23
autosync/autosync/models/message.py
Normal file
23
autosync/autosync/models/message.py
Normal file
@ -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
|
10
autosync/autosync/models/metadataid.py
Normal file
10
autosync/autosync/models/metadataid.py
Normal file
@ -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]
|
19
autosync/autosync/models/movie.py
Normal file
19
autosync/autosync/models/movie.py
Normal file
@ -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
|
19
autosync/autosync/models/show.py
Normal file
19
autosync/autosync/models/show.py
Normal file
@ -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
|
34
autosync/autosync/models/user.py
Normal file
34
autosync/autosync/models/user.py
Normal file
@ -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]
|
23
autosync/autosync/models/watch_status.py
Normal file
23
autosync/autosync/models/watch_status.py
Normal file
@ -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]
|
26
autosync/autosync/services/aggregate.py
Normal file
26
autosync/autosync/services/aggregate.py
Normal file
@ -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
|
||||
)
|
21
autosync/autosync/services/service.py
Normal file
21
autosync/autosync/services/service.py
Normal file
@ -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
|
115
autosync/autosync/services/simkl.py
Normal file
115
autosync/autosync/services/simkl.py
Normal file
@ -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
|
||||
}
|
2
autosync/pyproject.toml
Normal file
2
autosync/pyproject.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[tool.ruff.format]
|
||||
indent-style = "tab"
|
3
autosync/requirements.txt
Normal file
3
autosync/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
pika
|
||||
requests
|
||||
dataclasses-json
|
@ -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
|
||||
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
@ -29,12 +29,7 @@ namespace Kyoo.Abstractions.Controllers;
|
||||
/// </summary>
|
||||
public interface IWatchStatusRepository
|
||||
{
|
||||
// /// <summary>
|
||||
// /// The event handler type for all events of this repository.
|
||||
// /// </summary>
|
||||
// /// <param name="resource">The resource created/modified/deleted</param>
|
||||
// /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
// public delegate Task ResourceEventHandler(T resource);
|
||||
public delegate Task ResourceEventHandler<T>(T resource);
|
||||
|
||||
Task<ICollection<IWatchlist>> GetAll(
|
||||
Filter<IWatchlist>? filter = default,
|
||||
@ -52,12 +47,20 @@ public interface IWatchStatusRepository
|
||||
int? percent
|
||||
);
|
||||
|
||||
static event ResourceEventHandler<WatchStatus<Movie>> OnMovieStatusChangedHandler;
|
||||
protected static Task OnMovieStatusChanged(WatchStatus<Movie> obj) =>
|
||||
OnMovieStatusChangedHandler?.Invoke(obj) ?? Task.CompletedTask;
|
||||
|
||||
Task DeleteMovieStatus(Guid movieId, Guid userId);
|
||||
|
||||
Task<ShowWatchStatus?> GetShowStatus(Guid showId, Guid userId);
|
||||
|
||||
Task<ShowWatchStatus?> SetShowStatus(Guid showId, Guid userId, WatchStatus status);
|
||||
|
||||
static event ResourceEventHandler<WatchStatus<Show>> OnShowStatusChangedHandler;
|
||||
protected static Task OnShowStatusChanged(WatchStatus<Show> obj) =>
|
||||
OnShowStatusChangedHandler?.Invoke(obj) ?? Task.CompletedTask;
|
||||
|
||||
Task DeleteShowStatus(Guid showId, Guid userId);
|
||||
|
||||
Task<EpisodeWatchStatus?> GetEpisodeStatus(Guid episodeId, Guid userId);
|
||||
@ -72,5 +75,9 @@ public interface IWatchStatusRepository
|
||||
int? percent
|
||||
);
|
||||
|
||||
static event ResourceEventHandler<WatchStatus<Episode>> OnEpisodeStatusChangedHandler;
|
||||
protected static Task OnEpisodeStatusChanged(WatchStatus<Episode> obj) =>
|
||||
OnEpisodeStatusChangedHandler?.Invoke(obj) ?? Task.CompletedTask;
|
||||
|
||||
Task DeleteEpisodeStatus(Guid episodeId, Guid userId);
|
||||
}
|
||||
|
@ -46,6 +46,11 @@ public enum WatchStatus
|
||||
/// The user has not started watching this but plans to.
|
||||
/// </summary>
|
||||
Planned,
|
||||
|
||||
/// <summary>
|
||||
/// The watch status was deleted and can not be retrived again.
|
||||
/// </summary>
|
||||
Deleted,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -230,3 +235,45 @@ public class ShowWatchStatus : IAddedDate
|
||||
/// </remarks>
|
||||
public int? WatchedPercent { get; set; }
|
||||
}
|
||||
|
||||
public class WatchStatus<T> : IAddedDate
|
||||
{
|
||||
/// <summary>
|
||||
/// Has the user started watching, is it planned?
|
||||
/// </summary>
|
||||
public required WatchStatus Status { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTime AddedDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date at which this item was played.
|
||||
/// </summary>
|
||||
public DateTime? PlayedDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the player has stopped watching the episode (in seconds).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null if the status is not Watching or if the next episode is not started.
|
||||
/// </remarks>
|
||||
public int? WatchedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the player has stopped watching the episode (in percentage between 0 and 100).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null if the status is not Watching or if the next episode is not started.
|
||||
/// </remarks>
|
||||
public int? WatchedPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user that started watching this episode.
|
||||
/// </summary>
|
||||
public required User User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The episode/show/movie whose status changed
|
||||
/// </summary>
|
||||
public required T Resource { get; set; }
|
||||
}
|
||||
|
@ -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<object>();
|
||||
if (attributes.FirstOrDefault() is not LoadableRelationAttribute relation)
|
||||
continue;
|
||||
prop.ShouldSerialize = (_, _) =>
|
||||
{
|
||||
if (_accessor?.HttpContext?.Items["fields"] is not ICollection<string> fields)
|
||||
return false;
|
||||
return fields.Contains(prop.Name, StringComparer.InvariantCultureIgnoreCase);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
/// </summary>
|
||||
public static class Utility
|
||||
{
|
||||
public static readonly JsonSerializerOptions JsonOptions =
|
||||
new()
|
||||
{
|
||||
TypeInfoResolver = new JsonKindResolver(),
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Convert a string to snake case. Stollen from
|
||||
/// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.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<string, string>()
|
||||
{
|
||||
["code"] = code,
|
||||
["client_id"] = prov.ClientId,
|
||||
["client_secret"] = prov.Secret,
|
||||
["redirect_uri"] =
|
||||
$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}",
|
||||
["grant_type"] = "authorization_code",
|
||||
}
|
||||
)
|
||||
);
|
||||
Dictionary<string, string> 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<string, string>? 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<JwtProfile>(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);
|
||||
|
@ -17,30 +17,57 @@
|
||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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<string, object> Extra { get; set; }
|
||||
}
|
||||
|
@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// Some token endpoints do net respect the spec and require a json body instead of a form url encoded.
|
||||
/// </summary>
|
||||
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<JwtProfile, string?>? GetProfileUrl { get; init; }
|
||||
public Func<OidcProvider, Dictionary<string, string>>? 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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -33,7 +33,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Kyoo.Core.Controllers;
|
||||
|
||||
public class WatchStatusRepository : IWatchStatusRepository
|
||||
public class WatchStatusRepository(
|
||||
DatabaseContext database,
|
||||
IRepository<Movie> movies,
|
||||
IRepository<Show> shows,
|
||||
IRepository<Episode> episodes,
|
||||
IRepository<User> users,
|
||||
DbConnection db,
|
||||
SqlVariableContext context
|
||||
) : IWatchStatusRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 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<Movie> _movies;
|
||||
private readonly DbConnection _db;
|
||||
private readonly SqlVariableContext _context;
|
||||
|
||||
static WatchStatusRepository()
|
||||
{
|
||||
IRepository<Episode>.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<Movie> 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
|
||||
/// <inheritdoc />
|
||||
public Task<IWatchlist?> GetOrDefault(Guid id, Include<IWatchlist>? include = null)
|
||||
{
|
||||
return _db.QuerySingle<IWatchlist>(
|
||||
return db.QuerySingle<IWatchlist>(
|
||||
Sql,
|
||||
Config,
|
||||
Mapper,
|
||||
_context,
|
||||
context,
|
||||
include,
|
||||
new Filter<IWatchlist>.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
|
||||
/// <inheritdoc />
|
||||
public Task<MovieWatchStatus?> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ShowWatchStatus?> 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<Guid> episodes = await _database
|
||||
List<Guid> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EpisodeWatchStatus?> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -17,9 +17,14 @@
|
||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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<string> fields)
|
||||
return false;
|
||||
return fields.Contains(prop.Name, StringComparer.InvariantCultureIgnoreCase);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class Provider : IModelBinderProvider
|
||||
{
|
||||
public IModelBinder GetBinder(ModelBinderProviderContext context)
|
||||
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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
|
||||
// {
|
||||
// /// <summary>
|
||||
// /// A custom json serializer that respects <see cref="SerializeIgnoreAttribute"/> and
|
||||
// /// <see cref="DeserializeIgnoreAttribute"/>. It also handle <see cref="LoadableRelationAttribute"/> via the
|
||||
// /// <c>fields</c> query parameter and <see cref="IThumbnails"/> items.
|
||||
// /// </summary>
|
||||
// public class JsonSerializerContract(IHttpContextAccessor? httpContextAccessor, MediaTypeFormatter formatter)
|
||||
// : JsonContractResolver(formatter)
|
||||
// {
|
||||
// /// <inheritdoc />
|
||||
// protected override JsonProperty CreateProperty(
|
||||
// MemberInfo member,
|
||||
// MemberSerialization memberSerialization
|
||||
// )
|
||||
// {
|
||||
// JsonProperty property = base.CreateProperty(member, memberSerialization);
|
||||
//
|
||||
// LoadableRelationAttribute? relation =
|
||||
// member.GetCustomAttribute<LoadableRelationAttribute>();
|
||||
// if (relation != null)
|
||||
// {
|
||||
// if (httpContextAccessor != null)
|
||||
// {
|
||||
// property.ShouldSerialize = _ =>
|
||||
// {
|
||||
// if (
|
||||
// httpContextAccessor.HttpContext!.Items["fields"]
|
||||
// is not ICollection<string> fields
|
||||
// )
|
||||
// return false;
|
||||
// return fields.Contains(member.Name);
|
||||
// };
|
||||
// }
|
||||
// else
|
||||
// property.ShouldSerialize = _ => true;
|
||||
// }
|
||||
// return property;
|
||||
// }
|
||||
// }
|
||||
// }
|
@ -26,6 +26,7 @@
|
||||
<ProjectReference Include="../Kyoo.Core/Kyoo.Core.csproj" />
|
||||
<ProjectReference Include="../Kyoo.Postgresql/Kyoo.Postgresql.csproj" />
|
||||
<ProjectReference Include="../Kyoo.Meilisearch/Kyoo.Meilisearch.csproj" />
|
||||
<ProjectReference Include="../Kyoo.RabbitMq/Kyoo.RabbitMq.csproj" />
|
||||
<ProjectReference Include="../Kyoo.Authentication/Kyoo.Authentication.csproj" />
|
||||
<ProjectReference Include="../Kyoo.Swagger/Kyoo.Swagger.csproj" />
|
||||
</ItemGroup>
|
||||
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
16
back/src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj
Normal file
16
back/src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj
Normal file
@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Kyoo.RabbitMq</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="RabbitMQ.Client" Version="6.8.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Kyoo.Abstractions/Kyoo.Abstractions.csproj" />
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
</Project>
|
40
back/src/Kyoo.RabbitMq/Message.cs
Normal file
40
back/src/Kyoo.RabbitMq/Message.cs
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Kyoo.Utils;
|
||||
|
||||
namespace Kyoo.RabbitMq;
|
||||
|
||||
public class Message<T>
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
54
back/src/Kyoo.RabbitMq/RabbitMqModule.cs
Normal file
54
back/src/Kyoo.RabbitMq/RabbitMqModule.cs
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
using Autofac;
|
||||
using Kyoo.Abstractions.Controllers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using RabbitMQ.Client;
|
||||
|
||||
namespace Kyoo.RabbitMq;
|
||||
|
||||
public class RabbitMqModule(IConfiguration configuration) : IPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "RabbitMq";
|
||||
|
||||
/// <inheritdoc />
|
||||
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<RabbitProducer>().AsSelf().SingleInstance().AutoActivate();
|
||||
}
|
||||
}
|
106
back/src/Kyoo.RabbitMq/RabbitProducer.cs
Normal file
106
back/src/Kyoo.RabbitMq/RabbitProducer.cs
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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<Collection>("events.resource");
|
||||
_ListenResourceEvents<Movie>("events.resource");
|
||||
_ListenResourceEvents<Show>("events.resource");
|
||||
_ListenResourceEvents<Season>("events.resource");
|
||||
_ListenResourceEvents<Episode>("events.resource");
|
||||
_ListenResourceEvents<Studio>("events.resource");
|
||||
_ListenResourceEvents<User>("events.resource");
|
||||
|
||||
_channel.ExchangeDeclare("events.watched", ExchangeType.Topic);
|
||||
IWatchStatusRepository.OnMovieStatusChangedHandler += _PublishWatchStatus<Movie>("movie");
|
||||
IWatchStatusRepository.OnShowStatusChangedHandler += _PublishWatchStatus<Show>("show");
|
||||
IWatchStatusRepository.OnEpisodeStatusChangedHandler += _PublishWatchStatus<Episode>(
|
||||
"episode"
|
||||
);
|
||||
}
|
||||
|
||||
private void _ListenResourceEvents<T>(string exchange)
|
||||
where T : IResource, IQuery
|
||||
{
|
||||
string type = typeof(T).Name.ToLowerInvariant();
|
||||
|
||||
IRepository<T>.OnCreated += _Publish<T>(exchange, type, "created");
|
||||
IRepository<T>.OnEdited += _Publish<T>(exchange, type, "edited");
|
||||
IRepository<T>.OnDeleted += _Publish<T>(exchange, type, "deleted");
|
||||
}
|
||||
|
||||
private IRepository<T>.ResourceEventHandler _Publish<T>(
|
||||
string exchange,
|
||||
string type,
|
||||
string action
|
||||
)
|
||||
where T : IResource, IQuery
|
||||
{
|
||||
return (T resource) =>
|
||||
{
|
||||
Message<T> message =
|
||||
new()
|
||||
{
|
||||
Action = action,
|
||||
Type = type,
|
||||
Value = resource,
|
||||
};
|
||||
_channel.BasicPublish(
|
||||
exchange,
|
||||
routingKey: message.AsRoutingKey(),
|
||||
body: message.AsBytes()
|
||||
);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
private IWatchStatusRepository.ResourceEventHandler<WatchStatus<T>> _PublishWatchStatus<T>(
|
||||
string resource
|
||||
)
|
||||
{
|
||||
return (status) =>
|
||||
{
|
||||
Message<WatchStatus<T>> 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;
|
||||
};
|
||||
}
|
||||
}
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -101,7 +101,6 @@ export const OidcSettings = () => {
|
||||
text={t("settings.oidc.link")}
|
||||
as={Link}
|
||||
href={x.link}
|
||||
target="_blank"
|
||||
{...css({ minWidth: rem(6) })}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,2 +1,5 @@
|
||||
[tool.ruff.format]
|
||||
indent-style = "tab"
|
||||
|
||||
[tool.pyright]
|
||||
reportAbstractUsage = false
|
||||
|
Loading…
x
Reference in New Issue
Block a user