Add simkl automatic syncing (#347)

This commit is contained in:
Zoe Roux 2024-03-23 16:01:52 +01:00 committed by GitHub
commit 1cd3704bc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1016 additions and 188 deletions

View File

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

View File

@ -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
View File

@ -0,0 +1 @@
__pycache__

8
autosync/Dockerfile Normal file
View 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"]

View 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()

View File

@ -0,0 +1,4 @@
#!/usr/bin/env python
import autosync
autosync.main()

View 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"]

View 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

View 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]

View 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

View 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

View 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]

View 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]

View 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
)

View 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

View 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
View File

@ -0,0 +1,2 @@
[tool.ruff.format]
indent-style = "tab"

View File

@ -0,0 +1,3 @@
pika
requests
dataclasses-json

View File

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

View File

@ -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"]

View File

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

View File

@ -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);
}

View File

@ -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; }
}

View File

@ -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);
};
}
}
}

View File

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

View File

@ -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>()
Dictionary<string, string> data =
new()
{
["code"] = code,
["client_id"] = prov.ClientId,
["client_secret"] = prov.Secret,
["redirect_uri"] =
$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}",
["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);

View File

@ -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; }
}

View File

@ -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 },
},
};
}

View File

@ -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,
}
);
}
}

View File

@ -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;

View File

@ -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)

View File

@ -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;
// }
// }
// }

View File

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

View File

@ -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)
);
}

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

View 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));
}
}

View 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();
}
}

View 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;
};
}
}

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -101,7 +101,6 @@ export const OidcSettings = () => {
text={t("settings.oidc.link")}
as={Link}
href={x.link}
target="_blank"
{...css({ minWidth: rem(6) })}
/>
)}

View File

@ -1,2 +1,5 @@
[tool.ruff.format]
indent-style = "tab"
[tool.pyright]
reportAbstractUsage = false

View File

@ -5,6 +5,9 @@
aiohttp
jsons
watchfiles
pika
requests
dataclasses-json
]);
dotnet = with pkgs.dotnetCorePackages;
combinePackages [