From 75595d1136e32a17faf156d0eb9b577ccf2bdf9d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 26 Oct 2025 10:50:05 +0100 Subject: [PATCH] Initial copy --- .config/dotnet-tools.json | 18 + .dockerignore | 17 + .editorconfig | 99 ++ .env.example | 37 + .gitignore | 352 +++++ Dockerfile | 29 + Dockerfile.dev | 22 + Dockerfile.migrations | 30 + Kyoo.ruleset | 47 + Kyoo.sln | 64 + README.md | 5 + ef.rsp | 4 + icon.ico | Bin 0 -> 107761 bytes nuget.config | 14 + shell.nix | 15 + src/Directory.Build.props | 47 + src/Kyoo.Abstractions/.gitignore | 232 +++ .../Controllers/IIssueRepository.cs | 35 + .../Controllers/ILibraryManager.cs | 80 + .../Controllers/IPermissionValidator.cs | 46 + .../Controllers/IRepository.cs | 267 ++++ src/Kyoo.Abstractions/Controllers/IScanner.cs | 28 + .../Controllers/ISearchManager.cs | 125 ++ .../Controllers/IThumbnailsManager.cs | 43 + .../Controllers/IWatchStatusRepository.cs | 83 + src/Kyoo.Abstractions/Extensions.cs | 64 + .../Kyoo.Abstractions.csproj | 17 + .../Attributes/ApiDefinitionAttribute.cs | 51 + .../Models/Attributes/ComputedAttribute.cs | 27 + .../Attributes/LoadableRelationAttribute.cs | 53 + .../Attributes/NotMergeableAttribute.cs | 39 + .../Models/Attributes/OneOfAttribute.cs | 33 + .../Permission/PartialPermissionAttribute.cs | 87 + .../Permission/PermissionAttribute.cs | 136 ++ .../Permission/UserOnlyAttribute.cs | 30 + .../Attributes/SqlFirstColumnAttribute.cs | 37 + .../Exceptions/DuplicatedItemException.cs | 34 + .../Exceptions/ItemNotFoundException.cs | 41 + .../Exceptions/UnauthorizedException.cs | 31 + src/Kyoo.Abstractions/Models/Genre.cs | 50 + src/Kyoo.Abstractions/Models/ILibraryItem.cs | 31 + src/Kyoo.Abstractions/Models/INews.cs | 31 + src/Kyoo.Abstractions/Models/IWatchlist.cs | 27 + src/Kyoo.Abstractions/Models/Issues.cs | 52 + src/Kyoo.Abstractions/Models/MetadataID.cs | 61 + src/Kyoo.Abstractions/Models/Page.cs | 105 ++ src/Kyoo.Abstractions/Models/Patch.cs | 46 + .../Models/Resources/Collection.cs | 100 ++ .../Models/Resources/Episode.cs | 302 ++++ .../Models/Resources/Interfaces/IAddedDate.cs | 32 + .../Models/Resources/Interfaces/IMetadata.cs | 32 + .../Models/Resources/Interfaces/IQuery.cs | 30 + .../Resources/Interfaces/IRefreshable.cs | 40 + .../Models/Resources/Interfaces/IResource.cs | 49 + .../Resources/Interfaces/IThumbnails.cs | 147 ++ .../Models/Resources/JwtToken.cs | 65 + .../Models/Resources/Movie.cs | 192 +++ .../Models/Resources/Season.cs | 151 ++ .../Models/Resources/Show.cs | 283 ++++ .../Models/Resources/Studio.cs | 80 + .../Models/Resources/User.cs | 116 ++ .../Models/Resources/WatchStatus.cs | 279 ++++ src/Kyoo.Abstractions/Models/SearchPage.cs | 53 + src/Kyoo.Abstractions/Models/Utils/Claims.cs | 55 + .../Models/Utils/Constants.cs | 60 + src/Kyoo.Abstractions/Models/Utils/Filter.cs | 369 +++++ .../Models/Utils/Identifier.cs | 245 +++ src/Kyoo.Abstractions/Models/Utils/Include.cs | 109 ++ .../Models/Utils/Pagination.cs | 72 + .../Models/Utils/RequestError.cs | 56 + .../Models/Utils/SearchPagination.cs | 35 + src/Kyoo.Abstractions/Models/Utils/Sort.cs | 137 ++ src/Kyoo.Abstractions/Models/VideoLinks.cs | 35 + .../Utility/ExpressionParameterReplacer.cs | 51 + .../Utility/JsonKindResolver.cs | 78 + src/Kyoo.Abstractions/Utility/Utility.cs | 212 +++ src/Kyoo.Abstractions/Utility/Wrapper.cs | 47 + .../Attributes/DisableOnEnvVarAttribute.cs | 22 + .../AuthenticationModule.cs | 165 ++ .../Controllers/ITokenController.cs | 53 + .../Controllers/OidcController.cs | 143 ++ .../Controllers/PermissionValidator.cs | 284 ++++ .../Controllers/TokenController.cs | 116 ++ .../Kyoo.Authentication.csproj | 11 + .../Models/DTO/JwtProfile.cs | 77 + .../Models/DTO/LoginRequest.cs | 46 + .../Models/DTO/PasswordResetRequest.cs | 38 + .../Models/DTO/RegisterRequest.cs | 76 + .../Models/DTO/ServerInfo.cs | 98 ++ .../Models/Options/AuthenticationOption.cs | 24 + .../Models/Options/PermissionOption.cs | 180 +++ src/Kyoo.Authentication/Views/AuthApi.cs | 501 ++++++ src/Kyoo.Core/.gitignore | 234 +++ .../Controllers/Base64RouteConstraint.cs | 42 + .../Controllers/IdentifierRouteConstraint.cs | 41 + src/Kyoo.Core/Controllers/LibraryManager.cs | 105 ++ src/Kyoo.Core/Controllers/MiscRepository.cs | 154 ++ .../Repositories/CollectionRepository.cs | 71 + .../Controllers/Repositories/DapperHelper.cs | 415 +++++ .../Repositories/DapperRepository.cs | 221 +++ .../Controllers/Repositories/EfHelpers.cs | 140 ++ .../Repositories/EpisodeRepository.cs | 140 ++ .../Repositories/GenericRepository.cs | 369 +++++ .../Repositories/IssueRepository.cs | 54 + .../Repositories/LibraryItemRepository.cs | 124 ++ .../Repositories/MovieRepository.cs | 83 + .../Repositories/NewsRepository.cs | 63 + .../Repositories/RepositoryHelper.cs | 128 ++ .../Repositories/SeasonRepository.cs | 85 + .../Repositories/ShowRepository.cs | 83 + .../Repositories/StudioRepository.cs | 45 + .../Repositories/UserRepository.cs | 113 ++ .../Repositories/WatchStatusRepository.cs | 564 +++++++ .../Controllers/ThumbnailsManager.cs | 219 +++ src/Kyoo.Core/CoreModule.cs | 94 ++ src/Kyoo.Core/ExceptionFilter.cs | 82 + src/Kyoo.Core/Extensions/ServiceExtensions.cs | 90 ++ src/Kyoo.Core/Kyoo.Core.csproj | 44 + src/Kyoo.Core/Program.cs | 113 ++ src/Kyoo.Core/Storage/FileStorage.cs | 51 + src/Kyoo.Core/Storage/IStorage.cs | 33 + src/Kyoo.Core/Storage/S3Storage.cs | 78 + src/Kyoo.Core/Views/Admin/Health.cs | 63 + src/Kyoo.Core/Views/Admin/Misc.cs | 97 ++ src/Kyoo.Core/Views/Content/ThumbnailsApi.cs | 68 + src/Kyoo.Core/Views/Content/VideoApi.cs | 145 ++ src/Kyoo.Core/Views/Helper/BaseApi.cs | 99 ++ src/Kyoo.Core/Views/Helper/CrudApi.cs | 299 ++++ src/Kyoo.Core/Views/Helper/CrudThumbsApi.cs | 124 ++ src/Kyoo.Core/Views/Helper/FilterBinder.cs | 60 + src/Kyoo.Core/Views/Helper/IncludeBinder.cs | 89 ++ src/Kyoo.Core/Views/Helper/SortBinder.cs | 69 + src/Kyoo.Core/Views/Helper/Transcoder.cs | 150 ++ src/Kyoo.Core/Views/InfoApi.cs | 67 + src/Kyoo.Core/Views/Metadata/IssueApi.cs | 119 ++ src/Kyoo.Core/Views/Metadata/StudioApi.cs | 102 ++ .../Views/Resources/CollectionApi.cs | 270 ++++ src/Kyoo.Core/Views/Resources/EpisodeApi.cs | 221 +++ .../Views/Resources/LibraryItemApi.cs | 38 + src/Kyoo.Core/Views/Resources/MovieApi.cs | 232 +++ src/Kyoo.Core/Views/Resources/NewsApi.cs | 36 + src/Kyoo.Core/Views/Resources/SearchApi.cs | 216 +++ src/Kyoo.Core/Views/Resources/SeasonApi.cs | 138 ++ src/Kyoo.Core/Views/Resources/ShowApi.cs | 295 ++++ src/Kyoo.Core/Views/Resources/UserApi.cs | 116 ++ src/Kyoo.Core/Views/Resources/WatchlistApi.cs | 71 + .../FilterExtensionMethods.cs | 52 + src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj | 15 + src/Kyoo.Meilisearch/MeiliSync.cs | 112 ++ src/Kyoo.Meilisearch/MeilisearchModule.cs | 160 ++ src/Kyoo.Meilisearch/SearchManager.cs | 229 +++ src/Kyoo.Postgresql/DatabaseContext.cs | 476 ++++++ .../DbConfigurationProvider.cs | 28 + src/Kyoo.Postgresql/Kyoo.Postgresql.csproj | 28 + .../20231128171554_Initial.Designer.cs | 1082 +++++++++++++ .../Migrations/20231128171554_Initial.cs | 560 +++++++ .../20231204000849_Watchlist.Designer.cs | 1299 +++++++++++++++ .../Migrations/20231204000849_Watchlist.cs | 220 +++ .../20231220093441_Settings.Designer.cs | 1304 +++++++++++++++ .../Migrations/20231220093441_Settings.cs | 27 + ...20240120154137_RuntimeNullable.Designer.cs | 1307 +++++++++++++++ .../20240120154137_RuntimeNullable.cs | 57 + .../20240204193443_RemoveUserLogo.Designer.cs | 1276 +++++++++++++++ .../20240204193443_RemoveUserLogo.cs | 34 + .../20240217143306_AddIssues.Designer.cs | 1309 ++++++++++++++++ .../Migrations/20240217143306_AddIssues.cs | 67 + ...240219170615_AddPlayPermission.Designer.cs | 1309 ++++++++++++++++ .../20240219170615_AddPlayPermission.cs | 21 + ...240229202049_AddUserExternalId.Designer.cs | 1314 ++++++++++++++++ .../20240229202049_AddUserExternalId.cs | 27 + ...302151906_MakePasswordOptional.Designer.cs | 1313 ++++++++++++++++ .../20240302151906_MakePasswordOptional.cs | 37 + .../20240324174638_UseDateOnly.Designer.cs | 1317 ++++++++++++++++ .../Migrations/20240324174638_UseDateOnly.cs | 177 +++ .../20240401213942_AddGenres.Designer.cs | 1380 ++++++++++++++++ .../Migrations/20240401213942_AddGenres.cs | 47 + .../20240414212454_AddNextRefresh.Designer.cs | 1347 ++++++++++++++++ .../20240414212454_AddNextRefresh.cs | 89 ++ .../20240420124608_ReworkImages.Designer.cs | 1375 ++++++++++++++++ .../Migrations/20240420124608_ReworkImages.cs | 464 ++++++ ...0240423151632_AddServerOptions.Designer.cs | 1395 +++++++++++++++++ .../20240423151632_AddServerOptions.cs | 43 + ...0506175054_FixSeasonMetadataId.Designer.cs | 1395 +++++++++++++++++ .../20240506175054_FixSeasonMetadataId.cs | 33 + .../PostgresContextModelSnapshot.cs | 1392 ++++++++++++++++ src/Kyoo.Postgresql/PostgresContext.cs | 147 ++ src/Kyoo.Postgresql/PostgresModule.cs | 114 ++ src/Kyoo.Postgresql/Utils/JsonTypeHandler.cs | 42 + src/Kyoo.Postgresql/Utils/ListTypeHandler.cs | 39 + src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj | 15 + src/Kyoo.RabbitMq/Message.cs | 40 + src/Kyoo.RabbitMq/RabbitMqModule.cs | 223 +++ src/Kyoo.RabbitMq/RabbitProducer.cs | 132 ++ src/Kyoo.RabbitMq/ScannerProducer.cs | 87 + src/Kyoo.Swagger/ApiSorter.cs | 65 + src/Kyoo.Swagger/ApiTagsFilter.cs | 123 ++ src/Kyoo.Swagger/GenericResponseProvider.cs | 68 + src/Kyoo.Swagger/Kyoo.Swagger.csproj | 12 + src/Kyoo.Swagger/Models/TagGroups.cs | 41 + .../OperationPermissionProcessor.cs | 102 ++ src/Kyoo.Swagger/SwaggerModule.cs | 114 ++ stylecop.json | 9 + tests/.gitignore | 1 + tests/auth/auth.robot | 82 + tests/pyproject.toml | 4 + tests/rest.resource | 4 + 206 files changed, 41011 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Dockerfile.dev create mode 100644 Dockerfile.migrations create mode 100644 Kyoo.ruleset create mode 100644 Kyoo.sln create mode 100644 README.md create mode 100644 ef.rsp create mode 100644 icon.ico create mode 100644 nuget.config create mode 100644 shell.nix create mode 100644 src/Directory.Build.props create mode 100644 src/Kyoo.Abstractions/.gitignore create mode 100644 src/Kyoo.Abstractions/Controllers/IIssueRepository.cs create mode 100644 src/Kyoo.Abstractions/Controllers/ILibraryManager.cs create mode 100644 src/Kyoo.Abstractions/Controllers/IPermissionValidator.cs create mode 100644 src/Kyoo.Abstractions/Controllers/IRepository.cs create mode 100644 src/Kyoo.Abstractions/Controllers/IScanner.cs create mode 100644 src/Kyoo.Abstractions/Controllers/ISearchManager.cs create mode 100644 src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs create mode 100644 src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs create mode 100644 src/Kyoo.Abstractions/Extensions.cs create mode 100644 src/Kyoo.Abstractions/Kyoo.Abstractions.csproj create mode 100644 src/Kyoo.Abstractions/Models/Attributes/ApiDefinitionAttribute.cs create mode 100644 src/Kyoo.Abstractions/Models/Attributes/ComputedAttribute.cs create mode 100644 src/Kyoo.Abstractions/Models/Attributes/LoadableRelationAttribute.cs create mode 100644 src/Kyoo.Abstractions/Models/Attributes/NotMergeableAttribute.cs create mode 100644 src/Kyoo.Abstractions/Models/Attributes/OneOfAttribute.cs create mode 100644 src/Kyoo.Abstractions/Models/Attributes/Permission/PartialPermissionAttribute.cs create mode 100644 src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs create mode 100644 src/Kyoo.Abstractions/Models/Attributes/Permission/UserOnlyAttribute.cs create mode 100644 src/Kyoo.Abstractions/Models/Attributes/SqlFirstColumnAttribute.cs create mode 100644 src/Kyoo.Abstractions/Models/Exceptions/DuplicatedItemException.cs create mode 100644 src/Kyoo.Abstractions/Models/Exceptions/ItemNotFoundException.cs create mode 100644 src/Kyoo.Abstractions/Models/Exceptions/UnauthorizedException.cs create mode 100644 src/Kyoo.Abstractions/Models/Genre.cs create mode 100644 src/Kyoo.Abstractions/Models/ILibraryItem.cs create mode 100644 src/Kyoo.Abstractions/Models/INews.cs create mode 100644 src/Kyoo.Abstractions/Models/IWatchlist.cs create mode 100644 src/Kyoo.Abstractions/Models/Issues.cs create mode 100644 src/Kyoo.Abstractions/Models/MetadataID.cs create mode 100644 src/Kyoo.Abstractions/Models/Page.cs create mode 100644 src/Kyoo.Abstractions/Models/Patch.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Collection.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Episode.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Interfaces/IAddedDate.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Interfaces/IMetadata.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Interfaces/IQuery.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Interfaces/IRefreshable.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Interfaces/IResource.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/JwtToken.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Movie.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Season.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Show.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/Studio.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/User.cs create mode 100644 src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs create mode 100644 src/Kyoo.Abstractions/Models/SearchPage.cs create mode 100644 src/Kyoo.Abstractions/Models/Utils/Claims.cs create mode 100644 src/Kyoo.Abstractions/Models/Utils/Constants.cs create mode 100644 src/Kyoo.Abstractions/Models/Utils/Filter.cs create mode 100644 src/Kyoo.Abstractions/Models/Utils/Identifier.cs create mode 100644 src/Kyoo.Abstractions/Models/Utils/Include.cs create mode 100644 src/Kyoo.Abstractions/Models/Utils/Pagination.cs create mode 100644 src/Kyoo.Abstractions/Models/Utils/RequestError.cs create mode 100644 src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs create mode 100644 src/Kyoo.Abstractions/Models/Utils/Sort.cs create mode 100644 src/Kyoo.Abstractions/Models/VideoLinks.cs create mode 100644 src/Kyoo.Abstractions/Utility/ExpressionParameterReplacer.cs create mode 100644 src/Kyoo.Abstractions/Utility/JsonKindResolver.cs create mode 100644 src/Kyoo.Abstractions/Utility/Utility.cs create mode 100644 src/Kyoo.Abstractions/Utility/Wrapper.cs create mode 100644 src/Kyoo.Authentication/Attributes/DisableOnEnvVarAttribute.cs create mode 100644 src/Kyoo.Authentication/AuthenticationModule.cs create mode 100644 src/Kyoo.Authentication/Controllers/ITokenController.cs create mode 100644 src/Kyoo.Authentication/Controllers/OidcController.cs create mode 100644 src/Kyoo.Authentication/Controllers/PermissionValidator.cs create mode 100644 src/Kyoo.Authentication/Controllers/TokenController.cs create mode 100644 src/Kyoo.Authentication/Kyoo.Authentication.csproj create mode 100644 src/Kyoo.Authentication/Models/DTO/JwtProfile.cs create mode 100644 src/Kyoo.Authentication/Models/DTO/LoginRequest.cs create mode 100644 src/Kyoo.Authentication/Models/DTO/PasswordResetRequest.cs create mode 100644 src/Kyoo.Authentication/Models/DTO/RegisterRequest.cs create mode 100644 src/Kyoo.Authentication/Models/DTO/ServerInfo.cs create mode 100644 src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs create mode 100644 src/Kyoo.Authentication/Models/Options/PermissionOption.cs create mode 100644 src/Kyoo.Authentication/Views/AuthApi.cs create mode 100644 src/Kyoo.Core/.gitignore create mode 100644 src/Kyoo.Core/Controllers/Base64RouteConstraint.cs create mode 100644 src/Kyoo.Core/Controllers/IdentifierRouteConstraint.cs create mode 100644 src/Kyoo.Core/Controllers/LibraryManager.cs create mode 100644 src/Kyoo.Core/Controllers/MiscRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/CollectionRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/EfHelpers.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/EpisodeRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/GenericRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/IssueRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/MovieRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/RepositoryHelper.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/SeasonRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/ShowRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/StudioRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/UserRepository.cs create mode 100644 src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs create mode 100644 src/Kyoo.Core/Controllers/ThumbnailsManager.cs create mode 100644 src/Kyoo.Core/CoreModule.cs create mode 100644 src/Kyoo.Core/ExceptionFilter.cs create mode 100644 src/Kyoo.Core/Extensions/ServiceExtensions.cs create mode 100644 src/Kyoo.Core/Kyoo.Core.csproj create mode 100644 src/Kyoo.Core/Program.cs create mode 100644 src/Kyoo.Core/Storage/FileStorage.cs create mode 100644 src/Kyoo.Core/Storage/IStorage.cs create mode 100644 src/Kyoo.Core/Storage/S3Storage.cs create mode 100644 src/Kyoo.Core/Views/Admin/Health.cs create mode 100644 src/Kyoo.Core/Views/Admin/Misc.cs create mode 100644 src/Kyoo.Core/Views/Content/ThumbnailsApi.cs create mode 100644 src/Kyoo.Core/Views/Content/VideoApi.cs create mode 100644 src/Kyoo.Core/Views/Helper/BaseApi.cs create mode 100644 src/Kyoo.Core/Views/Helper/CrudApi.cs create mode 100644 src/Kyoo.Core/Views/Helper/CrudThumbsApi.cs create mode 100644 src/Kyoo.Core/Views/Helper/FilterBinder.cs create mode 100644 src/Kyoo.Core/Views/Helper/IncludeBinder.cs create mode 100644 src/Kyoo.Core/Views/Helper/SortBinder.cs create mode 100644 src/Kyoo.Core/Views/Helper/Transcoder.cs create mode 100644 src/Kyoo.Core/Views/InfoApi.cs create mode 100644 src/Kyoo.Core/Views/Metadata/IssueApi.cs create mode 100644 src/Kyoo.Core/Views/Metadata/StudioApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/CollectionApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/EpisodeApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/LibraryItemApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/MovieApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/NewsApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/SearchApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/SeasonApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/ShowApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/UserApi.cs create mode 100644 src/Kyoo.Core/Views/Resources/WatchlistApi.cs create mode 100644 src/Kyoo.Meilisearch/FilterExtensionMethods.cs create mode 100644 src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj create mode 100644 src/Kyoo.Meilisearch/MeiliSync.cs create mode 100644 src/Kyoo.Meilisearch/MeilisearchModule.cs create mode 100644 src/Kyoo.Meilisearch/SearchManager.cs create mode 100644 src/Kyoo.Postgresql/DatabaseContext.cs create mode 100644 src/Kyoo.Postgresql/DbConfigurationProvider.cs create mode 100644 src/Kyoo.Postgresql/Kyoo.Postgresql.csproj create mode 100644 src/Kyoo.Postgresql/Migrations/20231128171554_Initial.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20231128171554_Initial.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20231220093441_Settings.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20231220093441_Settings.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240120154137_RuntimeNullable.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240120154137_RuntimeNullable.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240204193443_RemoveUserLogo.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240204193443_RemoveUserLogo.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240219170615_AddPlayPermission.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240219170615_AddPlayPermission.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240229202049_AddUserExternalId.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240229202049_AddUserExternalId.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240302151906_MakePasswordOptional.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240302151906_MakePasswordOptional.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240324174638_UseDateOnly.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240324174638_UseDateOnly.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240401213942_AddGenres.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240401213942_AddGenres.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240420124608_ReworkImages.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240420124608_ReworkImages.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240423151632_AddServerOptions.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240423151632_AddServerOptions.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240506175054_FixSeasonMetadataId.Designer.cs create mode 100644 src/Kyoo.Postgresql/Migrations/20240506175054_FixSeasonMetadataId.cs create mode 100644 src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs create mode 100644 src/Kyoo.Postgresql/PostgresContext.cs create mode 100644 src/Kyoo.Postgresql/PostgresModule.cs create mode 100644 src/Kyoo.Postgresql/Utils/JsonTypeHandler.cs create mode 100644 src/Kyoo.Postgresql/Utils/ListTypeHandler.cs create mode 100644 src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj create mode 100644 src/Kyoo.RabbitMq/Message.cs create mode 100644 src/Kyoo.RabbitMq/RabbitMqModule.cs create mode 100644 src/Kyoo.RabbitMq/RabbitProducer.cs create mode 100644 src/Kyoo.RabbitMq/ScannerProducer.cs create mode 100644 src/Kyoo.Swagger/ApiSorter.cs create mode 100644 src/Kyoo.Swagger/ApiTagsFilter.cs create mode 100644 src/Kyoo.Swagger/GenericResponseProvider.cs create mode 100644 src/Kyoo.Swagger/Kyoo.Swagger.csproj create mode 100644 src/Kyoo.Swagger/Models/TagGroups.cs create mode 100644 src/Kyoo.Swagger/OperationPermissionProcessor.cs create mode 100644 src/Kyoo.Swagger/SwaggerModule.cs create mode 100644 stylecop.json create mode 100644 tests/.gitignore create mode 100644 tests/auth/auth.robot create mode 100644 tests/pyproject.toml create mode 100644 tests/rest.resource diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..4cdf769 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.21", + "commands": [ + "dotnet-ef" + ] + }, + "csharpier": { + "version": "0.28.2", + "commands": [ + "dotnet-csharpier" + ] + } + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9f8aece --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +Dockerfile +Dockerfile.dev +Dockerfile.* +.dockerignore +.gitignore +docker-compose.yml +README.md +**/build +**/dist +**/bin +**/obj +out +docs +tests +front +video +nginx.conf.template diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..30728be --- /dev/null +++ b/.editorconfig @@ -0,0 +1,99 @@ +root = false + +[*] +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = tab +indent_size = tab +smart_tab = true + +[*.cs] +csharp_prefer_braces = false +dotnet_diagnostic.IDE0046.severity = none +dotnet_diagnostic.IDE0055.severity = none +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0130.severity = none + +# Convert to file-scoped namespace +csharp_style_namespace_declarations = file_scoped:warning +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true +csharp_using_directive_placement = outside_namespace:warning +# Avoid "this." if not necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +# Suggest more modern language features when available +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = true +# Disable strange throw. +csharp_style_throw_expression = false:suggestion +# Forbid "var" everywhere +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = false:suggestion +csharp_style_var_elsewhere = false:suggestion +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = false +csharp_new_line_before_members_in_anonymous_types = true +# Indentation settings +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +# Modifiers +dotnet_style_readonly_field = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +# Naming style +dotnet_naming_symbols.privates.applicable_kinds = property,method,event,delegate +dotnet_naming_symbols.privates.applicable_accessibilities = private +dotnet_naming_style.underscore_pascal.capitalization = pascal_case +dotnet_naming_style.underscore_pascal.required_prefix = _ +dotnet_naming_rule.privates_with_underscore.symbols = privates +dotnet_naming_rule.privates_with_underscore.style = underscore_pascal +dotnet_naming_rule.privates_with_underscore.severity = warning +dotnet_diagnostic.IDE1006.severity = warning +# ReSharper properties +resharper_align_multiline_binary_expressions_chain = false +resharper_csharp_empty_block_style = together_same_line +resharper_indent_nested_foreach_stmt = true +resharper_indent_nested_for_stmt = true +resharper_indent_nested_while_stmt = true +resharper_keep_existing_embedded_arrangement = false +resharper_place_accessorholder_attribute_on_same_line = true +resharper_place_simple_embedded_statement_on_same_line = false +resharper_wrap_before_arrow_with_expressions = true +resharper_xmldoc_attribute_indent = align_by_first_attribute +resharper_xmldoc_indent_child_elements = RemoveIndent +resharper_xmldoc_indent_text = RemoveIndent +# Switch on enum +dotnet_diagnostic.CS8509.severity=error # missing switch case for named enum value +dotnet_diagnostic.CS8524.severity=none # missing switch case for unnamed enum value + +# Waiting for https://github.com/dotnet/roslyn/issues/44596 to get fixed. +# file_header_template = Kyoo - A portable and vast media library solution.\nCopyright (c) Kyoo.\n\nSee AUTHORS.md and LICENSE file in the project root for full license information.\n\nKyoo is free software: you can redistribute it and/or modify\nit under the terms of the GNU General Public License as published by\nthe Free Software Foundation, either version 3 of the License, or\nany later version.\n\nKyoo is distributed in the hope that it will be useful,\nbut WITHOUT ANY WARRANTY; without even the implied warranty of\nMERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\nGNU General Public License for more details.\n\nYou should have received a copy of the GNU General Public License\nalong with Kyoo. If not, see . diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..42beea2 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# vi: ft=sh +# shellcheck disable=SC2034 + +# http route prefix (will listen to $KYOO_PREFIX/movie for example) +KYOO_PREFIX="" + + +# Optional authentication settings +# Set to true to disable login with password (OIDC auth must be configured) +# AUTHENTICATION_DISABLE_PASSWORD_LOGIN=true +# Set to true to disable the creation of new users (OIDC auth must be configured) +# AUTHENTICATION_DISABLE_USER_REGISTRATION=true + +# Postgres settings +# POSTGRES_URL=postgres://user:password@hostname:port/dbname?sslmode=verify-full&sslrootcert=/path/to/server.crt&sslcert=/path/to/client.crt&sslkey=/path/to/client.key +# The behavior of the below variables match what is documented here: +# https://www.postgresql.org/docs/current/libpq-envars.html +PGUSER=kyoo +PGPASSWORD=password +PGDB=kyooDB +PGSERVER=postgres +PGPORT=5432 +# PGOPTIONS=-c search_path=kyoo,public +# PGPASSFILE=/my/password # Takes precedence over PGPASSWORD. New line characters are not trimmed. +# PGSSLMODE=verify-full +# PGSSLROOTCERT=/my/serving.crt +# PGSSLCERT=/my/client.crt +# PGSSLKEY=/my/client.key + +# RabbitMQ settings +# Full list of options: https://www.rabbitmq.com/uri-spec.html, https://www.rabbitmq.com/docs/uri-query-parameters +# RABBITMQ_URL=amqps://user:password@rabbitmq-server:1234/vhost?cacertfile=/path/to/cacert.pem&certfile=/path/to/cert.pem&keyfile=/path/to/key.pem&verify=verify_peer&auth_mechanism=EXTERNAL +# These values override what is provided the the URL variable +RABBITMQ_DEFAULT_USER=guest +RABBITMQ_DEFAULT_PASS=guest +RABBITMQ_HOST=rabbitmq +RABBITMQ_PORT=5672 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..906935b --- /dev/null +++ b/.gitignore @@ -0,0 +1,352 @@ +out +libtranscoder.so +libtranscoder.dylib +transcoder.dll +kyoo_datadir + +video +.env + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- Backup*.rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb +/Kyoo/TheTVDB-Credentials.json + +.vscode +.netcoredbg_hist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4bbc4f5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 as builder +ARG TARGETARCH +WORKDIR /kyoo + +COPY Kyoo.sln ./Kyoo.sln +COPY nuget.config ./nuget.config +COPY src/Directory.Build.props src/Directory.Build.props +COPY src/Kyoo.Authentication/Kyoo.Authentication.csproj src/Kyoo.Authentication/Kyoo.Authentication.csproj +COPY src/Kyoo.Abstractions/Kyoo.Abstractions.csproj src/Kyoo.Abstractions/Kyoo.Abstractions.csproj +COPY src/Kyoo.Core/Kyoo.Core.csproj src/Kyoo.Core/Kyoo.Core.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 + +COPY . . +ARG VERSION +RUN dotnet publish -a $TARGETARCH --no-restore -c Release -o /app "-p:Version=${VERSION:-"0.0.0-dev"}" src/Kyoo.Core + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +RUN apt-get update && apt-get install -y curl +COPY --from=builder /app /app + +WORKDIR /app +EXPOSE 5000 +# The back can take a long time to start if meilisearch is initializing +HEALTHCHECK --interval=30s --retries=15 CMD curl --fail http://localhost:5000/health || exit +ENTRYPOINT ["/app/kyoo"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..b442807 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 +RUN apt-get update && apt-get install -y curl +WORKDIR /app + +COPY Kyoo.sln ./Kyoo.sln +COPY nuget.config ./nuget.config +COPY src/Directory.Build.props src/Directory.Build.props +COPY src/Kyoo.Authentication/Kyoo.Authentication.csproj src/Kyoo.Authentication/Kyoo.Authentication.csproj +COPY src/Kyoo.Abstractions/Kyoo.Abstractions.csproj src/Kyoo.Abstractions/Kyoo.Abstractions.csproj +COPY src/Kyoo.Core/Kyoo.Core.csproj src/Kyoo.Core/Kyoo.Core.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 + +WORKDIR /app +EXPOSE 5000 +ENV DOTNET_USE_POLLING_FILE_WATCHER 1 +# HEALTHCHECK --interval=30s CMD curl --fail http://localhost:5000/health || exit +HEALTHCHECK CMD true +ENTRYPOINT ["dotnet", "watch", "--non-interactive", "run", "--no-restore", "--project", "/app/src/Kyoo.Core"] diff --git a/Dockerfile.migrations b/Dockerfile.migrations new file mode 100644 index 0000000..2278e64 --- /dev/null +++ b/Dockerfile.migrations @@ -0,0 +1,30 @@ +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 as builder +ARG TARGETARCH +WORKDIR /kyoo + +COPY .config/dotnet-tools.json .config/dotnet-tools.json +RUN dotnet tool restore + +COPY Kyoo.sln ./Kyoo.sln +COPY nuget.config ./nuget.config +COPY src/Directory.Build.props src/Directory.Build.props +COPY src/Kyoo.Authentication/Kyoo.Authentication.csproj src/Kyoo.Authentication/Kyoo.Authentication.csproj +COPY src/Kyoo.Abstractions/Kyoo.Abstractions.csproj src/Kyoo.Abstractions/Kyoo.Abstractions.csproj +COPY src/Kyoo.Core/Kyoo.Core.csproj src/Kyoo.Core/Kyoo.Core.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 + +COPY . . +RUN dotnet build +RUN dotnet ef migrations bundle \ + --msbuildprojectextensionspath out/obj/Kyoo.Postgresql \ + --no-build --self-contained -r linux-${TARGETARCH} -f \ + -o /app/migrate -p src/Kyoo.Postgresql --verbose + +FROM mcr.microsoft.com/dotnet/runtime-deps:8.0 +COPY --from=builder /app/migrate /app/migrate + +ENTRYPOINT ["/app/migrate"] diff --git a/Kyoo.ruleset b/Kyoo.ruleset new file mode 100644 index 0000000..82ed916 --- /dev/null +++ b/Kyoo.ruleset @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Kyoo.sln b/Kyoo.sln new file mode 100644 index 0000000..1c1c9fd --- /dev/null +++ b/Kyoo.sln @@ -0,0 +1,64 @@ +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}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Postgresql", "src\Kyoo.Postgresql\Kyoo.Postgresql.csproj", "{3213C96D-0BF3-460B-A8B5-B9977229408A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Authentication", "src\Kyoo.Authentication\Kyoo.Authentication.csproj", "{7A841335-6523-47DB-9717-80AA7BD943FD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Swagger", "src\Kyoo.Swagger\Kyoo.Swagger.csproj", "{7D1A7596-73F6-4D35-842E-A5AD9C620596}" +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 + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0F8275B6-C7DD-42DF-A168-755C81B1C329}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F8275B6-C7DD-42DF-A168-755C81B1C329}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F8275B6-C7DD-42DF-A168-755C81B1C329}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F8275B6-C7DD-42DF-A168-755C81B1C329}.Release|Any CPU.Build.0 = Release|Any CPU + {BAB2CAE1-AC28-4509-AA3E-8DC75BD59220}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BAB2CAE1-AC28-4509-AA3E-8DC75BD59220}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BAB2CAE1-AC28-4509-AA3E-8DC75BD59220}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BAB2CAE1-AC28-4509-AA3E-8DC75BD59220}.Release|Any CPU.Build.0 = Release|Any CPU + {3213C96D-0BF3-460B-A8B5-B9977229408A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3213C96D-0BF3-460B-A8B5-B9977229408A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3213C96D-0BF3-460B-A8B5-B9977229408A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3213C96D-0BF3-460B-A8B5-B9977229408A}.Release|Any CPU.Build.0 = Release|Any CPU + {7A841335-6523-47DB-9717-80AA7BD943FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A841335-6523-47DB-9717-80AA7BD943FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A841335-6523-47DB-9717-80AA7BD943FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A841335-6523-47DB-9717-80AA7BD943FD}.Release|Any CPU.Build.0 = Release|Any CPU + {6515380E-1E57-42DA-B6E3-E1C8A848818A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6515380E-1E57-42DA-B6E3-E1C8A848818A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6515380E-1E57-42DA-B6E3-E1C8A848818A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6515380E-1E57-42DA-B6E3-E1C8A848818A}.Release|Any CPU.Build.0 = Release|Any CPU + {2374D500-1ADB-4752-85DB-8BB0DDF5A8E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2374D500-1ADB-4752-85DB-8BB0DDF5A8E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2374D500-1ADB-4752-85DB-8BB0DDF5A8E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2374D500-1ADB-4752-85DB-8BB0DDF5A8E8}.Release|Any CPU.Build.0 = Release|Any CPU + {4FF1ECD9-6EEF-4440-B037-A661D78FB04D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FF1ECD9-6EEF-4440-B037-A661D78FB04D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FF1ECD9-6EEF-4440-B037-A661D78FB04D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FF1ECD9-6EEF-4440-B037-A661D78FB04D}.Release|Any CPU.Build.0 = Release|Any CPU + {7D1A7596-73F6-4D35-842E-A5AD9C620596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D1A7596-73F6-4D35-842E-A5AD9C620596}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D1A7596-73F6-4D35-842E-A5AD9C620596}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D1A7596-73F6-4D35-842E-A5AD9C620596}.Release|Any CPU.Build.0 = Release|Any CPU + {F8E6018A-FD51-40EB-99FF-A26BA59F2762}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8E6018A-FD51-40EB-99FF-A26BA59F2762}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8E6018A-FD51-40EB-99FF-A26BA59F2762}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8E6018A-FD51-40EB-99FF-A26BA59F2762}.Release|Any CPU.Build.0 = Release|Any CPU + {B97AD4A8-E6E6-41CD-87DF-5F1326FD7198}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B97AD4A8-E6E6-41CD-87DF-5F1326FD7198}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B97AD4A8-E6E6-41CD-87DF-5F1326FD7198}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B97AD4A8-E6E6-41CD-87DF-5F1326FD7198}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fa513f --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Kyoo backend + +This is a copy of the v4's backend of kyoo, mainly used for easy access now that the back was entirely rewritten for v5. + +If you wanna see the history of the tree, you can go to the main repo [here](https://github.com/zoriya/kyoo) diff --git a/ef.rsp b/ef.rsp new file mode 100644 index 0000000..de6fae1 --- /dev/null +++ b/ef.rsp @@ -0,0 +1,4 @@ +--project +src/Kyoo.Postgresql +--msbuildprojectextensionspath +out/obj/Kyoo.Postgresql diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8a6ef232129343ddec21d42f2f5544ee59d631dd GIT binary patch literal 107761 zcmeHQ2V4_L7vDgTCcQ%-q4#1ziXg?_&W<9Ac&Fl7v7o3Z5ygVNU>6X(q5>8yD2N`Y zSkHFYK+v-bR#1Wc-YnVNCMF?)AoxjsJZ5Ki%6soWJN3;hhS9KQ82ZsL7B-KGVWZ%h z&93&_DA2GMaL?D5qcL zlUJ~9%Q%%bdf|Uh`}6wmX|Eg|?=@Sz$;sf3?$IG3%J*arXtB7@3Z+G?ftfxovo zW`rzT>e}p%>Hfa09Zx35_ek!jIdy;ktgOH9C>unbU)OW7cGtzs^+i?3%LcFQ(Z{PS ze#*7e86|4hnXA4`QjIHn>`^l1>Ngp>pSl=-5i&a;JL|aS=jR)|dHs6Jf!hH~(&W3U z&!1-#JGj-Hma$2{*~A*gd0R!Z7)ifjFJnTw^|_^y|A)DPzl#bzv1;LbHtXI-M|xJp z#y;AgR=!Tvqz~V^yi;a>?V~xRhM}|`0sHSAd3{lxezut!&7~`C!=8Ynw)tz!6p~yc zl-OOBvgqs=V+Q11h-dk!Z#MVGdZb}i+a{{DOtbE$e*VfLZETGL+xEHt%C+9Zqgi*t z{IQoFa^AhbJ1LsAMGsrEKJk#ks1Z>Z{c-3BMfSzO=l0PC`AoWBxkkQBh`+9xUzaRL zxbXK?SN**^rhQa-zyY)0hm0`QoG<&2Ci~3u1J~_ltnIcWDJCR#i)?h^it#j;zyrE{ z>_+ysbzWje$PptLZd&Huf!UcZ|g-#3* z&d}~DY)n`f+;qj{YTG$#70zlo;P2AwliUIm(M3&f*79N zfyFV0lOC~I8T0qkvqpp;O;V--r>5Eim9kW}cy#yGS*BP}G=X-UxGS?lfxXy?9@i~+ zM~u5-Wk{c##}_r4@96YLF}9rM-n;CT1KR*w^RC0pPM`HpvvcVoDrxGKUB|SZCjW7Y ziTtsM=Yd#m(c+KEuUnilxTeHj9B^@a--}BotEOSQ{B)D-U819!`TCr@X|mY*?=@+; zZv(uXF{?S5%g51kE!gw^J%sf*ul2$<|Bqt#OoJ4;%!_*?%)FgusZQ829#}s*xWnEp zS}z#K4<^0SJ=;P)U^%3qmG@)uL;4QD(Xu6mdbwXZnp&095)0IO$(+$-yWTn&JbzYMtY-8a zEO(po*&RuH%rk=BT@9D)>h#A=Y!3EnR*^-C;)f1lrLtd@LIOT}J&bmi%Q~+e{pf;BHtj|s5-=VVO8^hmv`J(F)Y{yx;YV(i)bha@nq1o0mDUIqy1r9W3 zx_jmBo9jxL(Y)Y6j%U|>3EZi+x0`jl`)h{JdHZFsLRm$TQsMj9oSdf#wxu4;r&{OF zzEgStJ8t_t)V{Kd_JE8{jzza`jl{B6RZe287`0*8tH<^l8I!Aua)zBcmegWlaaQM{ z>t27zpJ)7~BK6dJ1MzioVZ@z7b5f^seYEi2!>KPb3=OXZCxlWh0BdlPlN=w`ys z!t`GEm9icWQd8}G>A0JN+w)5UbJX-gpPag+#kGkZ} zex}oLD|?>KGL>C^L+9+csVfuGtZJC*kTpejt&b|4Ii-J;@$u7xRu>*+7DhVWvU!&q z9e5+6Z=YLT6(+=v*&>(HxAK{ToX;?*C8u|sRqA%X)$-8yS2{c@ySX(dKE7A4Z250# zm#?LkzbQ~Sob;|m$3D9gmHk%VT_WptkRIfr&{}?$V*4ao{88GueX-V`v9bk+Jine< zT%^9MmFbqQA13$s80`v z6qMckGUsf>7&-NT0X^OZYMNBMl8yRn)rrcBA?z<61w)q>?Yh0_hPLZ1Z<~EdsWa9k zj#R$!{8FA_#ndweG_TtR&lh{EZ+e|~D>`D-aJ6=yH`@e9%<5R4KF2%3_=3xdPoc^^ zk2N<|ihkIl!`BHKw~h0UsBfOuxs9h`YB24p|FF;3-kY>eHMypcRn?`-=_zVsct`(uaFRN67inY(n)e{{Wb?cCswe>8jPc>ZG4nOWCHvZk+o=W0`KoYP7s z>)U;we#%Af^3FwgA6~jF^!-+glFoc3h^WQdBTu zpi7q3KJALtrSq{;Gsw(-;KCS2QL56|_a##&?4I;s12bb&l4{49w&mUnCelL=da;Yw z&synl+`9bJ6XyM$JJ;)-2>oz(&tdg}vC7x&@*IN}oefbh$S+tydlL9)RrIiyXVlps zdaoVY+O20SKGP}13fKJ0bW5H`nA2HxnfZgq#u_$n_U?4sg{3AtKNSw^fT>kZ3;$9P z`BfD=Tx8v`b+%TUkl{yn&ReYFvGx99=lDY&6W)xEiF;Bob-_fZ5o7N-obP?DcYEa< z-AZM%LLa?8|K-ca-;%dl4hl8ZyjrM8yMC?UaI1kPSvMzM%qw-9=lW&+Ei=t2ddAO{ zKAJu_HT_|r%>J`@X`I5JFTiND4_IQ%pt z#QRJ7)6`*#UX#XZmjnz>IA$WdozpgQ@)V^b%Q0ISrAH3O#IV*>;3%q#Sg!} znP~YX_f?^qT5_|fmtUhFrni}BdetV@)9Txl(RyCy80K>L$?UtUL3SI}6P+$^n3t)0 zKB00~j(Pbj4~0VcpojhzgU|JoDV;gWpsShuKu0EYqz4^HyQe$ z)P$~&?-R%K(80*P;E}RmH;iTGY$_T1Vv_mrG7l{IV&)Fr7EbmT|LRt{V)ugP-vWHP zr`+&=(uFZ|Y>PB?$3hQU#JjI!y00*LJGtQ2pfynwQmS6d;WK+Er&@b0J?N`evTc*@ z@2efo-wQuN&oCc2j^? zMq{B*n_iDSXxQNUdbnTj9yE>pP{4kBHm%#yst$5d-v5R_A^JHnPp0)S-C(7h+9Fk1s?+Fk5rzRz(CTx|xrsV5)v-CmHv0W=p zINrKZw0|)wp?l>vuJHb#Uz}bRxACRZRcO5Se(;VSXa~X^zaE=Yni}&wkcP3L*4(w9 zeQM^Dgv393R(Z^RThM&Yx;A5{Jf%feU14u8PEJ>sQ}8<-S=LRNl-qV$DvN#H ztnm!ZiccH-S=aq5G$Tit&KwLP#-tBy6Hf1Ri;=xAR0)Kjp1g@hSPRy6kX` ziG8Bh6|Q{!B{utm42DHTxt@HHxorM%XT6}D0~dV`pWQcbUZ%-Vg=0&OsIN#|=6L>? zI{R*`yv+g4)B5$gt~#M-Rp5(*k)G*Vo#)(tak6k&=dCgNuixtZX?pe8nwWodAxUee zmz9+-JvQb$UgmOWKj^+PCdnf4r#Oar}$1hn;8c zfBo*%x)=BJdVIrEcMMAPWV||iclwZ!naiI=XPUZ>j*DItyvQmLTZ38cAK+*{>sa!U zD@s{Ck2`)TD|e**TNpKJcrPQmPe_}N;TQZrJ_wZ2#Ar}(f-lE*t%?~C_hqy*<~4Jq zb8y7{xy>w8`whD>s4#p)uK}%hIbxyEfLDAgNtJsb|LC8qv+;?470Z7toZhdb!zzWB z%ZFUEeI7I~Fn`L!=7VWr|Hk_r+p%WR*i7@s6EZ56H}6*(I>v9Q<@9e><#Uf7rbjz= z7&x%C6|L|x^mlgUesG#P)BVo5aUUj~zVWQ%ONa9ZLps1PtGLZGHl1yrHnwW^gz0W) z&J@e^E4>DV;`$dq9Nvfp>Iz5H)v`zGCY8M!cS_;TlY&gAPqL#O^V=|*7j8Xm_sHl@ z%I^KqOX8d|GWHY>nIH$nadCvs_J`{?Jc@cVby(XG-)1Bg_&29-w^`qEPyhEBjvYRD zzNfQoyBtxsRlKk`WnaWXJ=lw>chy^yH-%qLQP(_{Cscgs*Hs3%xl@LlxN-h``;-t2Ud69 zxf(kDBWOKj(LjEO)`xA5m~~FlIGXELMcSZm2{zEyU7n_+);WLOJBJZt{34&eOgYrD zGNaF_ncJp)GSk=o-E@=AvK2P5GnG^oXa_L&kh|Z~U*;cPdCSawTI!0`v$q5eJ@DMW zw`Rb+_YPA{H)y;odLk2+b+9bTELg{|!pCmh^u);ZJ}+Ls)*O0c(CB{sH8ItH-c^-# z_Sb)N#%bKRYYwl^u3xk1)S>ssWd99}e%@}Cu36cxJr-CS1I6jBMi#a@TIgAlaNBdl zwxQQPp4zMm#lwo0a|OhqlWgobuh6~Z^()Ru{j^37OK3B{p>R1Q&-2L2(!0q zR;5oGxc1xaf!Q*s0(|`N;J(p4WJ2bBOB!eW0&3wkEwApLe&rAM3js+>)faBwl(ZTf z1YLz5Tc=`$f4v&_MJBjI+ivCWO{Rx;w|a0xI}XM#C9guib=qpL*4n$wdDD%zugY)z zHvMBai#yx@v>$Xp>AvA8+GUw8ZN7HyrV;ey0xRfqu=6*|GKIH)%+q`K!E#32>cyXq zKaSQofH}A@n!6-CnB{uEoON}`jI0}}K1auHO!nJ*tGAP*S^C=S$-{#?lAyr!Bf?S0)=`i*?>V zVEm53w|5Ob9RSkO?kq^Svac%sqDR8kDEFC*ll^w@8IYPbS*3ga+C5o5%7ybr6&`N4 zk zSu?{2o`1}IWTyVc=JDR4M`S|Ur@z0Pr8nZKceL&SueK{ukPjudN4D7bxz&r2UNe>@ zC@>Tpil&_UQzymsXm~)pjatEl=WY9%ZlEW295k_t<~FSI=|#0?>nC}puetxqn{j%6 zrG}^71NEDi@-l4yzVz+*0Q+mIFtnV!XT^BulJmC3AeAbY}U&eFMf1&S7-L2G0-wQgZ8dftKDEW9#M9)|Q?(>Ac|0mZyICORtJ{?+)gDQ`U|>|Cog!?!4X}K}44i+n2cz$v8TOKqoJRo@Q zhVY>yhdQ^HxsKWA;dj?P9M-$|e8+Y=3p(H0VxHdYblI_?i1Gy zIzj2{zn#dqbNccA?Dw?b{mHLJg3KW!za;FH8#)qqo%ieu_UH?CV0CV{$E&;8mjoW! z78fe7$yON@ScTu6-2ZdR#=;rx*qwE@g~x^4k1M(_^C{p_3#E!rAZ%f!>9K>ESn1ca zS!x%?std zmqkA7yDF%XeW>JkSiip4)#AcWH&IE^nz7k>HXru%f95F zdqz`apFNd*r=61XTik86gZ8_){pW@R+u!Sxy=K<>-nP3t2G00&chZ`xbNarKhoIJ9 z^AF>}kMN04dTc7_^LVXgTWYm&P7EE(3Ugu0&h7Z`vl+ATOpV~qF|yN(9wAgZ&L6>3!mK{j+Kw=m9tMFVqe<9^`0#t7Bf?nc3ssNJuz-;6hsA*!F=BrUc#mCLezgP>XcN0+tMY z@7cX1;I|?4)dS{OUj6Ou1y@bl-PRXB+Mm(G?AJ$O6CdE|-3A2ZeA>Ch{p%?mhv;GP z+kc}ExodIrPcJW@d2(k}ylifjLE8HVa}UX7 zy?o(=bvbzafd9b(n+D#Q{j{QEJh^RLMj%FZ@k7wSKc#brlLxh${G=(Q|o*N7;kIk|UbO1A}%-TKGXIIj#meNbT8&CHWmU6~Mq^O(z(0R|v6kH9Xb+bLS_W zBki#O!>CPLhE0m1_122-*|OhVz0y*hcAaC~!myJHOS_jZid-h2d8xegQOYOpiW3iF zUq7CBam>+0eQnQGc6wz0%(=+uvAfCQ5fN%9m@_AbHP4FhT6L_VSyF;oSm+ws#I5Ua>mS*VK{K?lU2cyqEV2H3O<38 z=tN(G?OK-bb<$GIogR`L)1rs6;r89{c48Taa=%)d479+;4uGYHY}u&&OFY8OTx3%V zPF7;e^?d?1roDS`?637f?CEj`LeK4t$C3}frw7~_IcBv%uN|S=qGV5P41HsvbKhW? z68m z_I(fwv)yl|e>vK1j$^5J|G>NIb9XboB_11f`JW{9&4t}*?j94bT+$jU8!~iFj}~*C zGtwq_t%_$mxukiF8P{`>6{H(+?831XbL@L1E=-UqRbV71!^C~A=|K8&!~Gh+D@WR5 z>_=wbV6No6M)Jhfw)@&1Ty-O1>dCZ&9W)XPL$lIhc+)F$z^K8BAHyfR%NB08+Njxq z9i5;tOc_f&M2Fr*`p!oY)5``Gtp9ej>#libzGo-W3gB7#UxvL@#?QEs+pQ8?xM{>m zNYNCQUm}&2<}6u>>02mwgkc``+o)-7vmfMqzPq{4?R6ao&tT`;y~@#g(RGoBH6%1| zLHh47V*2y&eZA8IK9|R&_-JMqw^dTfeCJb|^Lf{9+R<(%J6EsVVUid$V-3dW_3~Zz z@e&`ocGKl;%E$Z5T%Ug~)r4Vixj;So=83HlUk`M0qmA9Oc8bmV!$~qea^9o8jLKhG zZC{UXnj5Z>D8t;sqNT*Nx;trP=!cjdJr3P#C3~8ouN(-oh4Y!rE?6?L!i613bFeGD zCbMBoZdSpfK86{$$DWyTJ@ZTG$Q7rCvS=Y^6(areBYo8>6w^n2=s5g|oKNS)?kZ9D5AeR$k4E$rm-?&=3_TFI~ub+z(~Fj%3C%_yf2m!GJ_ZWZuo z$L83vn)4OBEV2Fjb-cItGHK@*c2-NqH^9Vi(b?NFi38rlXbkkM9Ac6FFG@B4C%x8-n8>O+n&;lFrRAatgsx?+*|LUqAek@^iX})@g z`+w|r-QbIomk&GJH7mUQ3`pU=IEaY8lHG%rc)irJYe(EG{2ao5G~%HOcpNN-AZZul6D#Ku(*zO z?f6@VgZ?4WHgL^GH(=!*EzMW|XqculMiA#llhdSuCJi)cph*Kw8fel$lLneJ(4>JT z4g7){pv%fS0D&QI)9+W*0ASAsDgjywr2i}G;8&KJgFg-~qB5W@Ko-BUPJSiX0c-UB zllYVFH@KGqxU_KyC(*@2{wvUqNOu@F&Z{bqL!V2n-~fe*Z5TkN|(O z96W$HBY+hCUv%&*%TWvb$#U=v;)Ve=jU9jD0wf6k=JN8mVlz1s=bBtJcAO8?G<@uqcVVqp{9bXD9DpI*W{wH z;|`#vvExrr4#dHqEC(j)s<@h>LQVKnpC5treSZQq{IV=cfAV+`U2$B`<+pOhX;lg1&+roleDC)b&4*UkFgs96}>06LrC#EC=SA z>bP!mWn5lP>~in~@B<$PX6yN8wMF12lG2hdBfZ#*SIQW7F8Np323K#-A()#MqITLlAq+ za2@S5t#*&Kfz|?j0K$PT1GSbCEI%xn#)3at4$vHefr><9M>L0k#*RO1{TXm}2YLpC zaHkR~3;ll>cv8ryq4BrTW#Hxxb8tQH+qgm7JltAan^<#1<-v#;J4&?vjK+@jy8aCK z4gvbchdZ?lpl9Qx0L>3cq9O6OWp%&}J#(qL!%=x4>jKh6V@C$=9FJ*s(($RYXJwJXcD9Jt*M;jUB7kpCuYQ_6J#OHFm5e_M~i$x(xhi{B4=Oq+Y&r zneIo#!k;V$XzYmApCueSjs}^*|pliqSuQmQ;eSq9r z*e-7XU05L908U;&qQ-dqyq7_EG#7;8v4CuVMgUz0qK553)qP{Q{?Yg&Y>e9Sj`^rM zGPNEr75vGvAgS%Ly0$|y@bikwQw`TB{CD*Si7h-s^~gU!jj|1B2>e-2OR4V!&$Vfb z^-{&3EC=G)E-NZ1i}xmuz)!PBV+&X<|6qpCwN6|Lvs~fa^Gw7`oQBy zr`b^Wn~4|)GVSO7F#K%|EMeX0s?f4XmIFPHGq`edZK@3;X}c`6@AspztWACmg}qd7aYwE$tV^qCxlr*Ic1_wYyEG)8lIGoz_?y`Yp9{7#?NqP$ z+c5paYV)lv`s2jdQF7a5UrDiOi0}=GznyU_q3<8des;a#k8BM1?GC8#Jb7(tH-(1> zV&AKxMEhRPgMNEA1g_%d*^v07I^EdwoWL?rpM3yU%Pj))BymSBvsEGw_SE*LmbS|w zpzB|L{-3HxbZzP|Paqcc)(5Z-6__6tdqdCj0_~9ac}H^yI+EF}7D5?lRJO|v{r=lA zy@cW+?RfwjhO5YVCsYQ|yc7z@kF_X2T7Q;s-|H)m-bJoIi(7}+C0s+}kMt^RJ&5JB zSRx!mVT8sLh8|~ymNoMIw}SdXR63%b5w^><+xOaFa|n$Ae}prsf2xc62BP#W$urXy z8wBP-&5zY8L2CI`?|T*7c9|SI+SKJ;u1(y=fWIB9y-;lIjU4Nai5P7@vOTYlb%$Eo zF1yzT%i6?g4EUpYIAL?J<~HN%gue|_yt#cF)9!W3r>43HjUCb1SrYDh?NS>|C5h7* z@JG5cbzC5@4iMKSSdjj0_05PqCDb}z(6zba{CdTn${QLxYD&KEbr9fGFJs5Xg1@kF zK=nBA`#m94oeH|P(b2;hZH@>mQ`9oRa6gHou!7R4&wI7p_c{*n65DoJoO+$y3)p;nqDn!m+Sn^p2mc~or#A)+|b@ovFm7S(e~wJ-Op;hv(aErEeGh@NEMx# zB=*>`PT%ZlO!y<&jluqp#E9zJnDykr3ym4ftjFMJKZ%t^Q1#eNOzhD<6J+){kgH>FKkWN)MYgfUTDuawS0)OC!~f$*Vg8Jc+zTYxbySFlziW7 z2H@)Zv-SUw+;)a`Jh+nB@wWlSp4kG!k>Q287S6Fh753D!V9MS1D%M$g$WG-tP;Vfi zV@G(#2I8@4AF!tfP!zj|G-{_TqDTUY$H%l!BW@S`)6MgV;` zc7%J44u5m&KLz5?YO#^~?ux=$&@*DJXVR7%zrHc5&-A6a-q;xNNAvIqFMb?}wONi` zApf(P{Fb(1KRx#KVQ2Zb>0Z*UQj>p zGTiT78!T%Rr@rvF275{>W_7Wx71j?#>jeDzuE~$eAzJ&V@!S7oe4C@l7KJZ66WO!t8b7eq+k1YOn5&iInyavWpqWE-o$x2WH`WZ+Wc0w^oL>&) zM`wU4DXAjd(Y}jBAgQdwHN?A*@{|_-sE;Q|U*tVv&joz%tEP8Poae&&fhZ4DKZuhC z8HPBsL%{b2MV0~A)K7|fJ%4K4$@2E2@JHnU)zRq8MQO~j3hM`=^+FO`Q7#(8)%3kl z`DO|@YWK-9%;%m5f4!{9k=XnQS6cWZT?;$IflqHZ!z~@wWb0;&Evz4i_7ICIUuX;` z$@hj86`McMZFf7LUj~l#Hr0CjRK1L*oEp>nb&s3uNa81}#pQAP$?E^-8LCN%lxx(xN z4cybnvAHOhw4rqng*){b@EJ+vmG62FHSEuDU0?Vkyij|9_HhWC7vj?$f1OJB(HZe( zwo`s|bB^ z@kQTIUF}~7vAUO)B#UGPGm#--Tvp==oP@xVBAXUCTmmjiG>YcwH14}gT}9^FUZb+2zgM22fZQ8NV^OKN^41hL-&9 zGUU%cN5k(IdUhGV{y?{=9I$~zjnAa|Z3W0n(lW4!pYMkB|0w+HBA>%a$x4!YO&$CNK=}$lz$bftGHV%WdeSrj(Icj^<=+*}63;!Q+ zcCje@(KEmU)y=3qkjngz82JgScTgFaUsL>scrI=Hf8^Qa4WX}^@&uewJ%Gmh2y;{) zpnAuvrg#nYTpIY-atv4JZx=L_&iupZ037`7cfS~uk2K--f;xvun>K~qZ z&p`tG3n8r3Y-sCUu%-;e(tsHFUxzRuKy|jC6N{myKtEIiBJe*C&wj|d$qyB{DKj1o z2*5uD?)mZrYV!QwYJh`3IfirjzZJAeFo7BX{M~^p1U_uK*`$Fc4K!(>Ndrw9XwpEF z2AVX`q=6<4G-;qo13$e6@Ou7T<4gY>pFfM=@56f|OO4M@5wxep$Zf)CACTY666xk^teSuuS+VELZ!PkVADY z)qY^Psyb4&AFeB^bFKD60_D|-aQsZXw3-W!UluQ{)&s|n;aSyUk$xX`wO&ZS8b?gh zFT>{ttq{Vo05Q0!3PHFi_zf}BP&ZF}@KXK!coT0OMLrar5}v<2@s~Fy z3Qh?h#pn0o^E3JU)h|6a|5D!cWy=Y6x#_D_@cMmt-gl^@s(9XaS@1`p3_0nmL4DxE z<1fdU6u&&w4^(|1Kfw?uzAVJ&_2ZQHhgJy6ViO7@!poNgfOZqd1p@lZ6p%aBpQ*i$(D>x!33- zt6GiRd(~2Ly?7SEHO24q-!;0*;`QR^`25%S{Ppcc##+8WY#H)b7RTaEByeANcN~rDJ2h^%D$t5l?lE_c$!bD9~*!lY> zA-xXb=G_>=)kc0&4=i=g_a}JE11cMrfY3K0Valv=|LP(?sRuLo4u>YRodV8%%7^qv z05y|T3vjN2D$ov~3LtdOf{vtVh!egp@>}bg;3m#1;k&5+5NDQ?ZKr@f#8b#897shR zo_v>J#!du3T& zWTwWgt^8&dLwWeLV09BIKdA>nXD7+g<*Kw=A9)J$@uUfUq>mO6n+;j-}owwl4nkn1{-i855+;&Q$gSyDivYEo=ThHqzw1HZZ=>Xag zU9Vf*Gzi&S?g!ag9~(y2x*3)S8EPONNKo@}uwP7`mqM#Mk%8!7X%6 zaa|a93iu8^+M78Ss7~KE;V&C?k{{jy1CMO345rrO#g-p^>xJ^J<2v5=a7%_8H-7cG zQ_XS3@5h}Vfag^}Cj7G0#$Q+Ysd6w{?c(xJzTbje3uYIdx)bTa+;X_kxJIhYab=jF z0$!=~75qs+cD0e4j8j+nEsfi9an$k7$E|hrg~~zbLf;D4YkQ3w2gz>SVw31NP|i?X zV>J~V%~SE|p$hz|K%S&blDKu1-%3jp`oVv3<*_jBDO46~Z5^IA1Ie!Ec?q}DV+xHU zsQeJ;ZYn77>jC-C0(Aotd+xgK@@o*^Z6e=g6UU`OWifGD!PQfB_(I%**->bCL9$ct znZmeJLlMTEyzd3l!wsMypc?0{r6Io+)LW$ddTp#fqY+t0N=tq#13Qqlx*v<)0n`_y%1pgBvl>-zvXk-~2%fV>=bCH;vXHv` zpm(Fz2e>j@u)5%e?q|4uQhr^pJGhmhW4*{PV$GryWL^aH-I@j5%LF3YRcaZk&9#|D z2v=TX=Qxnl1!uI_3Vplk_W;e4AwB5ASiyqTRcbmQ=iLOXS^W#LMF4&G-q#2I*1AT- zTp%GMniJGz5Hecp=;79e4xkSws3)t-gm$|}xS3^0opd1LyVv(Y=Fvbx>;3wWAC(zn zN*ix#IpTZyKqsi5Oui4OU6iz}Rk!;Jg4+G7AoD;VSyH~5TnH<6`8Tx*gR*=d`nbZ| zEoy(*sLfG#F2 z`%+~UbZu%mk}H>q(=w6aspCgumpC3Bpl>E2y9D5oN?*Xg6-X*)vWX)T2fgkR;dQ}9>m-8oMBd}qfdQlaz_4 z9<&}OQhpSNX&=SSL(6du-b{`=&PDS+vw@^G=TCh%#F5_w#wlcdjoK{>vjH{AL#+qV z_!yNTQa4nWJZ~6{T{LZ?x%p^1uB)WWDNY3bYHY)(@<@6uj{Hnp{`u)boC$T86{mft z%0s=T)`RAJIwRUJv}Q_qx6%5krgQuoQrF<_1QNR~lT>yTUL5&R-yZeZD0~sus7^4o z9s}*6o)HX~(K^0dq?2BtTZ2(;e{&qdc&`%V<@SF*>9f)6d$p0S5K~#K6p4CNu z)P@PM`$21$!&U9pqk1eD=qeCZ7G%p(qiqy~84iRnr^ZFscYvhwy@A@uPuhJ!T(UkR z-CVfW2Z%>D;FSh66^Q(<=_j}rIwwKJ55ffj3DSWFHC%14C6OQ5{i(qD(R^~FHY^>; zrzRdijz55qEr)AOVX4m{{0JaYcL@I&>hro>izB}X+m+oA-=!}3iyIeYZVwa%)K1(u z;)D|>e+{-v>mi=GIO#X`F#Ph1m_tRjYYTw%8oNxA@JGsz)|b)RiGVdEG>1A3NL7+_ zf0nSM{NVD}Yxm&ZD4=?^ziDVa36MVz?hOFqX~PG}d%MX7p~i zZmj$@@ztb(CJoe&285kMkidDckmIsR*Qfe|%{hI6%{i5U%{i@s%{laj%{eWH%{jJ# z%{h+;TxJ!-1y{ZT&Qt)GhK|4h7YlM+I4%}d{TyR*TzQU5<8#UTWH?SdG95BqG9OO99R88WM0zBjq|29K$ zIP#+-KOi2hf+IIh$V1D*LFu5!clcT4LSn_p#fcwR?~oSyv3&>!MInA9ZV4Q}Fb6Fl zp}|WC5cix2u#9LrCju1Gh?D7T!{ZxHw;Fsi|93!AT z;mEG8UTo5(jUCB9vMJ}Azd?JtkzJgowDXi^dL;j7Ega1|lWRznwX^pSW(kn7H1m~K zTKxQzbLRr)Ptm?Gw6zYPQ{F8DZ`%wXV zFESv0Cm`9n@=@FP;_^?*f#$IERg`f>-m?qHalM?le85~|cc4i?aB@`bf0FWVrEiNf zz<$u&s11&6VEFbv`~&HQ0?7;G2>6u+S`I|c8J~x1vF6(ar6~#j7Qi!F4~G1RHDeMJ z0ectFyxMdi9=i^RM+Ztm@%iW?_<6z#;#JT+N%%Lm`_B%=+~j-Pd}M>B#%qK27Sdk; zWC;9WKx_HG%oNt%IJ#q5jS*;g|HyU<*%(4T#5+KiA7L(O1FE9ygKK#|LxeD})VC(WqxGNab6uuNTU{1=LU4 z-Vcay$S%#C)m>m%0q3@$J*AU?;E3Crf3@MCWj&!KqAk^s15(9+>>R4^)Cf`|8>#?=sohl!>e}c01R{=aDJ0&k5 z?)Y8Y`k$M&E)&X7bz5d(;`iO2S0mUPW~C+Jo^eV!BJ&Z@Z%1v%X`qfkG$4uEFEU-^ zqWvpK*T#-ZU?1CZ9vKYU9LFsUYQL99z_3IDEb$?HuXWyj*Sd#Mwzz;c(QwrB;-oS|DRghaF|L|_=yJg@^ zn{a_VFl}cL{G)yXKVOz;yx@76cux>6{Lhh}I5UKI-!nOHBi2~}HSuo-w#lfB3)LZ< z(}3CnZSP08rD4nOctm9iVT8h%Sp;*R3$U9(`%%XMNi?3UiGRaZ`w0Fm44k-m^Xt*5 z<#r+rxi^#lIeL%vJaXaveuOj3hVY*|a2(!Bgx-+;O|3=?<^R9; z_$Jt$f4`rCr~|cZaV?Ka#5h3JcnIouW&w2v`mZvfGA8D=DE=*BPaC=K6O|7p+8?4V zz#bg!A%VT_NKO`IKLlwzN$%ZKRI{tp^7^Mtt6f2rmiMFKVzMYQm?-{{O*6w~Gr>&+bAdrC=^9}`9B!4wM6wN>M zt3bD?ub@xagAIAYJj6BJ*kKV)^xt65nWcdPx2&l!0?9fM&7H0U;^})q{4ppEpf5nZ zYvNJV^BVal+jQ77Yysycog+aZ2n0(2v-wi@xgU%qj7C_bUsA&-e?X#giLV#r=Wb{z6KDo^CiO} z_Z5(}pzt-_laznb_K)0qCP#0EE9-dQ)b_d=2Ru;Esd@r_fp%eV-wEg@5LE_ro|VLB zI*H4_5Zk>Rh~Eo{I12*uR|1LxBIgF%3)3Hj(E;MOT^=DUys-N<@lW=@$vP0iqyG0X zAfdLNz!$O$4hEtL%NN~;=ej`GfzUkMAs{7D;RQVt#=n5^589i!3#bK!uXN&KFL4Y6RFB;fBhPsq4N|4)`61d zQ(xgp{=p^NoFceJ=V8`*PQJc)6_hT8|9rSc=b6^)oHD_b9(}McN2uR}o1&Ae;ALJbZgwA@J4fMlpT2TII4wZ9u0Xl1G zIgnJ(D-fk9lt0RwZ|w&4$>M?P-KK=hpE|cvz4k(^A)z_hLqK&puR|R9sri%Fd}}9} zz=zbo@ghimGJoV+zk;z3^*%^^Gk+6N@8Hsgjz7ozev zXS9W}e?HE%o=D7T@SWNI0OF$af{1lJ$gc|!Id(?lwC7M^?2xQ5p zYJuLG@;5abM5J#fV6L1AXSbrf_1rRH-ph#45oP`sjYszZiTuVzP5GnqGSGS(nv){+ zj$CtNU%3AO=svt7ZamM>bG|dPPeMNJfxgp0jrk)CTEr7`OJx3*`f#>|XZ6`OOjxre z?+bF#`T=@(As(3@E*rxf8(KF(n4>vwN!GE*aX;sK4-X;VF+gaHQKS4w4u<10 zV$B21kC}kI5t3cMO*(F=XDL$ueCx*KoIJN)5|+OyoJ~O0tp(JDNNzJ$uj>3@{?r)G zEk*Obg4PiD+75KaYFD7|<{|{;ZwY7Mp|ueze&|{s)(#C@B=OuwXDcC_01G&4gwPZ5 zy&0nIM05EmKqB{i3(DUZ*1q*&t)4%B7M#sijk`V1GZQ%H3fY0EYS^PPj_f!Sfy6pv zSWx~3tq*~&;k+DlK0N4|%LmjkI&d}=;t$=k)C8SyzPF@iHTSEQ=hZ3|tA$;@d&x!I z5R|_etbgjjnTyqR+Zo&xtxs|0^%-zB33{fdZa{wT3eBfP$;oSJL-_qb&w;E&WXbbL zQ2vCxunx|$9EBUe`H<+$d?dG#%NE=a>OsVps^x5y??fQ(x)|KI20}J-g+PWp842+S z%^&nj>Yb%yfvYk9z%^PRo23FGe@Oow5RVNB_)$AdK1Xw)T;7CaFS7qZtiAqcH;CqB zf75Jx8_nsh;^Y&-pDx^=07TByF5rL8?-wLL#qJWlLq~zWn^T26IsplsmxlZ392PWh zYRaDF)q?;twxvKgu8Vqd0cIo z&^5XT&rlc?Ru*VDP$5t$j(L?6X`?()UXTUxL-Q19LYD=^ou`1-nSY)FFsjr1DcFte zgL01AC40z9;vcND9OA4ySHX2Eq$9Fj<<9`cC+kx*ZqQL@p)o(t+3QeWr~{#MOSt_* zG98}yVC#WL zV@nQ_z6rYx z6?MG1Q6OMli257}h);0MfYA4@){}gbE{abnzegZHZ}J(0=eFZ;jcoG2+uIL%+z;;$ qYLgtvc#^n+bRtRoD5yo4K*U_@3W$kn8)3B&x({KRU8|0R;{Feawb-Ko literal 0 HcmV?d00001 diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..36efead --- /dev/null +++ b/nuget.config @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..b4fd8ad --- /dev/null +++ b/shell.nix @@ -0,0 +1,15 @@ +{pkgs ? import {}}: let + dotnet = with pkgs.dotnetCorePackages; + combinePackages [ + sdk_8_0 + aspnetcore_8_0 + ]; +in + pkgs.mkShell { + packages = with pkgs; [ + dotnet + csharpier + ]; + + DOTNET_ROOT = "${dotnet}"; + } diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..7683270 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,47 @@ + + + net8.0 + default + enable + Kyoo + Kyoo + Copyright (c) Kyoo + true + GPL-3.0-or-later + true + + https://github.com/zoriya/Kyoo + git + true + https://github.com/zoriya/Kyoo + + 1.0.0 + true + snupkg + + $(MSBuildThisFileDirectory)../icon.ico + + true + + + + true + + + + $(MsBuildThisFileDirectory)/../out/obj/$(MSBuildProjectName) + $(MsBuildThisFileDirectory)/../out/bin/$(MSBuildProjectName) + + + + + + + + $(MSBuildThisFileDirectory)../Kyoo.ruleset + 1591;1305;8618;SYSLIB1045;CS1573 + + + + + diff --git a/src/Kyoo.Abstractions/.gitignore b/src/Kyoo.Abstractions/.gitignore new file mode 100644 index 0000000..8f7864d --- /dev/null +++ b/src/Kyoo.Abstractions/.gitignore @@ -0,0 +1,232 @@ +## PROJECT CUSTOM IGNORES + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +bin/ +Bin/ +obj/ +Obj/ + +# Visual Studio 2015 cache/options directory +.vs/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Microsoft Azure ApplicationInsights config file +ApplicationInsights.config + +# Windows Store app package directory +AppPackages/ +BundleArtifacts/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +orleans.codegen.cs + +/node_modules + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe + +# FAKE - F# Make +.fake/ diff --git a/src/Kyoo.Abstractions/Controllers/IIssueRepository.cs b/src/Kyoo.Abstractions/Controllers/IIssueRepository.cs new file mode 100644 index 0000000..831e50b --- /dev/null +++ b/src/Kyoo.Abstractions/Controllers/IIssueRepository.cs @@ -0,0 +1,35 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; + +namespace Kyoo.Abstractions.Controllers; + +public interface IIssueRepository +{ + Task> GetAll(Filter? filter = default); + + Task GetCount(Filter? filter = default); + + Task Upsert(Issue issue); + + Task DeleteAll(Filter? filter = default); +} diff --git a/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs b/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs new file mode 100644 index 0000000..8a1516b --- /dev/null +++ b/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs @@ -0,0 +1,80 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Models; + +namespace Kyoo.Abstractions.Controllers; + +/// +/// An interface to interact with the database. Every repository is mapped through here. +/// +public interface ILibraryManager +{ + IRepository Repository() + where T : IResource, IQuery; + + /// + /// The repository that handle libraries items (a wrapper around shows and collections). + /// + IRepository LibraryItems { get; } + + /// + /// The repository that handle new items. + /// + IRepository News { get; } + + /// + /// The repository that handle watched items. + /// + IWatchStatusRepository WatchStatus { get; } + + /// + /// The repository that handle collections. + /// + IRepository Collections { get; } + + /// + /// The repository that handle shows. + /// + IRepository Movies { get; } + + /// + /// The repository that handle shows. + /// + IRepository Shows { get; } + + /// + /// The repository that handle seasons. + /// + IRepository Seasons { get; } + + /// + /// The repository that handle episodes. + /// + IRepository Episodes { get; } + + /// + /// The repository that handle studios. + /// + IRepository Studios { get; } + + /// + /// The repository that handle users. + /// + IRepository Users { get; } +} diff --git a/src/Kyoo.Abstractions/Controllers/IPermissionValidator.cs b/src/Kyoo.Abstractions/Controllers/IPermissionValidator.cs new file mode 100644 index 0000000..4aa3562 --- /dev/null +++ b/src/Kyoo.Abstractions/Controllers/IPermissionValidator.cs @@ -0,0 +1,46 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Models.Permissions; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Kyoo.Abstractions.Controllers; + +/// +/// A service to validate permissions. +/// +public interface IPermissionValidator +{ + /// + /// Create an IAuthorizationFilter that will be used to validate permissions. + /// This can registered with any lifetime. + /// + /// The permission attribute to validate. + /// An authorization filter used to validate the permission. + IFilterMetadata Create(PermissionAttribute attribute); + + /// + /// Create an IAuthorizationFilter that will be used to validate permissions. + /// This can registered with any lifetime. + /// + /// + /// A partial attribute to validate. See . + /// + /// An authorization filter used to validate the permission. + IFilterMetadata Create(PartialPermissionAttribute attribute); +} diff --git a/src/Kyoo.Abstractions/Controllers/IRepository.cs b/src/Kyoo.Abstractions/Controllers/IRepository.cs new file mode 100644 index 0000000..4dbdd95 --- /dev/null +++ b/src/Kyoo.Abstractions/Controllers/IRepository.cs @@ -0,0 +1,267 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Abstractions.Models.Utils; + +namespace Kyoo.Abstractions.Controllers; + +/// +/// A common repository for every resources. +/// +/// The resource's type that this repository manage. +public interface IRepository : IBaseRepository + where T : IResource, IQuery +{ + /// + /// The event handler type for all events of this repository. + /// + /// The resource created/modified/deleted + /// A representing the asynchronous operation. + public delegate Task ResourceEventHandler(T resource); + + /// + /// Get a resource from it's ID. + /// + /// The id of the resource + /// The related fields to include. + /// If the item could not be found. + /// The resource found + Task Get(Guid id, Include? include = default); + + /// + /// Get a resource from it's slug. + /// + /// The slug of the resource + /// The related fields to include. + /// If the item could not be found. + /// The resource found + Task Get(string slug, Include? include = default); + + /// + /// Get the first resource that match the predicate. + /// + /// A predicate to filter the resource. + /// The related fields to include. + /// A custom sort method to handle cases where multiples items match the filters. + /// Reverse the sort. + /// Select the first element after this id if it was in a list. + /// If the item could not be found. + /// The resource found + Task Get( + Filter filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ); + + /// + /// Get a resource from it's ID or null if it is not found. + /// + /// The id of the resource + /// The related fields to include. + /// The resource found + Task GetOrDefault(Guid id, Include? include = default); + + /// + /// Get a resource from it's slug or null if it is not found. + /// + /// The slug of the resource + /// The related fields to include. + /// The resource found + Task GetOrDefault(string slug, Include? include = default); + + /// + /// Get the first resource that match the predicate or null if it is not found. + /// + /// A predicate to filter the resource. + /// The related fields to include. + /// A custom sort method to handle cases where multiples items match the filters. + /// Reverse the sort. + /// Select the first element after this id if it was in a list. + /// The resource found + Task GetOrDefault( + Filter? filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ); + + /// + /// Search for resources with the database. + /// + /// The query string. + /// The related fields to include. + /// A list of resources found + Task> Search(string query, Include? include = default); + + /// + /// Get every resources that match all filters + /// + /// A filter predicate + /// Sort information about the query (sort by, sort order) + /// The related fields to include. + /// How pagination should be done (where to start and how many to return) + /// A list of resources that match every filters + Task> GetAll( + Filter? filter = null, + Sort? sort = default, + Include? include = default, + Pagination? limit = default + ); + + /// + /// Get the number of resources that match the filter's predicate. + /// + /// A filter predicate + /// How many resources matched that filter + Task GetCount(Filter? filter = null); + + /// + /// Map a list of ids to a list of items (keep the order). + /// + /// The list of items id. + /// The related fields to include. + /// A list of resources mapped from ids. + Task> FromIds(IList ids, Include? include = default); + + /// + /// Create a new resource. + /// + /// The item to register + /// The resource registers and completed by database's information (related items and so on) + Task Create(T obj); + + /// + /// Create a new resource if it does not exist already. If it does, the existing value is returned instead. + /// + /// The object to create + /// The newly created item or the existing value if it existed. + Task CreateIfNotExists(T obj); + + /// + /// Called when a resource has been created. + /// + static event ResourceEventHandler OnCreated; + + /// + /// Callback that should be called after a resource has been created. + /// + /// The resource newly created. + /// A representing the asynchronous operation. + protected static Task OnResourceCreated(T obj) => OnCreated?.Invoke(obj) ?? Task.CompletedTask; + + /// + /// Edit a resource and replace every property + /// + /// The resource to edit, it's ID can't change. + /// If the item is not found + /// The resource edited and completed by database's information (related items and so on) + Task Edit(T edited); + + /// + /// Edit only specific properties of a resource + /// + /// The id of the resource to edit + /// + /// A method that will be called when you need to update every properties that you want to + /// persist. + /// + /// If the item is not found + /// The resource edited and completed by database's information (related items and so on) + Task Patch(Guid id, Func patch); + + /// + /// Called when a resource has been edited. + /// + static event ResourceEventHandler OnEdited; + + /// + /// Callback that should be called after a resource has been edited. + /// + /// The resource newly edited. + /// A representing the asynchronous operation. + protected static Task OnResourceEdited(T obj) => OnEdited?.Invoke(obj) ?? Task.CompletedTask; + + /// + /// Delete a resource by it's ID + /// + /// The ID of the resource + /// If the item is not found + /// A representing the asynchronous operation. + Task Delete(Guid id); + + /// + /// Delete a resource by it's slug + /// + /// The slug of the resource + /// If the item is not found + /// A representing the asynchronous operation. + Task Delete(string slug); + + /// + /// Delete a resource + /// + /// The resource to delete + /// If the item is not found + /// A representing the asynchronous operation. + Task Delete(T obj); + + /// + /// Delete all resources that match the predicate. + /// + /// A predicate to filter resources to delete. Every resource that match this will be deleted. + /// A representing the asynchronous operation. + Task DeleteAll(Filter filter); + + /// + /// Called when a resource has been edited. + /// + static event ResourceEventHandler OnDeleted; + + /// + /// Callback that should be called after a resource has been deleted. + /// + /// The resource newly deleted. + /// A representing the asynchronous operation. + protected static Task OnResourceDeleted(T obj) => OnDeleted?.Invoke(obj) ?? Task.CompletedTask; +} + +/// +/// A base class for repositories. Every service implementing this will be handled by the . +/// +public interface IBaseRepository +{ + /// + /// The type for witch this repository is responsible or null if non applicable. + /// + Type RepositoryType { get; } +} + +public interface IUserRepository : IRepository +{ + Task GetByExternalId(string provider, string id); + Task AddExternalToken(Guid userId, string provider, ExternalToken token); + Task DeleteExternalToken(Guid userId, string provider); +} diff --git a/src/Kyoo.Abstractions/Controllers/IScanner.cs b/src/Kyoo.Abstractions/Controllers/IScanner.cs new file mode 100644 index 0000000..776131b --- /dev/null +++ b/src/Kyoo.Abstractions/Controllers/IScanner.cs @@ -0,0 +1,28 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Threading.Tasks; + +namespace Kyoo.Abstractions.Controllers; + +public interface IScanner +{ + Task SendRescanRequest(); + Task SendRefreshRequest(string kind, Guid id); +} diff --git a/src/Kyoo.Abstractions/Controllers/ISearchManager.cs b/src/Kyoo.Abstractions/Controllers/ISearchManager.cs new file mode 100644 index 0000000..d3985c6 --- /dev/null +++ b/src/Kyoo.Abstractions/Controllers/ISearchManager.cs @@ -0,0 +1,125 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; + +namespace Kyoo.Abstractions.Controllers; + +/// +/// The service to search items. +/// +public interface ISearchManager +{ + /// + /// Search for items. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchItems( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ); + + /// + /// Search for movies. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchMovies( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ); + + /// + /// Search for shows. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchShows( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ); + + /// + /// Search for collections. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchCollections( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ); + + /// + /// Search for episodes. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchEpisodes( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ); + + /// + /// Search for studios. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchStudios( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ); +} diff --git a/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs b/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs new file mode 100644 index 0000000..a1a37aa --- /dev/null +++ b/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs @@ -0,0 +1,43 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.IO; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; + +namespace Kyoo.Abstractions.Controllers; + +public interface IThumbnailsManager +{ + Task DownloadImages(T item) + where T : IThumbnails; + + Task DownloadImage(Image? image, string what); + + Task IsImageSaved(Guid imageId, ImageQuality quality); + + Task GetImage(Guid imageId, ImageQuality quality); + + Task DeleteImages(T item) + where T : IThumbnails; + + Task GetUserImage(Guid userId); + + Task SetUserImage(Guid userId, Stream? image); +} diff --git a/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs b/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs new file mode 100644 index 0000000..17ebe40 --- /dev/null +++ b/src/Kyoo.Abstractions/Controllers/IWatchStatusRepository.cs @@ -0,0 +1,83 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; + +namespace Kyoo.Abstractions.Controllers; + +/// +/// A local repository to handle watched items +/// +public interface IWatchStatusRepository +{ + public delegate Task ResourceEventHandler(T resource); + + Task> GetAll( + Filter? filter = default, + Include? include = default, + Pagination? limit = default + ); + + Task GetMovieStatus(Guid movieId, Guid userId); + + Task SetMovieStatus( + Guid movieId, + Guid userId, + WatchStatus status, + int? watchedTime, + int? percent + ); + + static event ResourceEventHandler> OnMovieStatusChangedHandler; + protected static Task OnMovieStatusChanged(WatchStatus obj) => + OnMovieStatusChangedHandler?.Invoke(obj) ?? Task.CompletedTask; + + Task DeleteMovieStatus(Guid movieId, Guid userId); + + Task GetShowStatus(Guid showId, Guid userId); + + Task SetShowStatus(Guid showId, Guid userId, WatchStatus status); + + static event ResourceEventHandler> OnShowStatusChangedHandler; + protected static Task OnShowStatusChanged(WatchStatus obj) => + OnShowStatusChangedHandler?.Invoke(obj) ?? Task.CompletedTask; + + Task DeleteShowStatus(Guid showId, Guid userId); + + Task GetEpisodeStatus(Guid episodeId, Guid userId); + + /// Where the user has stopped watching. Only usable if Status + /// is + Task SetEpisodeStatus( + Guid episodeId, + Guid userId, + WatchStatus status, + int? watchedTime, + int? percent + ); + + static event ResourceEventHandler> OnEpisodeStatusChangedHandler; + protected static Task OnEpisodeStatusChanged(WatchStatus obj) => + OnEpisodeStatusChangedHandler?.Invoke(obj) ?? Task.CompletedTask; + + Task DeleteEpisodeStatus(Guid episodeId, Guid userId); +} diff --git a/src/Kyoo.Abstractions/Extensions.cs b/src/Kyoo.Abstractions/Extensions.cs new file mode 100644 index 0000000..42f4b0a --- /dev/null +++ b/src/Kyoo.Abstractions/Extensions.cs @@ -0,0 +1,64 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Authentication.Models; + +namespace Kyoo.Authentication; + +/// +/// Extension methods. +/// +public static class Extensions +{ + /// + /// Get the permissions of an user. + /// + /// The user + /// The list of permissions + public static ICollection GetPermissions(this ClaimsPrincipal user) + { + return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',') + ?? Array.Empty(); + } + + /// + /// Get the id of the current user or null if unlogged or invalid. + /// + /// The user. + /// The id of the user or null. + public static Guid? GetId(this ClaimsPrincipal user) + { + Claim? value = user.FindFirst(Claims.Id); + if (Guid.TryParse(value?.Value, out Guid id)) + return id; + return null; + } + + public static Guid GetIdOrThrow(this ClaimsPrincipal user) + { + Guid? ret = user.GetId(); + if (ret == null) + throw new UnauthorizedException(); + return ret.Value; + } +} diff --git a/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj b/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj new file mode 100644 index 0000000..a7727ec --- /dev/null +++ b/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj @@ -0,0 +1,17 @@ + + + Kyoo.Abstractions + Base package to create plugins for Kyoo. + Kyoo.Abstractions + + + + + + + + + + + + diff --git a/src/Kyoo.Abstractions/Models/Attributes/ApiDefinitionAttribute.cs b/src/Kyoo.Abstractions/Models/Attributes/ApiDefinitionAttribute.cs new file mode 100644 index 0000000..46014a6 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Attributes/ApiDefinitionAttribute.cs @@ -0,0 +1,51 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models.Attributes; + +/// +/// An attribute to specify on apis to specify it's documentation's name and category. +/// If this is applied on a method, the specified method will be exploded from the controller's page and be +/// included on the specified tag page. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class ApiDefinitionAttribute : Attribute +{ + /// + /// The public name of this api. + /// + public string Name { get; } + + /// + /// The name of the group in witch this API is. You can also specify a custom sort order using the following + /// format: order:name. Everything before the first : will be removed but kept for + /// th alphabetical ordering. + /// + public string? Group { get; set; } + + /// + /// Create a new . + /// + /// The name of the api that will be used on the documentation page. + public ApiDefinitionAttribute(string name) + { + Name = name; + } +} diff --git a/src/Kyoo.Abstractions/Models/Attributes/ComputedAttribute.cs b/src/Kyoo.Abstractions/Models/Attributes/ComputedAttribute.cs new file mode 100644 index 0000000..2fde7a8 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Attributes/ComputedAttribute.cs @@ -0,0 +1,27 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models.Attributes; + +/// +/// An attribute to inform that the property is computed automatically and can't be assigned manually. +/// +[AttributeUsage(AttributeTargets.Property)] +public class ComputedAttribute : NotMergeableAttribute { } diff --git a/src/Kyoo.Abstractions/Models/Attributes/LoadableRelationAttribute.cs b/src/Kyoo.Abstractions/Models/Attributes/LoadableRelationAttribute.cs new file mode 100644 index 0000000..8b40679 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Attributes/LoadableRelationAttribute.cs @@ -0,0 +1,53 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models.Attributes; + +/// +/// The targeted relation can be loaded. +/// +[AttributeUsage(AttributeTargets.Property)] +public class LoadableRelationAttribute : Attribute +{ + /// + /// The name of the field containing the related resource's ID. + /// + public string? RelationID { get; } + + public string? Sql { get; set; } + + public string? On { get; set; } + + public string? Projected { get; set; } + + /// + /// Create a new . + /// + public LoadableRelationAttribute() { } + + /// + /// Create a new with a baking relationID field. + /// + /// The name of the RelationID field. + public LoadableRelationAttribute(string relationID) + { + RelationID = relationID; + } +} diff --git a/src/Kyoo.Abstractions/Models/Attributes/NotMergeableAttribute.cs b/src/Kyoo.Abstractions/Models/Attributes/NotMergeableAttribute.cs new file mode 100644 index 0000000..138ec9a --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Attributes/NotMergeableAttribute.cs @@ -0,0 +1,39 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models.Attributes; + +/// +/// Specify that a property can't be merged. +/// +[AttributeUsage(AttributeTargets.Property)] +public class NotMergeableAttribute : Attribute { } + +/// +/// An interface with a method called when this object is merged. +/// +public interface IOnMerge +{ + /// + /// This function is called after the object has been merged. + /// + /// The object that has been merged with this. + void OnMerge(object merged); +} diff --git a/src/Kyoo.Abstractions/Models/Attributes/OneOfAttribute.cs b/src/Kyoo.Abstractions/Models/Attributes/OneOfAttribute.cs new file mode 100644 index 0000000..de06460 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Attributes/OneOfAttribute.cs @@ -0,0 +1,33 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models.Attributes; + +/// +/// An attribute to inform that this interface is a type union +/// +[AttributeUsage(AttributeTargets.Interface)] +public class OneOfAttribute : Attribute +{ + /// + /// The types this union concist of. + /// + public Type[] Types { get; set; } +} diff --git a/src/Kyoo.Abstractions/Models/Attributes/Permission/PartialPermissionAttribute.cs b/src/Kyoo.Abstractions/Models/Attributes/Permission/PartialPermissionAttribute.cs new file mode 100644 index 0000000..ea30082 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Attributes/Permission/PartialPermissionAttribute.cs @@ -0,0 +1,87 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using Kyoo.Abstractions.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; + +namespace Kyoo.Abstractions.Models.Permissions; + +/// +/// Specify one part of a permissions needed for the API (the kind or the type). +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public class PartialPermissionAttribute : Attribute, IFilterFactory +{ + /// + /// The needed permission type. + /// + public string? Type { get; } + + /// + /// The needed permission kind. + /// + public Kind? Kind { get; } + + /// + /// The group of this permission. + /// + public Group Group { get; set; } + + /// + /// Ask a permission to run an action. + /// + /// + /// With this attribute, you can only specify a type or a kind. + /// To have a valid permission attribute, you must specify the kind and the permission using two attributes. + /// Those attributes can be dispatched at different places (one on the class, one on the method for example). + /// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will + /// lead to unspecified behaviors. + /// + /// The type of the action + public PartialPermissionAttribute(string type) + { + Type = type.ToLower(); + } + + /// + /// Ask a permission to run an action. + /// + /// + /// With this attribute, you can only specify a type or a kind. + /// To have a valid permission attribute, you must specify the kind and the permission using two attributes. + /// Those attributes can be dispatched at different places (one on the class, one on the method for example). + /// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will + /// lead to unspecified behaviors. + /// + /// The kind of permission needed. + public PartialPermissionAttribute(Kind permission) + { + Kind = permission; + } + + /// + public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) + { + return serviceProvider.GetRequiredService().Create(this); + } + + /// + public bool IsReusable => true; +} diff --git a/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs b/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs new file mode 100644 index 0000000..ba1ff74 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs @@ -0,0 +1,136 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using Kyoo.Abstractions.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; + +namespace Kyoo.Abstractions.Models.Permissions; + +/// +/// The kind of permission needed. +/// +public enum Kind +{ + /// + /// Allow the user to read for this kind of data. + /// + Read, + + /// + /// Allow the user to write for this kind of data. + /// + Write, + + /// + /// Allow the user to create this kind of data. + /// + Create, + + /// + /// Allow the user to delete this kind of data. + /// + Delete, + + /// + /// Allow the user to play this file. + /// + Play, +} + +/// +/// The group of the permission. +/// +public enum Group +{ + /// + /// Default group indicating no value. + /// + None, + + /// + /// Allow all operations on basic items types. + /// + Overall, + + /// + /// Allow operation on sensitive items like libraries path, configurations and so on. + /// + Admin +} + +/// +/// Specify permissions needed for the API. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public class PermissionAttribute : Attribute, IFilterFactory +{ + /// + /// The needed permission as string. + /// + public string Type { get; } + + /// + /// The needed permission kind. + /// + public Kind Kind { get; } + + /// + /// The group of this permission. + /// + public Group Group { get; set; } + + /// + /// Ask a permission to run an action. + /// + /// + /// The type of the action + /// + /// + /// The kind of permission needed. + /// + /// + /// The group of this permission (allow grouped permission like overall.read + /// for all read permissions of this group). + /// + public PermissionAttribute(string type, Kind permission, Group group = Group.Overall) + { + Type = type.ToLower(); + Kind = permission; + Group = group; + } + + /// + public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) + { + return serviceProvider.GetRequiredService().Create(this); + } + + /// + public bool IsReusable => true; + + /// + /// Return this permission attribute as a string. + /// + /// The string representation. + public string AsPermissionString() + { + return Type; + } +} diff --git a/src/Kyoo.Abstractions/Models/Attributes/Permission/UserOnlyAttribute.cs b/src/Kyoo.Abstractions/Models/Attributes/Permission/UserOnlyAttribute.cs new file mode 100644 index 0000000..a50424a --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Attributes/Permission/UserOnlyAttribute.cs @@ -0,0 +1,30 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models.Permissions; + +/// +/// The annotated route can only be accessed by a logged in user. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public class UserOnlyAttribute : Attribute +{ + // TODO: Implement a Filter Attribute to make this work. For now, this attribute is only useful as documentation. +} diff --git a/src/Kyoo.Abstractions/Models/Attributes/SqlFirstColumnAttribute.cs b/src/Kyoo.Abstractions/Models/Attributes/SqlFirstColumnAttribute.cs new file mode 100644 index 0000000..e420a1f --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Attributes/SqlFirstColumnAttribute.cs @@ -0,0 +1,37 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using Kyoo.Utils; + +namespace Kyoo.Abstractions.Models.Attributes; + +[AttributeUsage(AttributeTargets.Class)] +public class SqlFirstColumnAttribute : Attribute +{ + /// + /// The name of the first column of the element. Used to split multiples + /// items on a single sql query. If not specified, it defaults to "Id". + /// + public string Name { get; set; } + + public SqlFirstColumnAttribute(string name) + { + Name = name.ToSnakeCase(); + } +} diff --git a/src/Kyoo.Abstractions/Models/Exceptions/DuplicatedItemException.cs b/src/Kyoo.Abstractions/Models/Exceptions/DuplicatedItemException.cs new file mode 100644 index 0000000..19f5ece --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Exceptions/DuplicatedItemException.cs @@ -0,0 +1,34 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models.Exceptions; + +/// +/// An exception raised when an item already exists in the database. +/// +[Serializable] +public class DuplicatedItemException(object? existing = null) + : Exception("Already exists in the database.") +{ + /// + /// The existing object. + /// + public object? Existing { get; } = existing; +} diff --git a/src/Kyoo.Abstractions/Models/Exceptions/ItemNotFoundException.cs b/src/Kyoo.Abstractions/Models/Exceptions/ItemNotFoundException.cs new file mode 100644 index 0000000..ef708f8 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Exceptions/ItemNotFoundException.cs @@ -0,0 +1,41 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models.Exceptions; + +/// +/// An exception raised when an item could not be found. +/// +[Serializable] +public class ItemNotFoundException : Exception +{ + /// + /// Create a default with no message. + /// + public ItemNotFoundException() + : base("Item not found") { } + + /// + /// Create a new with a message + /// + /// The message of the exception + public ItemNotFoundException(string message) + : base(message) { } +} diff --git a/src/Kyoo.Abstractions/Models/Exceptions/UnauthorizedException.cs b/src/Kyoo.Abstractions/Models/Exceptions/UnauthorizedException.cs new file mode 100644 index 0000000..89f09d7 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Exceptions/UnauthorizedException.cs @@ -0,0 +1,31 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models.Exceptions; + +[Serializable] +public class UnauthorizedException : Exception +{ + public UnauthorizedException() + : base("User not authenticated or token invalid.") { } + + public UnauthorizedException(string message) + : base(message) { } +} diff --git a/src/Kyoo.Abstractions/Models/Genre.cs b/src/Kyoo.Abstractions/Models/Genre.cs new file mode 100644 index 0000000..e2e4f6a --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Genre.cs @@ -0,0 +1,50 @@ +// 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 . + +namespace Kyoo.Abstractions.Models; + +/// +/// A genre that allow one to specify categories for shows. +/// +public enum Genre +{ + Action, + Adventure, + Animation, + Comedy, + Crime, + Documentary, + Drama, + Family, + Fantasy, + History, + Horror, + Music, + Mystery, + Romance, + ScienceFiction, + Thriller, + War, + Western, + Kids, + News, + Reality, + Soap, + Talk, + Politics, +} diff --git a/src/Kyoo.Abstractions/Models/ILibraryItem.cs b/src/Kyoo.Abstractions/Models/ILibraryItem.cs new file mode 100644 index 0000000..8b98aab --- /dev/null +++ b/src/Kyoo.Abstractions/Models/ILibraryItem.cs @@ -0,0 +1,31 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models.Attributes; + +namespace Kyoo.Abstractions.Models; + +/// +/// A show, a movie or a collection. +/// +[OneOf(Types = new[] { typeof(Show), typeof(Movie), typeof(Collection) })] +public interface ILibraryItem : IResource, IThumbnails, IMetadata, IAddedDate, IQuery +{ + static Sort IQuery.DefaultSort => new Sort.By(nameof(Movie.Name)); +} diff --git a/src/Kyoo.Abstractions/Models/INews.cs b/src/Kyoo.Abstractions/Models/INews.cs new file mode 100644 index 0000000..b5642f5 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/INews.cs @@ -0,0 +1,31 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models.Attributes; + +namespace Kyoo.Abstractions.Models; + +/// +/// A show, a movie or a collection. +/// +[OneOf(Types = [typeof(Episode), typeof(Movie)])] +public interface INews : IResource, IThumbnails, IAddedDate, IQuery +{ + static Sort IQuery.DefaultSort => new Sort.By(nameof(AddedDate), true); +} diff --git a/src/Kyoo.Abstractions/Models/IWatchlist.cs b/src/Kyoo.Abstractions/Models/IWatchlist.cs new file mode 100644 index 0000000..0302270 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/IWatchlist.cs @@ -0,0 +1,27 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Models.Attributes; + +namespace Kyoo.Abstractions.Models; + +/// +/// A watch list item. +/// +[OneOf(Types = new[] { typeof(Show), typeof(Movie) })] +public interface IWatchlist : IResource, IThumbnails, IMetadata, IAddedDate { } diff --git a/src/Kyoo.Abstractions/Models/Issues.cs b/src/Kyoo.Abstractions/Models/Issues.cs new file mode 100644 index 0000000..851dc29 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Issues.cs @@ -0,0 +1,52 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; + +namespace Kyoo.Abstractions.Models; + +/// +/// An issue that occured on kyoo. +/// +public class Issue : IAddedDate +{ + /// + /// The type of issue (for example, "Scanner" if this issue was created due to scanning error). + /// + public string Domain { get; set; } + + /// + /// Why this issue was caused? An unique cause that can be used to identify this issue. + /// For the scanner, a cause should be a video path. + /// + public string Cause { get; set; } + + /// + /// A human readable string explaining why this issue occured. + /// + public string Reason { get; set; } + + /// + /// Some extra data that could store domain-specific info. + /// + public Dictionary Extra { get; set; } = new(); + + /// + public DateTime AddedDate { get; set; } +} diff --git a/src/Kyoo.Abstractions/Models/MetadataID.cs b/src/Kyoo.Abstractions/Models/MetadataID.cs new file mode 100644 index 0000000..ec384ec --- /dev/null +++ b/src/Kyoo.Abstractions/Models/MetadataID.cs @@ -0,0 +1,61 @@ +// 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 . + +namespace Kyoo.Abstractions.Models; + +/// +/// ID and link of an item on an external provider. +/// +public class MetadataId +{ + /// + /// The ID of the resource on the external provider. + /// + public string DataId { get; set; } + + /// + /// The URL of the resource on the external provider. + /// + public string? Link { get; set; } +} + +/// +/// ID informations about an episode. +/// +public class EpisodeId +{ + /// + /// The Id of the show on the metadata database. + /// + public string ShowId { get; set; } + + /// + /// The season number or null if absolute numbering is used in this database. + /// + public int? Season { get; set; } + + /// + /// The episode number or absolute number if Season is null. + /// + public int Episode { get; set; } + + /// + /// The URL of the resource on the external provider. + /// + public string? Link { get; set; } +} diff --git a/src/Kyoo.Abstractions/Models/Page.cs b/src/Kyoo.Abstractions/Models/Page.cs new file mode 100644 index 0000000..75c0f18 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Page.cs @@ -0,0 +1,105 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Linq; +using Kyoo.Utils; + +namespace Kyoo.Abstractions.Models; + +/// +/// A page of resource that contains information about the pagination of resources. +/// +/// The type of resource contained in this page. +public class Page + where T : IResource +{ + /// + /// The link of the current page. + /// + public string This { get; } + + /// + /// The link of the first page. + /// + public string First { get; } + + /// + /// The link of the previous page. + /// + public string? Previous { get; } + + /// + /// The link of the next page. + /// + public string? Next { get; } + + /// + /// The number of items in the current page. + /// + public int Count => Items.Count; + + /// + /// The list of items in the page. + /// + public ICollection Items { get; } + + /// + /// Create a new . + /// + /// The list of items in the page. + /// The link of the current page. + /// The link of the previous page. + /// The link of the next page. + /// The link of the first page. + public Page(ICollection items, string @this, string? previous, string? next, string first) + { + Items = items; + This = @this; + Previous = previous; + Next = next; + First = first; + } + + /// + /// Create a new and compute the urls. + /// + /// The list of items in the page. + /// The base url of the resources available from this page. + /// The list of query strings of the current page + /// The number of items requested for the current page. + public Page(ICollection items, string url, Dictionary query, int limit) + { + Items = items; + This = url + query.ToQueryString(); + if (items.Count > 0 && query.ContainsKey("afterID")) + { + query["afterID"] = items.First().Id.ToString(); + query["reverse"] = "true"; + Previous = url + query.ToQueryString(); + } + query.Remove("reverse"); + if (items.Count == limit && limit > 0) + { + query["afterID"] = items.Last().Id.ToString(); + Next = url + query.ToQueryString(); + } + query.Remove("afterID"); + First = url + query.ToQueryString(); + } +} diff --git a/src/Kyoo.Abstractions/Models/Patch.cs b/src/Kyoo.Abstractions/Models/Patch.cs new file mode 100644 index 0000000..1eafe94 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Patch.cs @@ -0,0 +1,46 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; +using Kyoo.Abstractions.Models; + +namespace Kyoo.Models; + +public class Patch : Dictionary + where T : class, IResource +{ + public Guid? Id => this.GetValueOrDefault(nameof(IResource.Id))?.Deserialize(); + + public string? Slug => this.GetValueOrDefault(nameof(IResource.Slug))?.Deserialize(); + + public T Apply(T current) + { + foreach ((string property, JsonDocument value) in this) + { + PropertyInfo prop = typeof(T).GetProperty( + property, + BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance + )!; + prop.SetValue(current, value.Deserialize(prop.PropertyType)); + } + return current; + } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Collection.cs b/src/Kyoo.Abstractions/Models/Resources/Collection.cs new file mode 100644 index 0000000..caa6a13 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Collection.cs @@ -0,0 +1,100 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Kyoo.Abstractions.Controllers; +using Kyoo.Utils; + +namespace Kyoo.Abstractions.Models; + +/// +/// A class representing collections of . +/// +public class Collection + : IQuery, + IResource, + IMetadata, + IThumbnails, + IAddedDate, + IRefreshable, + ILibraryItem +{ + public static Sort DefaultSort => new Sort.By(nameof(Collection.Name)); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + + /// + /// The name of this collection. + /// + public string Name { get; set; } + + /// + /// The description of this collection. + /// + public string? Overview { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + public Image? Poster { get; set; } + + /// + public Image? Thumbnail { get; set; } + + /// + public Image? Logo { get; set; } + + /// + /// The list of movies contained in this collection. + /// + [JsonIgnore] + public ICollection? Movies { get; set; } + + /// + /// The list of shows contained in this collection. + /// + [JsonIgnore] + public ICollection? Shows { get; set; } + + /// + public Dictionary ExternalId { get; set; } = new(); + + /// + public DateTime? NextMetadataRefresh { get; set; } + + public Collection() { } + + [JsonConstructor] + public Collection(string name) + { + if (name != null) + { + Slug = Utility.ToSlug(name); + Name = name; + } + } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Episode.cs b/src/Kyoo.Abstractions/Models/Resources/Episode.cs new file mode 100644 index 0000000..f32bf74 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Episode.cs @@ -0,0 +1,302 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using EntityFrameworkCore.Projectables; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models.Attributes; + +namespace Kyoo.Abstractions.Models; + +/// +/// A class to represent a single show's episode. +/// +public class Episode : IQuery, IResource, IThumbnails, IAddedDate, IRefreshable, INews +{ + // Use absolute numbers by default and fallback to season/episodes if it does not exists. + public static Sort DefaultSort => + new Sort.Conglomerate( + new Sort.By(x => x.AbsoluteNumber), + new Sort.By(x => x.SeasonNumber), + new Sort.By(x => x.EpisodeNumber) + ); + + /// + public Guid Id { get; set; } + + /// + [Computed] + [MaxLength(256)] + public string Slug + { + get + { + if (ShowSlug != null || Show?.Slug != null) + return GetSlug(ShowSlug ?? Show!.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber); + return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber); + } + private set + { + Match match = Regex.Match(value, @"(?.+)-s(?\d+)e(?\d+)"); + + if (match.Success) + { + ShowSlug = match.Groups["show"].Value; + SeasonNumber = int.Parse(match.Groups["season"].Value); + EpisodeNumber = int.Parse(match.Groups["episode"].Value); + } + else + { + match = Regex.Match(value, @"(?.+)-(?\d+)"); + if (match.Success) + { + ShowSlug = match.Groups["show"].Value; + AbsoluteNumber = int.Parse(match.Groups["absolute"].Value); + } + else + ShowSlug = value; + SeasonNumber = null; + EpisodeNumber = null; + } + } + } + + /// + /// The slug of the Show that contain this episode. If this is not set, this episode is ill-formed. + /// + [JsonIgnore] + public string? ShowSlug { private get; set; } + + /// + /// The ID of the Show containing this episode. + /// + public Guid ShowId { get; set; } + + /// + /// The show that contains this episode. + /// + [LoadableRelation(nameof(ShowId))] + public Show? Show { get; set; } + + /// + /// The ID of the Season containing this episode. + /// + public Guid? SeasonId { get; set; } + + /// + /// The season that contains this episode. + /// + /// + /// This can be null if the season is unknown and the episode is only identified + /// by it's . + /// + [LoadableRelation(nameof(SeasonId))] + public Season? Season { get; set; } + + /// + /// The season in witch this episode is in. + /// + public int? SeasonNumber { get; set; } + + /// + /// The number of this episode in it's season. + /// + public int? EpisodeNumber { get; set; } + + /// + /// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season. + /// + public int? AbsoluteNumber { get; set; } + + /// + /// The path of the video file for this episode. + /// + public string Path { get; set; } + + /// + /// The title of this episode. + /// + public string? Name { get; set; } + + /// + /// The overview of this episode. + /// + public string? Overview { get; set; } + + /// + /// How long is this episode? (in minutes) + /// + public int? Runtime { get; set; } + + /// + /// The release date of this episode. It can be null if unknown. + /// + public DateOnly? ReleaseDate { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + public Image? Poster { get; set; } + + /// + public Image? Thumbnail { get; set; } + + /// + public Image? Logo { get; set; } + + /// + public Dictionary ExternalId { get; set; } = []; + + /// + public DateTime? NextMetadataRefresh { get; set; } + + /// + /// The previous episode that should be seen before viewing this one. + /// + [Projectable(UseMemberBody = nameof(_PreviousEpisode), OnlyOnInclude = true)] + [LoadableRelation( + // language=PostgreSQL + Sql = """ + select + pe.* -- Episode as pe + from + episodes as "pe" + where + pe.show_id = "this".show_id + and (pe.absolute_number < "this".absolute_number + or pe.season_number < "this".season_number + or (pe.season_number = "this".season_number + and e.episode_number < "this".episode_number)) + order by + pe.absolute_number desc nulls last, + pe.season_number desc, + pe.episode_number desc + limit 1 + """ + )] + public Episode? PreviousEpisode { get; set; } + + private Episode? _PreviousEpisode => + Show! + .Episodes!.OrderBy(x => x.AbsoluteNumber == null) + .ThenByDescending(x => x.AbsoluteNumber) + .ThenByDescending(x => x.SeasonNumber) + .ThenByDescending(x => x.EpisodeNumber) + .FirstOrDefault(x => + x.AbsoluteNumber < AbsoluteNumber + || x.SeasonNumber < SeasonNumber + || (x.SeasonNumber == SeasonNumber && x.EpisodeNumber < EpisodeNumber) + ); + + /// + /// The next episode to watch after this one. + /// + [Projectable(UseMemberBody = nameof(_NextEpisode), OnlyOnInclude = true)] + [LoadableRelation( + // language=PostgreSQL + Sql = """ + select + ne.* -- Episode as ne + from + episodes as "ne" + where + ne.show_id = "this".show_id + and (ne.absolute_number > "this".absolute_number + or ne.season_number > "this".season_number + or (ne.season_number = "this".season_number + and e.episode_number > "this".episode_number)) + order by + ne.absolute_number, + ne.season_number, + ne.episode_number + limit 1 + """ + )] + public Episode? NextEpisode { get; set; } + + private Episode? _NextEpisode => + Show! + .Episodes!.OrderBy(x => x.AbsoluteNumber) + .ThenBy(x => x.SeasonNumber) + .ThenBy(x => x.EpisodeNumber) + .FirstOrDefault(x => + x.AbsoluteNumber > AbsoluteNumber + || x.SeasonNumber > SeasonNumber + || (x.SeasonNumber == SeasonNumber && x.EpisodeNumber > EpisodeNumber) + ); + + [JsonIgnore] + public ICollection? Watched { get; set; } + + /// + /// Metadata of what an user as started/planned to watch. + /// + [Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] + [LoadableRelation( + Sql = "episode_watch_status", + On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]" + )] + public EpisodeWatchStatus? WatchStatus { get; set; } + + // There is a global query filter to filter by user so we just need to do single. + private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); + + /// + /// Links to watch this episode. + /// + public VideoLinks Links => + new() { Direct = $"/episode/{Slug}/direct", Hls = $"/episode/{Slug}/master.m3u8", }; + + /// + /// Get the slug of an episode. + /// + /// The slug of the show. It can't be null. + /// + /// The season in which the episode is. + /// If this is a movie or if the episode should be referred by it's absolute number, set this to null. + /// + /// + /// The number of the episode in it's season. + /// If this is a movie or if the episode should be referred by it's absolute number, set this to null. + /// + /// + /// The absolute number of this show. + /// If you don't know it or this is a movie, use null + /// + /// The slug corresponding to the given arguments + public static string GetSlug( + string showSlug, + int? seasonNumber, + int? episodeNumber, + int? absoluteNumber = null + ) + { + return seasonNumber switch + { + null when absoluteNumber == null => showSlug, + null => $"{showSlug}-{absoluteNumber}", + _ => $"{showSlug}-s{seasonNumber}e{episodeNumber}" + }; + } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Interfaces/IAddedDate.cs b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IAddedDate.cs new file mode 100644 index 0000000..8b64b61 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IAddedDate.cs @@ -0,0 +1,32 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models; + +/// +/// An interface applied to resources. +/// +public interface IAddedDate +{ + /// + /// The date at which this resource was added to kyoo. + /// + public DateTime AddedDate { get; set; } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Interfaces/IMetadata.cs b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IMetadata.cs new file mode 100644 index 0000000..db840ca --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IMetadata.cs @@ -0,0 +1,32 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; + +namespace Kyoo.Abstractions.Models; + +/// +/// An interface applied to resources containing external metadata. +/// +public interface IMetadata +{ + /// + /// The link to metadata providers that this show has. See for more information. + /// + public Dictionary ExternalId { get; set; } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Interfaces/IQuery.cs b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IQuery.cs new file mode 100644 index 0000000..95634fa --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IQuery.cs @@ -0,0 +1,30 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using Kyoo.Abstractions.Controllers; + +namespace Kyoo.Abstractions.Models; + +public interface IQuery +{ + /// + /// The sorting that will be used when no user defined one is present. + /// + public static virtual Sort DefaultSort => throw new NotImplementedException(); +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Interfaces/IRefreshable.cs b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IRefreshable.cs new file mode 100644 index 0000000..0a8acae --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IRefreshable.cs @@ -0,0 +1,40 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Models; + +public interface IRefreshable +{ + /// + /// The date of the next metadata refresh. Null if auto-refresh is disabled. + /// + public DateTime? NextMetadataRefresh { get; set; } + + public static DateTime ComputeNextRefreshDate(DateOnly airDate) + { + int days = DateOnly.FromDateTime(DateTime.UtcNow).DayNumber - airDate.DayNumber; + return days switch + { + <= 4 => DateTime.UtcNow.AddDays(1), + <= 21 => DateTime.UtcNow.AddDays(14), + _ => DateTime.UtcNow.AddMonths(2) + }; + } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Interfaces/IResource.cs b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IResource.cs new file mode 100644 index 0000000..8779645 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IResource.cs @@ -0,0 +1,49 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.ComponentModel.DataAnnotations; +using Kyoo.Abstractions.Controllers; + +namespace Kyoo.Abstractions.Models; + +/// +/// An interface to represent a resource that can be retrieved from the database. +/// +public interface IResource : IQuery +{ + /// + /// A unique ID for this type of resource. This can't be changed and duplicates are not allowed. + /// + /// + /// You don't need to specify an ID manually when creating a new resource, + /// this field is automatically assigned by the . + /// + public Guid Id { get; set; } + + /// + /// A human-readable identifier that can be used instead of an ID. + /// A slug must be unique for a type of resource but it can be changed. + /// + /// + /// There is no setter for a slug since it can be computed from other fields. + /// For example, a season slug is {ShowSlug}-s{SeasonNumber}. + /// + [MaxLength(256)] + public string Slug { get; } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs new file mode 100644 index 0000000..69fbca6 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs @@ -0,0 +1,147 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Kyoo.Abstractions.Models; + +/// +/// An interface representing items that contains images (like posters, thumbnails, logo, banners...) +/// +public interface IThumbnails +{ + /// + /// A poster is a 2/3 format image with the cover of the resource. + /// + public Image? Poster { get; set; } + + /// + /// A thumbnail is a 16/9 format image, it could ether be used as a background or as a preview but it usually + /// is not an official image. + /// + public Image? Thumbnail { get; set; } + + /// + /// A logo is a small image representing the resource. + /// + public Image? Logo { get; set; } +} + +[JsonConverter(typeof(ImageConvertor))] +public class Image +{ + /// + /// A unique identifier for the image. Used for proper http caches. + /// + public Guid Id { get; set; } + + /// + /// The original image from another server. + /// + public string Source { get; set; } + + /// + /// A hash to display as placeholder while the image is loading. + /// + [MaxLength(32)] + public string Blurhash { get; set; } + + /// + /// The url to access the image in low quality. + /// + public string Low => $"/thumbnails/{Id}?quality=low"; + + /// + /// The url to access the image in medium quality. + /// + public string Medium => $"/thumbnails/{Id}?quality=medium"; + + /// + /// The url to access the image in high quality. + /// + public string High => $"/thumbnails/{Id}?quality=high"; + + public Image() { } + + [JsonConstructor] + public Image(string source, string? blurhash = null) + { + Source = source; + Blurhash = blurhash ?? "000000"; + } + + // + public class ImageConvertor : JsonConverter + { + /// + public override Image? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.String && reader.GetString() is string source) + return new Image(source); + using JsonDocument document = JsonDocument.ParseValue(ref reader); + string? src = document.RootElement.GetProperty("Source").GetString(); + string? blurhash = document.RootElement.GetProperty("Blurhash").GetString(); + Guid? id = document.RootElement.GetProperty("Id").GetGuid(); + return new Image(src ?? string.Empty, blurhash) { Id = id ?? Guid.Empty }; + } + + /// + public override void Write( + Utf8JsonWriter writer, + Image value, + JsonSerializerOptions options + ) + { + writer.WriteStartObject(); + writer.WriteString("source", value.Source); + writer.WriteString("blurhash", value.Blurhash); + writer.WriteString("low", value.Low); + writer.WriteString("medium", value.Medium); + writer.WriteString("high", value.High); + writer.WriteEndObject(); + } + } +} + +/// +/// The quality of an image +/// +public enum ImageQuality +{ + /// + /// Small + /// + Low, + + /// + /// Medium + /// + Medium, + + /// + /// Large + /// + High, +} diff --git a/src/Kyoo.Abstractions/Models/Resources/JwtToken.cs b/src/Kyoo.Abstractions/Models/Resources/JwtToken.cs new file mode 100644 index 0000000..1342dc0 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/JwtToken.cs @@ -0,0 +1,65 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Text.Json.Serialization; + +namespace Kyoo.Abstractions.Models; + +/// +/// A container representing the response of a login or token refresh. +/// +/// +/// Initializes a new instance of the class. +/// +/// The access token used to authorize requests. +/// The refresh token to retrieve a new access token. +/// When the access token will expire. +public class JwtToken(string accessToken, string refreshToken, TimeSpan expireIn) +{ + /// + /// The type of this token (always a Bearer). + /// + [JsonPropertyName("token_type")] + public string TokenType => "Bearer"; + + /// + /// The access token used to authorize requests. + /// + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = accessToken; + + /// + /// The refresh token used to retrieve a new access/refresh token when the access token has expired. + /// + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = refreshToken; + + /// + /// When the access token will expire. After this time, the refresh token should be used to retrieve. + /// a new token.cs + /// + [JsonPropertyName("expire_in")] + public TimeSpan ExpireIn => ExpireAt.Subtract(DateTime.UtcNow); + + /// + /// The exact date at which the access token will expire. + /// + [JsonPropertyName("expire_at")] + public DateTime ExpireAt { get; set; } = DateTime.UtcNow + expireIn; +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Movie.cs b/src/Kyoo.Abstractions/Models/Resources/Movie.cs new file mode 100644 index 0000000..18d4994 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Movie.cs @@ -0,0 +1,192 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text.Json.Serialization; +using EntityFrameworkCore.Projectables; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Utils; + +namespace Kyoo.Abstractions.Models; + +/// +/// A series or a movie. +/// +public class Movie + : IQuery, + IResource, + IMetadata, + IThumbnails, + IAddedDate, + IRefreshable, + ILibraryItem, + INews, + IWatchlist +{ + public static Sort DefaultSort => new Sort.By(x => x.Name); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + + /// + /// The title of this show. + /// + public string Name { get; set; } + + /// + /// A catchphrase for this movie. + /// + public string? Tagline { get; set; } + + /// + /// The list of alternative titles of this show. + /// + public string[] Aliases { get; set; } = Array.Empty(); + + /// + /// The path of the movie video file. + /// + public string Path { get; set; } + + /// + /// The summary of this show. + /// + public string? Overview { get; set; } + + /// + /// A list of tags that match this movie. + /// + public string[] Tags { get; set; } = []; + + /// + /// The list of genres (themes) this show has. + /// + public List Genres { get; set; } = []; + + /// + /// Is this show airing, not aired yet or finished? + /// + public Status Status { get; set; } + + /// + /// How well this item is rated? (from 0 to 100). + /// + public int Rating { get; set; } + + /// + /// How long is this movie? (in minutes) + /// + public int? Runtime { get; set; } + + /// + /// The date this movie aired. + /// + public DateOnly? AirDate { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + public Image? Poster { get; set; } + + /// + public Image? Thumbnail { get; set; } + + /// + public Image? Logo { get; set; } + + [JsonIgnore] + [Column("air_date")] + public DateOnly? StartAir => AirDate; + + [JsonIgnore] + [Column("air_date")] + public DateOnly? EndAir => AirDate; + + /// + /// A video of a few minutes that tease the content. + /// + public string? Trailer { get; set; } + + /// + public Dictionary ExternalId { get; set; } = new(); + + /// + public DateTime? NextMetadataRefresh { get; set; } + + /// + /// The ID of the Studio that made this show. + /// + [JsonIgnore] + public Guid? StudioId { get; set; } + + /// + /// The Studio that made this show. + /// + [LoadableRelation(nameof(StudioId))] + public Studio? Studio { get; set; } + + /// + /// The list of collections that contains this show. + /// + [JsonIgnore] + public ICollection? Collections { get; set; } + + /// + /// Links to watch this movie. + /// + public VideoLinks Links => + new() { Direct = $"/movie/{Slug}/direct", Hls = $"/movie/{Slug}/master.m3u8", }; + + [JsonIgnore] + public ICollection? Watched { get; set; } + + /// + /// Metadata of what an user as started/planned to watch. + /// + [Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] + [LoadableRelation( + Sql = "movie_watch_status", + On = "movie_id = \"this\".id and \"relation\".user_id = [current_user]" + )] + public MovieWatchStatus? WatchStatus { get; set; } + + // There is a global query filter to filter by user so we just need to do single. + private MovieWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); + + public Movie() { } + + [JsonConstructor] + public Movie(string name) + { + if (name != null) + { + Slug = Utility.ToSlug(name); + Name = name; + } + } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Season.cs b/src/Kyoo.Abstractions/Models/Resources/Season.cs new file mode 100644 index 0000000..d94e651 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Season.cs @@ -0,0 +1,151 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using EntityFrameworkCore.Projectables; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models.Attributes; + +namespace Kyoo.Abstractions.Models; + +/// +/// A season of a . +/// +public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, IRefreshable +{ + public static Sort DefaultSort => new Sort.By(x => x.SeasonNumber); + + /// + public Guid Id { get; set; } + + /// + [Computed] + [MaxLength(256)] + public string Slug + { + get + { + if (ShowSlug == null && Show == null) + return $"{ShowId}-s{SeasonNumber}"; + return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}"; + } + private set + { + Match match = Regex.Match(value, @"(?.+)-s(?\d+)"); + + if (!match.Success) + throw new ArgumentException( + "Invalid season slug. Format: {showSlug}-s{seasonNumber}" + ); + ShowSlug = match.Groups["show"].Value; + SeasonNumber = int.Parse(match.Groups["season"].Value); + } + } + + /// + /// The slug of the Show that contain this episode. If this is not set, this season is ill-formed. + /// + [JsonIgnore] + public string? ShowSlug { private get; set; } + + /// + /// The ID of the Show containing this season. + /// + public Guid ShowId { get; set; } + + /// + /// The show that contains this season. + /// + [LoadableRelation(nameof(ShowId))] + public Show? Show { get; set; } + + /// + /// The number of this season. This can be set to 0 to indicate specials. + /// + public int SeasonNumber { get; set; } + + /// + /// The title of this season. + /// + public string? Name { get; set; } + + /// + /// A quick overview of this season. + /// + public string? Overview { get; set; } + + /// + /// The starting air date of this season. + /// + public DateOnly? StartDate { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The ending date of this season. + /// + public DateOnly? EndDate { get; set; } + + /// + public Image? Poster { get; set; } + + /// + public Image? Thumbnail { get; set; } + + /// + public Image? Logo { get; set; } + + /// + public Dictionary ExternalId { get; set; } = new(); + + /// + public DateTime? NextMetadataRefresh { get; set; } + + /// + /// The list of episodes that this season contains. + /// + [JsonIgnore] + public ICollection? Episodes { get; set; } + + /// + /// The number of episodes in this season. + /// + [Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)] + [NotMapped] + [LoadableRelation( + // language=PostgreSQL + Projected = """ + ( + select + count(*)::int + from + episodes as e + where + e.season_id = id) as episodes_count + """ + )] + public int EpisodesCount { get; set; } + + private int _EpisodesCount => Episodes!.Count; +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Show.cs b/src/Kyoo.Abstractions/Models/Resources/Show.cs new file mode 100644 index 0000000..b3af184 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Show.cs @@ -0,0 +1,283 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text.Json.Serialization; +using EntityFrameworkCore.Projectables; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Utils; + +namespace Kyoo.Abstractions.Models; + +/// +/// A series or a movie. +/// +public class Show + : IQuery, + IResource, + IMetadata, + IOnMerge, + IThumbnails, + IAddedDate, + IRefreshable, + ILibraryItem, + IWatchlist +{ + public static Sort DefaultSort => new Sort.By(x => x.Name); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + + /// + /// The title of this show. + /// + public string Name { get; set; } + + /// + /// A catchphrase for this show. + /// + public string? Tagline { get; set; } + + /// + /// The list of alternative titles of this show. + /// + public List Aliases { get; set; } = new(); + + /// + /// The summary of this show. + /// + public string? Overview { get; set; } + + /// + /// A list of tags that match this movie. + /// + public List Tags { get; set; } = new(); + + /// + /// The list of genres (themes) this show has. + /// + public List Genres { get; set; } = new(); + + /// + /// Is this show airing, not aired yet or finished? + /// + public Status Status { get; set; } + + /// + /// How well this item is rated? (from 0 to 100). + /// + public int Rating { get; set; } + + /// + /// The date this show started airing. It can be null if this is unknown. + /// + public DateOnly? StartAir { get; set; } + + /// + /// The date this show finished airing. + /// It can also be null if this is unknown. + /// + public DateOnly? EndAir { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + public Image? Poster { get; set; } + + /// + public Image? Thumbnail { get; set; } + + /// + public Image? Logo { get; set; } + + /// + /// A video of a few minutes that tease the content. + /// + public string? Trailer { get; set; } + + [JsonIgnore] + [Column("start_air")] + public DateOnly? AirDate => StartAir; + + /// + public Dictionary ExternalId { get; set; } = new(); + + /// + public DateTime? NextMetadataRefresh { get; set; } + + /// + /// The ID of the Studio that made this show. + /// + public Guid? StudioId { get; set; } + + /// + /// The Studio that made this show. + /// + [LoadableRelation(nameof(StudioId))] + public Studio? Studio { get; set; } + + /// + /// The different seasons in this show. If this is a movie, this list is always null or empty. + /// + [JsonIgnore] + public ICollection? Seasons { get; set; } + + /// + /// The list of episodes in this show. + /// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null). + /// Having an episode is necessary to store metadata and tracks. + /// + [JsonIgnore] + public ICollection? Episodes { get; set; } + + /// + /// The list of collections that contains this show. + /// + [JsonIgnore] + public ICollection? Collections { get; set; } + + /// + /// The first episode of this show. + /// + [Projectable(UseMemberBody = nameof(_FirstEpisode), OnlyOnInclude = true)] + [LoadableRelation( + // language=PostgreSQL + Sql = """ + select + fe.* -- Episode as fe + from ( + select + e.*, + row_number() over (partition by e.show_id order by e.absolute_number, e.season_number, e.episode_number) as number + from + episodes as e) as "fe" + where + fe.number <= 1 + """, + On = "show_id = \"this\".id" + )] + public Episode? FirstEpisode { get; set; } + + private Episode? _FirstEpisode => + Episodes! + .OrderBy(x => x.AbsoluteNumber) + .ThenBy(x => x.SeasonNumber) + .ThenBy(x => x.EpisodeNumber) + .FirstOrDefault(); + + /// + /// The number of episodes in this show. + /// + [Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)] + [NotMapped] + [LoadableRelation( + // language=PostgreSQL + Projected = """ + ( + select + count(*)::int + from + episodes as e + where + e.show_id = "this".id) as episodes_count + """ + )] + public int EpisodesCount { get; set; } + + private int _EpisodesCount => Episodes!.Count; + + [JsonIgnore] + public ICollection? Watched { get; set; } + + /// + /// Metadata of what an user as started/planned to watch. + /// + [Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] + [LoadableRelation( + Sql = "show_watch_status", + On = "show_id = \"this\".id and \"relation\".user_id = [current_user]" + )] + public ShowWatchStatus? WatchStatus { get; set; } + + // There is a global query filter to filter by user so we just need to do single. + private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); + + /// + public void OnMerge(object merged) + { + if (Seasons != null) + { + foreach (Season season in Seasons) + season.Show = this; + } + + if (Episodes != null) + { + foreach (Episode episode in Episodes) + episode.Show = this; + } + } + + public Show() { } + + [JsonConstructor] + public Show(string name) + { + if (name != null) + { + Slug = Utility.ToSlug(name); + Name = name; + } + } +} + +/// +/// The enum containing show's status. +/// +public enum Status +{ + /// + /// The status of the show is not known. + /// + Unknown, + + /// + /// The show has finished airing. + /// + Finished, + + /// + /// The show is still actively airing. + /// + Airing, + + /// + /// This show has not aired yet but has been announced. + /// + Planned +} diff --git a/src/Kyoo.Abstractions/Models/Resources/Studio.cs b/src/Kyoo.Abstractions/Models/Resources/Studio.cs new file mode 100644 index 0000000..9b6a557 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/Studio.cs @@ -0,0 +1,80 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Kyoo.Abstractions.Controllers; +using Kyoo.Utils; + +namespace Kyoo.Abstractions.Models; + +/// +/// A studio that make shows. +/// +public class Studio : IQuery, IResource, IMetadata +{ + public static Sort DefaultSort => new Sort.By(x => x.Name); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + + /// + /// The name of this studio. + /// + public string Name { get; set; } + + /// + /// The list of shows that are made by this studio. + /// + [JsonIgnore] + public ICollection? Shows { get; set; } + + /// + /// The list of movies that are made by this studio. + /// + [JsonIgnore] + public ICollection? Movies { get; set; } + + /// + public Dictionary ExternalId { get; set; } = new(); + + /// + /// Create a new, empty, . + /// + public Studio() { } + + /// + /// Create a new with a specific name, the slug is calculated automatically. + /// + /// The name of the studio. + [JsonConstructor] + public Studio(string name) + { + if (name != null) + { + Slug = Utility.ToSlug(name); + Name = name; + } + } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/User.cs b/src/Kyoo.Abstractions/Models/Resources/User.cs new file mode 100644 index 0000000..2dfffc7 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/User.cs @@ -0,0 +1,116 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Kyoo.Abstractions.Controllers; +using Kyoo.Utils; + +namespace Kyoo.Abstractions.Models; + +/// +/// A single user of the app. +/// +public class User : IQuery, IResource, IAddedDate +{ + public static Sort DefaultSort => new Sort.By(x => x.Username); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + + /// + /// A username displayed to the user. + /// + public string Username { get; set; } + + /// + /// The user email address. + /// + public string Email { get; set; } + + /// + /// The user password (hashed, it can't be read like that). The hashing format is implementation defined. + /// + [JsonIgnore] + public string? Password { get; set; } + + /// + /// Does the user can sign-in with a password or only via oidc? + /// + public bool HasPassword => Password != null; + + /// + /// The list of permissions of the user. The format of this is implementation dependent. + /// + public string[] Permissions { get; set; } = Array.Empty(); + + /// + public DateTime AddedDate { get; set; } + + /// + /// User settings + /// + public Dictionary Settings { get; set; } = new(); + + /// + /// User accounts on other services. + /// + public Dictionary ExternalId { get; set; } = new(); + + public User() { } + + [JsonConstructor] + public User(string username) + { + if (username != null) + { + Slug = Utility.ToSlug(username); + Username = username; + } + } +} + +public class ExternalToken +{ + /// + /// The id of this user on the external service. + /// + public string Id { get; set; } + + /// + /// The username on the external service. + /// + public string Username { get; set; } + + /// + /// The link to the user profile on this website. Null if it does not exist. + /// + public string? ProfileUrl { get; set; } + + /// + /// A jwt token used to interact with the service. + /// Do not forget to refresh it when using it if necessary. + /// + public JwtToken Token { get; set; } +} diff --git a/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs b/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs new file mode 100644 index 0000000..d6576f5 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs @@ -0,0 +1,279 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Text.Json.Serialization; +using Kyoo.Abstractions.Models.Attributes; + +namespace Kyoo.Abstractions.Models; + +/// +/// Has the user started watching, is it planned? +/// +public enum WatchStatus +{ + /// + /// The user has already watched this. + /// + Completed, + + /// + /// The user started watching this but has not finished. + /// + Watching, + + /// + /// The user does not plan to continue watching. + /// + Droped, + + /// + /// The user has not started watching this but plans to. + /// + Planned, + + /// + /// The watch status was deleted and can not be retrived again. + /// + Deleted, +} + +/// +/// Metadata of what an user as started/planned to watch. +/// +[SqlFirstColumn(nameof(UserId))] +public class MovieWatchStatus : IAddedDate +{ + /// + /// The ID of the user that started watching this episode. + /// + public Guid UserId { get; set; } + + /// + /// The user that started watching this episode. + /// + [JsonIgnore] + public User User { get; set; } + + /// + /// The ID of the movie started. + /// + public Guid MovieId { get; set; } + + /// + /// The started. + /// + [JsonIgnore] + public Movie Movie { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The date at which this item was played. + /// + public DateTime? PlayedDate { get; set; } + + /// + /// Has the user started watching, is it planned? + /// + public WatchStatus Status { get; set; } + + /// + /// Where the player has stopped watching the movie (in seconds). + /// + /// + /// Null if the status is not Watching. + /// + public int? WatchedTime { get; set; } + + /// + /// Where the player has stopped watching the movie (in percentage between 0 and 100). + /// + /// + /// Null if the status is not Watching. + /// + public int? WatchedPercent { get; set; } +} + +[SqlFirstColumn(nameof(UserId))] +public class EpisodeWatchStatus : IAddedDate +{ + /// + /// The ID of the user that started watching this episode. + /// + public Guid UserId { get; set; } + + /// + /// The user that started watching this episode. + /// + [JsonIgnore] + public User User { get; set; } + + /// + /// The ID of the episode started. + /// + public Guid? EpisodeId { get; set; } + + /// + /// The started. + /// + [JsonIgnore] + public Episode Episode { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The date at which this item was played. + /// + public DateTime? PlayedDate { get; set; } + + /// + /// Has the user started watching, is it planned? + /// + public WatchStatus Status { get; set; } + + /// + /// Where the player has stopped watching the episode (in seconds). + /// + /// + /// Null if the status is not Watching. + /// + public int? WatchedTime { get; set; } + + /// + /// Where the player has stopped watching the episode (in percentage between 0 and 100). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedPercent { get; set; } +} + +[SqlFirstColumn(nameof(UserId))] +public class ShowWatchStatus : IAddedDate +{ + /// + /// The ID of the user that started watching this episode. + /// + public Guid UserId { get; set; } + + /// + /// The user that started watching this episode. + /// + [JsonIgnore] + public User User { get; set; } + + /// + /// The ID of the show started. + /// + public Guid ShowId { get; set; } + + /// + /// The started. + /// + [JsonIgnore] + public Show Show { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The date at which this item was played. + /// + public DateTime? PlayedDate { get; set; } + + /// + /// Has the user started watching, is it planned? + /// + public WatchStatus Status { get; set; } + + /// + /// The number of episodes the user has not seen. + /// + public int UnseenEpisodesCount { get; set; } + + /// + /// The ID of the episode started. + /// + public Guid? NextEpisodeId { get; set; } + + /// + /// The next to watch. + /// + public Episode? NextEpisode { get; set; } + + /// + /// Where the player has stopped watching the episode (in seconds). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedTime { get; set; } + + /// + /// Where the player has stopped watching the episode (in percentage between 0 and 100). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedPercent { get; set; } +} + +public class WatchStatus : IAddedDate +{ + /// + /// Has the user started watching, is it planned? + /// + public required WatchStatus Status { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The date at which this item was played. + /// + public DateTime? PlayedDate { get; set; } + + /// + /// Where the player has stopped watching the episode (in seconds). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedTime { get; set; } + + /// + /// Where the player has stopped watching the episode (in percentage between 0 and 100). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedPercent { get; set; } + + /// + /// The user that started watching this episode. + /// + public required User User { get; set; } + + /// + /// The episode/show/movie whose status changed + /// + public required T Resource { get; set; } +} diff --git a/src/Kyoo.Abstractions/Models/SearchPage.cs b/src/Kyoo.Abstractions/Models/SearchPage.cs new file mode 100644 index 0000000..8ce2043 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/SearchPage.cs @@ -0,0 +1,53 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; + +namespace Kyoo.Abstractions.Models; + +/// +/// Results of a search request. +/// +/// The search item's type. +public class SearchPage : Page + where T : IResource +{ + public SearchPage( + SearchResult result, + string @this, + string? previous, + string? next, + string first + ) + : base(result.Items, @this, previous, next, first) + { + Query = result.Query; + } + + /// + /// The query of the search request. + /// + public string? Query { get; init; } + + public class SearchResult + { + public string? Query { get; set; } + + public ICollection Items { get; set; } + } +} diff --git a/src/Kyoo.Abstractions/Models/Utils/Claims.cs b/src/Kyoo.Abstractions/Models/Utils/Claims.cs new file mode 100644 index 0000000..c8d8c3c --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/Claims.cs @@ -0,0 +1,55 @@ +// 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 . + +namespace Kyoo.Authentication.Models; + +/// +/// List of well known claims of kyoo +/// +public static class Claims +{ + /// + /// The id of the user + /// + public static string Id => "id"; + + /// + /// The name of the user + /// + public static string Name => "name"; + + /// + /// The email of the user. + /// + public static string Email => "email"; + + /// + /// The list of permissions that the user has. + /// + public static string Permissions => "permissions"; + + /// + /// The type of the token (either "access" or "refresh"). + /// + public static string Type => "type"; + + /// + /// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens. + /// + public static string Guid => "guid"; +} diff --git a/src/Kyoo.Abstractions/Models/Utils/Constants.cs b/src/Kyoo.Abstractions/Models/Utils/Constants.cs new file mode 100644 index 0000000..f12c44d --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/Constants.cs @@ -0,0 +1,60 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Models.Attributes; + +namespace Kyoo.Abstractions.Models.Utils; + +/// +/// A class containing constant numbers. +/// +public static class Constants +{ + /// + /// A property to use on a Microsoft.AspNet.MVC.Route.Order property to mark it as an alternative route + /// that won't be included on the swagger. + /// + public const int AlternativeRoute = 1; + + /// + /// A group name for . It should be used for endpoints used by users. + /// + public const string UsersGroup = "0:Users"; + + /// + /// A group name for . It should be used for main resources of kyoo. + /// + public const string ResourcesGroup = "1:Resources"; + + /// + /// A group name for . + /// It should be used for sub resources of kyoo that help define the main resources. + /// + public const string MetadataGroup = "2:Metadata"; + + /// + /// A group name for . It should be used for endpoints useful for playback. + /// + public const string WatchGroup = "3:Watch"; + + /// + /// A group name for . It should be used for endpoints used by admins. + /// + public const string AdminGroup = "4:Admin"; + public const string OtherGroup = "5:Other"; +} diff --git a/src/Kyoo.Abstractions/Models/Utils/Filter.cs b/src/Kyoo.Abstractions/Models/Utils/Filter.cs new file mode 100644 index 0000000..4d83292 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/Filter.cs @@ -0,0 +1,369 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Kyoo.Abstractions.Models.Attributes; +using Sprache; + +namespace Kyoo.Abstractions.Models.Utils; + +public static class ParseHelper +{ + public static Parser ErrorMessage(this Parser @this, string message) => + input => + { + IResult result = @this(input); + + return result.WasSuccessful + ? result + : Result.Failure(result.Remainder, message, result.Expectations); + }; + + public static Parser Error(string message) => + input => + { + return Result.Failure(input, message, Array.Empty()); + }; +} + +public abstract record Filter +{ + public static Filter? And(params Filter?[] filters) + { + return filters + .Where(x => x != null) + .Aggregate( + (Filter?)null, + (acc, filter) => + { + if (acc == null) + return filter; + return new Filter.And(acc, filter!); + } + ); + } + + public static Filter? Or(params Filter?[] filters) + { + return filters + .Where(x => x != null) + .Aggregate( + (Filter?)null, + (acc, filter) => + { + if (acc == null) + return filter; + return new Filter.Or(acc, filter!); + } + ); + } +} + +public abstract record Filter : Filter +{ + public record And(Filter First, Filter Second) : Filter; + + public record Or(Filter First, Filter Second) : Filter; + + public record Not(Filter Filter) : Filter; + + public record Eq(string Property, object? Value) : Filter; + + public record Ne(string Property, object? Value) : Filter; + + public record Gt(string Property, object Value) : Filter; + + public record Ge(string Property, object Value) : Filter; + + public record Lt(string Property, object Value) : Filter; + + public record Le(string Property, object Value) : Filter; + + public record Has(string Property, object Value) : Filter; + + /// + /// Internal filter used for keyset paginations to resume random sorts. + /// The pseudo sql is md5(seed || table.id) = md5(seed || 'hardCodedId') + /// + public record CmpRandom(string cmp, string Seed, Guid ReferenceId) : Filter; + + /// + /// Internal filter used only in EF with hard coded lamdas (used for relations). + /// + public record Lambda(Expression> Inner) : Filter; + + public static class FilterParsers + { + public static readonly Parser> Filter = Parse + .Ref(() => Bracket) + .Or(Parse.Ref(() => Not)) + .Or(Parse.Ref(() => Eq)) + .Or(Parse.Ref(() => Ne)) + .Or(Parse.Ref(() => Gt)) + .Or(Parse.Ref(() => Ge)) + .Or(Parse.Ref(() => Lt)) + .Or(Parse.Ref(() => Le)) + .Or(Parse.Ref(() => Has)); + + public static readonly Parser> CompleteFilter = Parse + .Ref(() => Or) + .Or(Parse.Ref(() => And)) + .Or(Filter); + + public static readonly Parser> Bracket = + from open in Parse.Char('(').Token() + from filter in CompleteFilter + from close in Parse.Char(')').Token() + select filter; + + public static readonly Parser> AndOperator = Parse + .IgnoreCase("and") + .Or(Parse.String("&&")) + .Token(); + + public static readonly Parser> OrOperator = Parse + .IgnoreCase("or") + .Or(Parse.String("||")) + .Token(); + + public static readonly Parser> And = Parse.ChainOperator( + AndOperator, + Filter, + (_, a, b) => new And(a, b) + ); + + public static readonly Parser> Or = Parse.ChainOperator( + OrOperator, + And.Or(Filter), + (_, a, b) => new Or(a, b) + ); + + public static readonly Parser> Not = + from not in Parse.IgnoreCase("not").Or(Parse.String("!")).Token() + from filter in CompleteFilter + select new Not(filter); + + private static Parser _GetValueParser(Type type) + { + Type? nullable = Nullable.GetUnderlyingType(type); + if (nullable != null) + { + return from value in _GetValueParser(nullable) select value; + } + + if (type == typeof(int)) + return Parse.Number.Select(x => int.Parse(x) as object); + + if (type == typeof(float)) + { + return from a in Parse.Number + from dot in Parse.Char('.') + from b in Parse.Number + select float.Parse($"{a}.{b}") as object; + } + + if (type == typeof(Guid)) + { + return from guid in Parse.Regex( + @"[({]?[a-fA-F0-9]{8}[-]?([a-fA-F0-9]{4}[-]?){3}[a-fA-F0-9]{12}[})]?", + "Guid" + ) + select Guid.Parse(guid) as object; + } + + if (type == typeof(string)) + { + return ( + from lq in Parse.Char('"').Or(Parse.Char('\'')) + from str in Parse.AnyChar.Where(x => x != lq).Many().Text() + from rq in Parse.Char(lq) + select str + ).Or(Parse.LetterOrDigit.Many().Text()); + } + + if (type.IsEnum) + { + return Parse + .LetterOrDigit.Many() + .Text() + .Then(x => + { + if (Enum.TryParse(type, x, true, out object? value)) + return Parse.Return(value); + return ParseHelper.Error($"Invalid enum value. Unexpected {x}"); + }); + } + + if (type == typeof(DateTime) || type == typeof(DateOnly)) + { + return from year in Parse.Digit.Repeat(4).Text().Select(int.Parse) + from yd in Parse.Char('-') + from month in Parse.Digit.Repeat(2).Text().Select(int.Parse) + from md in Parse.Char('-') + from day in Parse.Digit.Repeat(2).Text().Select(int.Parse) + select type == typeof(DateTime) + ? new DateTime(year, month, day) as object + : new DateOnly(year, month, day) as object; + } + + if (typeof(IEnumerable).IsAssignableFrom(type)) + return ParseHelper.Error( + "Can't filter a list with a default comparator, use the 'has' filter." + ); + return ParseHelper.Error("Unfilterable field found"); + } + + private static Parser> _GetOperationParser( + Parser op, + Func> apply, + Func>? customTypeParser = null + ) + { + Parser property = Parse.LetterOrDigit.AtLeastOnce().Text(); + + return property.Then(prop => + { + Type[] types = + typeof(T).GetCustomAttribute()?.Types ?? new[] { typeof(T) }; + + if (string.Equals(prop, "kind", StringComparison.OrdinalIgnoreCase)) + { + return from eq in op + from val in types + .Select(x => Parse.IgnoreCase(x.Name).Text()) + .Aggregate( + null as Parser, + (acc, x) => acc == null ? x : Parse.Or(acc, x) + ) + select apply("kind", val); + } + + PropertyInfo? propInfo = types + .Select(x => + x.GetProperty( + prop, + BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance + ) + ) + .FirstOrDefault(); + if (propInfo == null) + return ParseHelper.Error>($"The given filter '{prop}' is invalid."); + + Parser value = + customTypeParser != null + ? customTypeParser(propInfo.PropertyType) + : _GetValueParser(propInfo.PropertyType); + + return from eq in op + from val in value + select apply(propInfo.Name, val); + }); + } + + public static readonly Parser> Eq = _GetOperationParser( + Parse.IgnoreCase("eq").Or(Parse.String("=")).Token(), + (property, value) => new Eq(property, value), + (Type type) => + { + Type? inner = Nullable.GetUnderlyingType(type); + if (inner == null) + return _GetValueParser(type); + return Parse + .String("null") + .Token() + .Return((object?)null) + .Or(_GetValueParser(inner)); + } + ); + + public static readonly Parser> Ne = _GetOperationParser( + Parse.IgnoreCase("ne").Or(Parse.String("!=")).Token(), + (property, value) => new Ne(property, value), + (Type type) => + { + Type? inner = Nullable.GetUnderlyingType(type); + if (inner == null) + return _GetValueParser(type); + return Parse + .String("null") + .Token() + .Return((object?)null) + .Or(_GetValueParser(inner)); + } + ); + + public static readonly Parser> Gt = _GetOperationParser( + Parse.IgnoreCase("gt").Or(Parse.String(">")).Token(), + (property, value) => new Gt(property, value) + ); + + public static readonly Parser> Ge = _GetOperationParser( + Parse.IgnoreCase("ge").Or(Parse.IgnoreCase("gte")).Or(Parse.String(">=")).Token(), + (property, value) => new Ge(property, value) + ); + + public static readonly Parser> Lt = _GetOperationParser( + Parse.IgnoreCase("lt").Or(Parse.String("<")).Token(), + (property, value) => new Lt(property, value) + ); + + public static readonly Parser> Le = _GetOperationParser( + Parse.IgnoreCase("le").Or(Parse.IgnoreCase("lte")).Or(Parse.String("<=")).Token(), + (property, value) => new Le(property, value) + ); + + public static readonly Parser> Has = _GetOperationParser( + Parse.IgnoreCase("has").Token(), + (property, value) => new Has(property, value), + (Type type) => + { + if (typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string)) + return _GetValueParser( + type.GetElementType() ?? type.GenericTypeArguments.First() + ); + return ParseHelper.Error("Can't use 'has' on a non-list."); + } + ); + } + + public static Filter? From(string? filter) + { + if (filter == null) + return null; + + try + { + IResult> ret = FilterParsers.CompleteFilter.End().TryParse(filter); + if (ret.WasSuccessful) + return ret.Value; + throw new ValidationException( + $"Could not parse filter argument: {ret.Message}. Not parsed: {filter[ret.Remainder.Position..]}" + ); + } + catch (ParseException ex) + { + throw new ValidationException($"Could not parse filter argument: {ex.Message}."); + } + } +} diff --git a/src/Kyoo.Abstractions/Models/Utils/Identifier.cs b/src/Kyoo.Abstractions/Models/Utils/Identifier.cs new file mode 100644 index 0000000..f63a6af --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/Identifier.cs @@ -0,0 +1,245 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace Kyoo.Abstractions.Models.Utils; + +/// +/// A class that represent a resource. It is made to be used as a parameter in a query and not used somewhere else +/// on the application. +/// This class allow routes to be used via ether IDs or Slugs, this is suitable for every . +/// +[TypeConverter(typeof(IdentifierConvertor))] +public class Identifier +{ + /// + /// The ID of the resource or null if the slug is specified. + /// + private readonly Guid? _id; + + /// + /// The slug of the resource or null if the id is specified. + /// + private readonly string? _slug; + + /// + /// Create a new for the given id. + /// + /// The id of the resource. + public Identifier(Guid id) + { + _id = id; + } + + /// + /// Create a new for the given slug. + /// + /// The slug of the resource. + public Identifier(string slug) + { + _slug = slug; + } + + /// + /// Pattern match out of the identifier to a resource. + /// + /// The function to match the ID to a type . + /// The function to match the slug to a type . + /// The return type that will be converted to from an ID or a slug. + /// + /// The result of the or depending on the pattern. + /// + /// + /// Example usage: + /// + /// T ret = await identifier.Match( + /// id => _repository.GetOrDefault(id), + /// slug => _repository.GetOrDefault(slug) + /// ); + /// + /// + public T Match(Func idFunc, Func slugFunc) + { + return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!); + } + + /// + /// Match a custom type to an identifier. This can be used for wrapped resources (see example for more details). + /// + /// An expression to retrieve an ID from the type . + /// An expression to retrieve a slug from the type . + /// The type to match against this identifier. + /// An expression to match the type to this identifier. + /// + /// + /// identifier.Matcher<Season>(x => x.ShowID, x => x.Show.Slug) + /// + /// + public Filter Matcher( + Expression> idGetter, + Expression> slugGetter + ) + { + ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); + BinaryExpression equal = Expression.Equal( + _id.HasValue ? idGetter.Body : slugGetter.Body, + self + ); + ICollection parameters = _id.HasValue + ? idGetter.Parameters + : slugGetter.Parameters; + Expression> lambda = Expression.Lambda>(equal, parameters); + return new Filter.Lambda(lambda); + } + + /// + /// A matcher overload for nullable IDs. See + /// + /// for more details. + /// + /// An expression to retrieve an ID from the type . + /// An expression to retrieve a slug from the type . + /// The type to match against this identifier. + /// An expression to match the type to this identifier. + public Filter Matcher( + Expression> idGetter, + Expression> slugGetter + ) + { + ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); + BinaryExpression equal = Expression.Equal( + _id.HasValue ? idGetter.Body : slugGetter.Body, + self + ); + ICollection parameters = _id.HasValue + ? idGetter.Parameters + : slugGetter.Parameters; + Expression> lambda = Expression.Lambda>(equal, parameters); + return new Filter.Lambda(lambda); + } + + /// + /// Return true if this match a resource. + /// + /// The resource to match + /// + /// true if the match this identifier, false otherwise. + /// + public bool IsSame(IResource resource) + { + return Match(id => resource.Id == id, slug => resource.Slug == slug); + } + + /// + /// Return a filter to get this match a given resource. + /// + /// The type of resource to match against. + /// + /// true if the given resource match this identifier, false otherwise. + /// + public Filter IsSame() + where T : IResource + { + return _id.HasValue ? new Filter.Eq("Id", _id.Value) : new Filter.Eq("Slug", _slug!); + } + + public bool Is(Guid uid) + { + return _id.HasValue && _id.Value == uid; + } + + public bool Is(string slug) + { + return !_id.HasValue && _slug == slug; + } + + private Expression> _IsSameExpression() + where T : IResource + { + return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug; + } + + /// + /// Return an expression that return true if this is containing in a collection. + /// + /// An expression to retrieve the list to check. + /// The type that contain the list to check. + /// The type of resource to check this identifier against. + /// An expression to check if this is contained. + public Filter IsContainedIn(Expression?>> listGetter) + where T2 : IResource + { + MethodInfo method = typeof(Enumerable) + .GetMethods() + .Where(x => x.Name == nameof(Enumerable.Any)) + .FirstOrDefault(x => x.GetParameters().Length == 2)! + .MakeGenericMethod(typeof(T2)); + MethodCallExpression call = Expression.Call( + null, + method, + listGetter.Body, + _IsSameExpression() + ); + Expression> lambda = Expression.Lambda>( + call, + listGetter.Parameters + ); + return new Filter.Lambda(lambda); + } + + /// + public override string ToString() + { + return _id.HasValue ? _id.Value.ToString() : _slug!; + } + + /// + /// A custom used to convert int or strings to an . + /// + public class IdentifierConvertor : TypeConverter + { + /// + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + { + if (sourceType == typeof(Guid) || sourceType == typeof(string)) + return true; + return base.CanConvertFrom(context, sourceType); + } + + /// + public override object ConvertFrom( + ITypeDescriptorContext? context, + CultureInfo? culture, + object value + ) + { + if (value is Guid id) + return new Identifier(id); + if (value is not string slug) + return base.ConvertFrom(context, culture, value)!; + return Guid.TryParse(slug, out id) ? new Identifier(id) : new Identifier(slug); + } + } +} diff --git a/src/Kyoo.Abstractions/Models/Utils/Include.cs b/src/Kyoo.Abstractions/Models/Utils/Include.cs new file mode 100644 index 0000000..78cf65c --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/Include.cs @@ -0,0 +1,109 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; +using Kyoo.Abstractions.Models.Attributes; + +namespace Kyoo.Abstractions.Models.Utils; + +public class Include +{ + /// + /// The aditional fields to include in the result. + /// + public ICollection Metadatas { get; set; } = ArraySegment.Empty; + + public abstract record Metadata(string Name); + + public record SingleRelation(string Name, Type type, string RelationIdName) : Metadata(Name); + + public record CustomRelation(string Name, Type type, string Sql, string? On, Type Declaring) + : Metadata(Name); + + public record ProjectedRelation(string Name, string Sql) : Metadata(Name); +} + +/// +/// The aditional fields to include in the result. +/// +/// The type related to the new fields +public class Include : Include +{ + /// + /// The aditional fields names to include in the result. + /// + public ICollection Fields => Metadatas.Select(x => x.Name).ToList(); + + public Include() { } + + public Include(params string[] fields) + { + Type[] types = typeof(T).GetCustomAttribute()?.Types ?? new[] { typeof(T) }; + Metadatas = fields + .SelectMany(key => + { + var relations = types + .Select(x => + x.GetProperty( + key, + BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance + )! + ) + .Select(prop => + (prop, attr: prop?.GetCustomAttribute()!) + ) + .Where(x => x.prop != null && x.attr != null) + .ToList(); + if (!relations.Any()) + throw new ValidationException($"No loadable relation with the name {key}."); + return relations + .Select(x => + { + (PropertyInfo prop, LoadableRelationAttribute attr) = x; + + if (attr.RelationID != null) + return new SingleRelation(prop.Name, prop.PropertyType, attr.RelationID) + as Metadata; + if (attr.Sql != null) + return new CustomRelation( + prop.Name, + prop.PropertyType, + attr.Sql, + attr.On, + prop.DeclaringType! + ); + if (attr.Projected != null) + return new ProjectedRelation(prop.Name, attr.Projected); + throw new NotImplementedException(); + }) + .Distinct(); + }) + .ToArray(); + } + + public static Include From(string? fields) + { + if (string.IsNullOrEmpty(fields)) + return new Include(); + return new Include(fields.Split(',')); + } +} diff --git a/src/Kyoo.Abstractions/Models/Utils/Pagination.cs b/src/Kyoo.Abstractions/Models/Utils/Pagination.cs new file mode 100644 index 0000000..66cfe74 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/Pagination.cs @@ -0,0 +1,72 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; + +namespace Kyoo.Abstractions.Controllers; + +/// +/// Information about the pagination. How many items should be displayed and where to start. +/// +public class Pagination +{ + /// + /// The count of items to return. + /// + public int Limit { get; set; } + + /// + /// Where to start? Using the given sort. + /// + public Guid? AfterID { get; set; } + + /// + /// Should the previous page be returned instead of the next? + /// + public bool Reverse { get; set; } + + /// + /// Create a new with default values. + /// + public Pagination() + { + Limit = 50; + AfterID = null; + Reverse = false; + } + + /// + /// Create a new instance. + /// + /// Set the value + /// Set the value. If not specified, it will start from the start + /// Should the previous page be returned instead of the next? + public Pagination(int count, Guid? afterID = null, bool reverse = false) + { + Limit = count; + AfterID = afterID; + Reverse = reverse; + } + + /// + /// Implicitly create a new pagination from a limit number. + /// + /// Set the value + /// A new instance + public static implicit operator Pagination(int limit) => new(limit); +} diff --git a/src/Kyoo.Abstractions/Models/Utils/RequestError.cs b/src/Kyoo.Abstractions/Models/Utils/RequestError.cs new file mode 100644 index 0000000..f3ea820 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/RequestError.cs @@ -0,0 +1,56 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Linq; + +namespace Kyoo.Abstractions.Models.Utils; + +/// +/// The list of errors that where made in the request. +/// +public class RequestError +{ + /// + /// The list of errors that where made in the request. + /// + /// ["InvalidFilter: no field 'startYear' on a collection"] + public string[] Errors { get; set; } + + /// + /// Create a new with one error. + /// + /// The error to specify in the response. + public RequestError(string error) + { + if (error == null) + throw new ArgumentNullException(nameof(error)); + Errors = new[] { error }; + } + + /// + /// Create a new with multiple errors. + /// + /// The errors to specify in the response. + public RequestError(string[] errors) + { + if (errors == null || !errors.Any()) + throw new ArgumentException("Errors must be non null and not empty", nameof(errors)); + Errors = errors; + } +} diff --git a/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs b/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs new file mode 100644 index 0000000..3000298 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs @@ -0,0 +1,35 @@ +// 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 . + +namespace Kyoo.Abstractions.Controllers; + +/// +/// Information about the pagination. How many items should be displayed and where to start. +/// +public class SearchPagination +{ + /// + /// The count of items to return. + /// + public int Limit { get; set; } = 50; + + /// + /// Where to start? How many items to skip? + /// + public int? Skip { get; set; } +} diff --git a/src/Kyoo.Abstractions/Models/Utils/Sort.cs b/src/Kyoo.Abstractions/Models/Utils/Sort.cs new file mode 100644 index 0000000..4bd4d44 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/Sort.cs @@ -0,0 +1,137 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Utils; + +namespace Kyoo.Abstractions.Controllers; + +public record Sort; + +/// +/// Information about how a query should be sorted. What factor should decide the sort and in which order. +/// +/// For witch type this sort applies +public record Sort : Sort + where T : IQuery +{ + /// + /// Sort by a specific key + /// + /// The sort keys. This members will be used to sort the results. + /// + /// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. + /// + public record By(string Key, bool Desendant = false) : Sort + { + /// + /// Sort by a specific key + /// + /// The sort keys. This members will be used to sort the results. + /// + /// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. + /// + public By(Expression> key, bool desendant = false) + : this(Utility.GetPropertyName(key), desendant) { } + } + + /// + /// Sort by multiple keys. + /// + /// The list of keys to sort by. + public record Conglomerate(params Sort[] List) : Sort; + + /// Sort randomly items + public record Random(uint Seed) : Sort + { + public Random() + : this(0) + { + uint seed = BitConverter.ToUInt32( + BitConverter.GetBytes(new System.Random().Next(int.MinValue, int.MaxValue)), + 0 + ); + Seed = seed; + } + } + + /// The default sort method for the given type. + public record Default : Sort + { + public void Deconstruct(out Sort value) + { + value = (Sort)T.DefaultSort; + } + } + + /// + /// Create a new instance from a key's name (case insensitive). + /// + /// A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key". + /// The random seed. + /// An invalid key or sort specifier as been given. + /// A for the given string + public static Sort From(string? sortBy, uint seed) + { + if (string.IsNullOrEmpty(sortBy) || sortBy == "default") + return new Default(); + if (sortBy == "random") + return new Random(seed); + if (sortBy.Contains(',')) + return new Conglomerate(sortBy.Split(',').Select(x => From(x, seed)).ToArray()); + + if (sortBy.StartsWith("random:")) + { + if (uint.TryParse(sortBy["random:".Length..], out uint sseed)) + return new Random(sseed); + throw new ValidationException("Invalid random seed specified. Expected a number."); + } + + string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy; + string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null; + bool desendant = order switch + { + "desc" => true, + "asc" => false, + null => false, + _ + => throw new ValidationException( + $"The sort order, if set, should be :asc or :desc but it was :{order}." + ) + }; + + Type[] types = typeof(T).GetCustomAttribute()?.Types ?? new[] { typeof(T) }; + PropertyInfo? property = types + .Select(x => + x.GetProperty( + key, + BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance + ) + ) + .FirstOrDefault(x => x != null); + if (property == null) + throw new ValidationException("The given sort key is not valid."); + return new By(property.Name, desendant); + } +} diff --git a/src/Kyoo.Abstractions/Models/VideoLinks.cs b/src/Kyoo.Abstractions/Models/VideoLinks.cs new file mode 100644 index 0000000..36998e9 --- /dev/null +++ b/src/Kyoo.Abstractions/Models/VideoLinks.cs @@ -0,0 +1,35 @@ +// 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 . + +namespace Kyoo.Abstractions.Models; + +/// +/// The links to see a movie or an episode. +/// +public class VideoLinks +{ + /// + /// The direct link to the unprocessed video (pristine quality). + /// + public string Direct { get; set; } + + /// + /// The link to an HLS master playlist containing all qualities available for this video. + /// + public string Hls { get; set; } +} diff --git a/src/Kyoo.Abstractions/Utility/ExpressionParameterReplacer.cs b/src/Kyoo.Abstractions/Utility/ExpressionParameterReplacer.cs new file mode 100644 index 0000000..9e24845 --- /dev/null +++ b/src/Kyoo.Abstractions/Utility/ExpressionParameterReplacer.cs @@ -0,0 +1,51 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace Kyoo.Utils; + +public sealed class ExpressionArgumentReplacer : ExpressionVisitor +{ + private readonly Dictionary _mapping; + + public ExpressionArgumentReplacer(Dictionary dict) + { + _mapping = dict; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + if (_mapping.TryGetValue(node, out Expression? mappedArgument)) + return Visit(mappedArgument); + return base.VisitParameter(node); + } + + public static Expression ReplaceParams( + Expression expression, + IEnumerable epxParams, + params ParameterExpression[] param + ) + { + ExpressionArgumentReplacer replacer = + new(epxParams.Zip(param).ToDictionary(x => x.First, x => x.Second as Expression)); + return replacer.Visit(expression); + } +} diff --git a/src/Kyoo.Abstractions/Utility/JsonKindResolver.cs b/src/Kyoo.Abstractions/Utility/JsonKindResolver.cs new file mode 100644 index 0000000..db72f7e --- /dev/null +++ b/src/Kyoo.Abstractions/Utility/JsonKindResolver.cs @@ -0,0 +1,78 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using static System.Text.Json.JsonNamingPolicy; + +namespace Kyoo.Utils; + +public class JsonKindResolver : DefaultJsonTypeInfoResolver +{ + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + JsonTypeInfo jsonTypeInfo = base.GetTypeInfo(type, options); + + if (jsonTypeInfo.Type.GetCustomAttribute() != null) + { + jsonTypeInfo.PolymorphismOptions = new() + { + TypeDiscriminatorPropertyName = "kind", + IgnoreUnrecognizedTypeDiscriminators = true, + DerivedTypes = { }, + }; + IEnumerable derived = AppDomain + .CurrentDomain.GetAssemblies() + .SelectMany(s => s.GetTypes()) + .Where(p => type.IsAssignableFrom(p) && p.IsClass); + foreach (Type der in derived) + { + jsonTypeInfo.PolymorphismOptions.DerivedTypes.Add( + new JsonDerivedType(der, CamelCase.ConvertName(der.Name)) + ); + } + } + else if ( + jsonTypeInfo.Type.IsAssignableTo(typeof(IResource)) + && jsonTypeInfo.Properties.All(x => x.Name != "kind") + ) + { + jsonTypeInfo.PolymorphismOptions = new JsonPolymorphismOptions + { + TypeDiscriminatorPropertyName = "kind", + IgnoreUnrecognizedTypeDiscriminators = true, + DerivedTypes = + { + new JsonDerivedType( + jsonTypeInfo.Type, + CamelCase.ConvertName(jsonTypeInfo.Type.Name) + ), + }, + }; + } + + return jsonTypeInfo; + } +} diff --git a/src/Kyoo.Abstractions/Utility/Utility.cs b/src/Kyoo.Abstractions/Utility/Utility.cs new file mode 100644 index 0000000..9e08545 --- /dev/null +++ b/src/Kyoo.Abstractions/Utility/Utility.cs @@ -0,0 +1,212 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +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; + +/// +/// A set of utility functions that can be used everywhere. +/// +public static class Utility +{ + public static readonly JsonSerializerOptions JsonOptions = + new() + { + TypeInfoResolver = new JsonKindResolver(), + Converters = { new JsonStringEnumConverter() }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + /// Convert a string to snake case. Stollen from + /// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs + /// + /// The string to convert. + /// The string in snake case + public static string ToSnakeCase(this string name) + { + StringBuilder builder = new(name.Length + Math.Min(2, name.Length / 5)); + UnicodeCategory? previousCategory = default; + + for (int currentIndex = 0; currentIndex < name.Length; currentIndex++) + { + char currentChar = name[currentIndex]; + if (currentChar == '_') + { + builder.Append('_'); + previousCategory = null; + continue; + } + + UnicodeCategory currentCategory = char.GetUnicodeCategory(currentChar); + switch (currentCategory) + { + case UnicodeCategory.UppercaseLetter: + case UnicodeCategory.TitlecaseLetter: + if ( + previousCategory == UnicodeCategory.SpaceSeparator + || previousCategory == UnicodeCategory.LowercaseLetter + || ( + previousCategory != UnicodeCategory.DecimalDigitNumber + && previousCategory != null + && currentIndex > 0 + && currentIndex + 1 < name.Length + && char.IsLower(name[currentIndex + 1]) + ) + ) + { + builder.Append('_'); + } + + currentChar = char.ToLowerInvariant(currentChar); + break; + + case UnicodeCategory.LowercaseLetter: + case UnicodeCategory.DecimalDigitNumber: + if (previousCategory == UnicodeCategory.SpaceSeparator) + { + builder.Append('_'); + } + break; + + default: + if (previousCategory != null) + { + previousCategory = UnicodeCategory.SpaceSeparator; + } + continue; + } + + builder.Append(currentChar); + previousCategory = currentCategory; + } + + return builder.ToString(); + } + + /// + /// Is the lambda expression a member (like x => x.Body). + /// + /// The expression that should be checked + /// True if the expression is a member, false otherwise + public static bool IsPropertyExpression(LambdaExpression ex) + { + return ex.Body is MemberExpression + || ( + ex.Body.NodeType == ExpressionType.Convert + && ((UnaryExpression)ex.Body).Operand is MemberExpression + ); + } + + /// + /// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows) + /// + /// The expression + /// The name of the expression + /// If the expression is not a property, ArgumentException is thrown. + public static string GetPropertyName(LambdaExpression ex) + { + if (!IsPropertyExpression(ex)) + throw new ArgumentException($"{ex} is not a property expression."); + MemberExpression? member = + ex.Body.NodeType == ExpressionType.Convert + ? ((UnaryExpression)ex.Body).Operand as MemberExpression + : ex.Body as MemberExpression; + return member!.Member.Name; + } + + /// + /// Slugify a string (Replace spaces by -, Uniformize accents) + /// + /// The string to slugify + /// The slug version of the given string + public static string ToSlug(string str) + { + str = str.ToLowerInvariant(); + + string normalizedString = str.Normalize(NormalizationForm.FormD); + StringBuilder stringBuilder = new(); + foreach (char c in normalizedString) + { + UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + stringBuilder.Append(c); + } + str = stringBuilder.ToString().Normalize(NormalizationForm.FormC); + + str = Regex.Replace(str, @"\s", "-", RegexOptions.Compiled); + str = Regex.Replace(str, @"[^\w\s\p{Pd}]", string.Empty, RegexOptions.Compiled); + str = str.Trim('-', '_'); + str = Regex.Replace(str, @"([-_]){2,}", "$1", RegexOptions.Compiled); + return str; + } + + /// + /// Return every in the inheritance tree of the parameter (interfaces are not returned) + /// + /// The starting type + /// A list of types + public static IEnumerable GetInheritanceTree(this Type self) + { + for (Type? type = self; type != null; type = type.BaseType) + yield return type; + } + + /// + /// Get the generic definition of . + /// For example, calling this function with List<string> and typeof(IEnumerable<>) will return IEnumerable<string> + /// + /// The type to check + /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>). + /// The generic definition of genericType that type inherit or null if type does not implement the generic type. + /// must be a generic type + public static Type? GetGenericDefinition(Type type, Type genericType) + { + if (!genericType.IsGenericType) + throw new ArgumentException($"{nameof(genericType)} is not a generic type."); + + IEnumerable types = genericType.IsInterface + ? type.GetInterfaces() + : type.GetInheritanceTree(); + return types + .Prepend(type) + .FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); + } + + /// + /// Convert a dictionary to a query string. + /// + /// The list of query parameters. + /// A valid query string with all items in the dictionary. + public static string ToQueryString(this Dictionary query) + { + if (!query.Any()) + return string.Empty; + return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}")); + } +} diff --git a/src/Kyoo.Abstractions/Utility/Wrapper.cs b/src/Kyoo.Abstractions/Utility/Wrapper.cs new file mode 100644 index 0000000..1db9b0d --- /dev/null +++ b/src/Kyoo.Abstractions/Utility/Wrapper.cs @@ -0,0 +1,47 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Data; +using Dapper; + +namespace Kyoo.Utils; + +// Only used due to https://github.com/DapperLib/Dapper/issues/332 +public class Wrapper +{ + public object Value { get; set; } + + public Wrapper(object value) + { + Value = value; + } + + public class Handler : SqlMapper.TypeHandler + { + public override Wrapper? Parse(object value) + { + throw new NotImplementedException("Wrapper should only be used to write"); + } + + public override void SetValue(IDbDataParameter parameter, Wrapper? value) + { + parameter.Value = value?.Value; + } + } +} diff --git a/src/Kyoo.Authentication/Attributes/DisableOnEnvVarAttribute.cs b/src/Kyoo.Authentication/Attributes/DisableOnEnvVarAttribute.cs new file mode 100644 index 0000000..50e2e67 --- /dev/null +++ b/src/Kyoo.Authentication/Attributes/DisableOnEnvVarAttribute.cs @@ -0,0 +1,22 @@ +using System; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Kyoo.Authentication.Attributes; + +/// +/// Disables the action if the specified environment variable is set to true. +/// +public class DisableOnEnvVarAttribute(string varName) : Attribute, IResourceFilter +{ + public void OnResourceExecuting(ResourceExecutingContext context) + { + var config = context.HttpContext.RequestServices.GetRequiredService(); + + if (config.GetValue(varName, false)) + context.Result = new Microsoft.AspNetCore.Mvc.NotFoundResult(); + } + + public void OnResourceExecuted(ResourceExecutedContext context) { } +} diff --git a/src/Kyoo.Authentication/AuthenticationModule.cs b/src/Kyoo.Authentication/AuthenticationModule.cs new file mode 100644 index 0000000..611a9ee --- /dev/null +++ b/src/Kyoo.Authentication/AuthenticationModule.cs @@ -0,0 +1,165 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Authentication.Models; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Tokens; +using Serilog; + +namespace Kyoo.Authentication; + +public static class AuthenticationModule +{ + public static void ConfigureAuthentication(this WebApplicationBuilder builder) + { + PermissionOption options = + new() + { + Default = builder + .Configuration.GetValue("UNLOGGED_PERMISSIONS", "")! + .Split(',') + .Where(x => x.Length > 0) + .ToArray(), + NewUser = builder + .Configuration.GetValue("DEFAULT_PERMISSIONS", "overall.read,overall.play")! + .Split(','), + RequireVerification = builder.Configuration.GetValue( + "REQUIRE_ACCOUNT_VERIFICATION", + true + ), + PublicUrl = + builder.Configuration.GetValue("PUBLIC_URL") + ?? "http://localhost:8901", + ApiKeys = builder.Configuration.GetValue("KYOO_APIKEYS", string.Empty)!.Split(','), + OIDC = builder + .Configuration.AsEnumerable() + .Where((pair) => pair.Key.StartsWith("OIDC_")) + .Aggregate( + new Dictionary(), + (acc, val) => + { + if (val.Value is null) + return acc; + if (val.Key.Split("_") is not ["OIDC", string provider, string key]) + { + Log.Error("Invalid oidc config value: {Key}", val.Key); + return acc; + } + provider = provider.ToLowerInvariant(); + key = key.ToLowerInvariant(); + + if (!acc.ContainsKey(provider)) + acc.Add(provider, new(provider)); + switch (key) + { + case "clientid": + acc[provider].ClientId = val.Value; + break; + case "secret": + acc[provider].Secret = val.Value; + break; + case "scope": + acc[provider].Scope = val.Value; + break; + case "authorization": + acc[provider].AuthorizationUrl = val.Value; + break; + case "token": + acc[provider].TokenUrl = val.Value; + break; + case "userinfo": + case "profile": + acc[provider].ProfileUrl = val.Value; + break; + case "name": + acc[provider].DisplayName = val.Value; + break; + case "logo": + acc[provider].LogoUrl = val.Value; + break; + case "clientauthmethod": + case "authmethod": + case "auth": + case "method": + if (!Enum.TryParse(val.Value, out AuthMethod method)) + { + Log.Error( + "Invalid AuthMethod value: {AuthMethod}. Ignoring.", + val.Value + ); + break; + } + acc[provider].ClientAuthMethod = method; + break; + default: + Log.Error("Invalid oidc config value: {Key}", key); + return acc; + } + return acc; + } + ), + }; + builder.Services.AddSingleton(options); + + byte[] secret = builder.Configuration.GetValue("AUTHENTICATION_SECRET")!; + builder.Services.AddSingleton(new AuthenticationOption() { Secret = secret }); + + builder + .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Events = new() + { + OnMessageReceived = (ctx) => + { + string prefix = "Bearer "; + if ( + ctx.Request.Headers.TryGetValue("Authorization", out StringValues val) + && val.ToString() is string auth + && auth.StartsWith(prefix) + ) + { + ctx.Token ??= auth[prefix.Length..]; + } + ctx.Token ??= ctx.Request.Cookies["X-Bearer"]; + return Task.CompletedTask; + } + }; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(secret) + }; + }); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } +} diff --git a/src/Kyoo.Authentication/Controllers/ITokenController.cs b/src/Kyoo.Authentication/Controllers/ITokenController.cs new file mode 100644 index 0000000..b0599e0 --- /dev/null +++ b/src/Kyoo.Authentication/Controllers/ITokenController.cs @@ -0,0 +1,53 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Microsoft.IdentityModel.Tokens; + +namespace Kyoo.Authentication; + +/// +/// The service that controls jwt creation and validation. +/// +public interface ITokenController +{ + /// + /// Create a new access token for the given user. + /// + /// The user to create a token for. + /// When this token will expire. + /// A new, valid access token. + string CreateAccessToken(User user, out TimeSpan expireIn); + + /// + /// Create a new refresh token for the given user. + /// + /// The user to create a token for. + /// A new, valid refresh token. + Task CreateRefreshToken(User user); + + /// + /// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to. + /// + /// The refresh token to validate. + /// The given refresh token is not valid. + /// The id of the token's user. + Guid GetRefreshTokenUserID(string refreshToken); +} diff --git a/src/Kyoo.Authentication/Controllers/OidcController.cs b/src/Kyoo.Authentication/Controllers/OidcController.cs new file mode 100644 index 0000000..b4fbbee --- /dev/null +++ b/src/Kyoo.Authentication/Controllers/OidcController.cs @@ -0,0 +1,143 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.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; +using Kyoo.Authentication.Models; +using Kyoo.Authentication.Models.DTO; + +namespace Kyoo.Authentication; + +public class OidcController( + IUserRepository users, + IHttpClientFactory clientFactory, + PermissionOption options +) +{ + private async Task<(User, ExternalToken)> _TranslateCode(string provider, string code) + { + OidcProvider prov = options.OIDC[provider]; + + HttpClient client = clientFactory.CreateClient(); + + Dictionary data = + new() + { + ["code"] = code, + ["redirect_uri"] = $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", + ["grant_type"] = "authorization_code", + }; + + if (prov.ClientAuthMethod == AuthMethod.ClientSecretBasic) + { + string auth = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{prov.ClientId}:{prov.Secret}") + ); + client.DefaultRequestHeaders.Add("Authorization", $"Basic {auth}"); + } + else if (prov.ClientAuthMethod == AuthMethod.ClientSecretPost) + { + data["client_id"] = prov.ClientId; + data["client_secret"] = prov.Secret; + } + + 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()}" + ); + JwtToken? token = await resp.Content.ReadFromJsonAsync(); + if (token is null) + throw new ValidationException("Could not retrive token."); + + client.DefaultRequestHeaders.Remove("Authorization"); + client.DefaultRequestHeaders.Add("Authorization", $"{token.TokenType} {token.AccessToken}"); + Dictionary? extraHeaders = prov.GetExtraHeaders?.Invoke(prov); + if (extraHeaders is not null) + { + foreach ((string key, string value) in extraHeaders) + client.DefaultRequestHeaders.Add(key, value); + } + + JwtProfile? profile = await client.GetFromJsonAsync(prov.ProfileUrl); + if (profile is null || profile.Sub is null) + throw new ValidationException( + $"Missing sub on user object. 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; + 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 = profile.Username; + newUser.Username = profile.Username; + newUser.Slug = Utils.Utility.ToSlug(newUser.Username); + newUser.ExternalId.Add(provider, extToken); + return (newUser, extToken); + } + + public async Task LoginViaCode(string provider, string code) + { + (User newUser, ExternalToken extToken) = await _TranslateCode(provider, code); + User? user = await users.GetByExternalId(provider, extToken.Id); + if (user == null) + { + try + { + user = await users.Create(newUser); + } + catch + { + throw new ValidationException( + "A user already exists with the same username. If this is you, login via username and then link your account." + ); + } + } + return user; + } + + public async Task LinkAccountOrLogin(Guid userId, string provider, string code) + { + (_, ExternalToken extToken) = await _TranslateCode(provider, code); + User? user = await users.GetByExternalId(provider, extToken.Id); + if (user != null) + return user; + return await users.AddExternalToken(userId, provider, extToken); + } +} diff --git a/src/Kyoo.Authentication/Controllers/PermissionValidator.cs b/src/Kyoo.Authentication/Controllers/PermissionValidator.cs new file mode 100644 index 0000000..5b72316 --- /dev/null +++ b/src/Kyoo.Authentication/Controllers/PermissionValidator.cs @@ -0,0 +1,284 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Primitives; + +namespace Kyoo.Authentication; + +/// +/// A permission validator to validate permission with user Permission array +/// or the default array from the configurations if the user is not logged. +/// +public class PermissionValidator : IPermissionValidator +{ + /// + /// The permissions options to retrieve default permissions. + /// + private readonly PermissionOption _options; + + /// + /// Create a new factory with the given options. + /// + /// The option containing default values. + public PermissionValidator(PermissionOption options) + { + _options = options; + } + + /// + public IFilterMetadata Create(PermissionAttribute attribute) + { + return new PermissionValidatorFilter( + attribute.Type, + attribute.Kind, + attribute.Group, + _options + ); + } + + /// + public IFilterMetadata Create(PartialPermissionAttribute attribute) + { + return new PermissionValidatorFilter( + ((object?)attribute.Type ?? attribute.Kind)!, + attribute.Group, + _options + ); + } + + /// + /// The authorization filter used by . + /// + private class PermissionValidatorFilter : IAsyncAuthorizationFilter + { + /// + /// The permission to validate. + /// + private readonly string? _permission; + + /// + /// The kind of permission needed. + /// + private readonly Kind? _kind; + + /// + /// The group of he permission. + /// + private Group _group; + + /// + /// The permissions options to retrieve default permissions. + /// + private readonly PermissionOption _options; + + /// + /// Create a new permission validator with the given options. + /// + /// The permission to validate. + /// The kind of permission needed. + /// The group of the permission. + /// The option containing default values. + public PermissionValidatorFilter( + string permission, + Kind kind, + Group group, + PermissionOption options + ) + { + _permission = permission; + _kind = kind; + _group = group; + _options = options; + } + + /// + /// Create a new permission validator with the given options. + /// + /// The partial permission to validate. + /// The group of the permission. + /// The option containing default values. + public PermissionValidatorFilter(object partialInfo, Group? group, PermissionOption options) + { + switch (partialInfo) + { + case Kind kind: + _kind = kind; + break; + case string perm: + _permission = perm; + break; + default: + throw new ArgumentException( + $"{nameof(partialInfo)} can only be a permission string or a kind." + ); + } + + if (group is not null and not Group.None) + _group = group.Value; + _options = options; + } + + /// + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + string? permission = _permission; + Kind? kind = _kind; + + if (permission == null || kind == null) + { + if (context.HttpContext.Items["PermissionGroup"] is Group group and not Group.None) + _group = group; + else if (_group == Group.None) + _group = Group.Overall; + else + context.HttpContext.Items["PermissionGroup"] = _group; + + switch (context.HttpContext.Items["PermissionType"]) + { + case string perm: + permission = perm; + break; + case Kind kin: + kind = kin; + break; + case null when kind != null: + context.HttpContext.Items["PermissionType"] = kind; + return; + case null when permission != null: + context.HttpContext.Items["PermissionType"] = permission; + return; + default: + throw new ArgumentException( + "Multiple non-matching partial permission attribute " + + "are not supported." + ); + } + if (permission == null || kind == null) + { + throw new ArgumentException( + "The permission type or kind is still missing after two partial " + + "permission attributes, this is unsupported." + ); + } + } + + string permStr = $"{permission.ToLower()}.{kind.ToString()!.ToLower()}"; + string overallStr = $"{_group.ToString().ToLower()}.{kind.ToString()!.ToLower()}"; + AuthenticateResult res = _ApiKeyCheck(context); + if (res.None) + res = await _JwtCheck(context); + + if (res.Succeeded) + { + ICollection permissions = res.Principal.GetPermissions(); + if (permissions.All(x => x != permStr && x != overallStr)) + context.Result = _ErrorResult( + $"Missing permission {permStr} or {overallStr}", + StatusCodes.Status403Forbidden + ); + } + else if (res.None) + { + ICollection permissions = _options.Default ?? Array.Empty(); + if (permissions.All(x => x != permStr && x != overallStr)) + { + context.Result = _ErrorResult( + $"Unlogged user does not have permission {permStr} or {overallStr}", + StatusCodes.Status401Unauthorized + ); + } + } + else if (res.Failure != null) + context.Result = _ErrorResult(res.Failure.Message, StatusCodes.Status403Forbidden); + else + context.Result = _ErrorResult( + "Authentication panic", + StatusCodes.Status500InternalServerError + ); + } + + private AuthenticateResult _ApiKeyCheck(ActionContext context) + { + if ( + !context.HttpContext.Request.Headers.TryGetValue( + "X-API-Key", + out StringValues apiKey + ) + ) + return AuthenticateResult.NoResult(); + if (!_options.ApiKeys.Contains(apiKey!)) + return AuthenticateResult.Fail("Invalid API-Key."); + return AuthenticateResult.Success( + new AuthenticationTicket( + new ClaimsPrincipal( + new[] + { + new ClaimsIdentity( + new[] + { + // TODO: Make permission configurable, for now every APIKEY as all permissions. + new Claim( + Claims.Permissions, + string.Join(',', PermissionOption.Admin) + ) + } + ) + } + ), + "apikey" + ) + ); + } + + private async Task _JwtCheck(ActionContext context) + { + AuthenticateResult ret = await context.HttpContext.AuthenticateAsync( + JwtBearerDefaults.AuthenticationScheme + ); + // Change the failure message to make the API nice to use. + if (ret.Failure != null) + return AuthenticateResult.Fail("Invalid JWT token. The token may have expired."); + return ret; + } + } + + /// + /// Create a new action result with the given error message and error code. + /// + /// The error message. + /// The status code of the error. + /// The resulting error action. + private static IActionResult _ErrorResult(string error, int code) + { + return new ObjectResult(new RequestError(error)) { StatusCode = code }; + } +} diff --git a/src/Kyoo.Authentication/Controllers/TokenController.cs b/src/Kyoo.Authentication/Controllers/TokenController.cs new file mode 100644 index 0000000..818d65b --- /dev/null +++ b/src/Kyoo.Authentication/Controllers/TokenController.cs @@ -0,0 +1,116 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Authentication.Models; +using Microsoft.IdentityModel.Tokens; + +namespace Kyoo.Authentication; + +public class TokenController(AuthenticationOption options) : ITokenController +{ + /// + public string CreateAccessToken(User user, out TimeSpan expireIn) + { + expireIn = new TimeSpan(1, 0, 0); + + SymmetricSecurityKey key = new(options.Secret); + SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); + string permissions = + user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty; + List claims = + new() + { + new Claim(Claims.Id, user.Id.ToString()), + new Claim(Claims.Name, user.Username), + new Claim(Claims.Permissions, permissions), + new Claim(Claims.Type, "access") + }; + if (user.Email != null) + claims.Add(new Claim(Claims.Email, user.Email)); + JwtSecurityToken token = + new( + signingCredentials: credential, + claims: claims, + expires: DateTime.UtcNow.Add(expireIn) + ); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// + public Task CreateRefreshToken(User user) + { + SymmetricSecurityKey key = new(options.Secret); + SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); + JwtSecurityToken token = + new( + signingCredentials: credential, + claims: new[] + { + new Claim(Claims.Id, user.Id.ToString()), + new Claim(Claims.Guid, Guid.NewGuid().ToString()), + new Claim(Claims.Type, "refresh") + }, + expires: DateTime.UtcNow.AddYears(1) + ); + // TODO: refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user. + return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token)); + } + + /// + public Guid GetRefreshTokenUserID(string refreshToken) + { + SymmetricSecurityKey key = new(options.Secret); + JwtSecurityTokenHandler tokenHandler = new(); + ClaimsPrincipal principal; + try + { + principal = tokenHandler.ValidateToken( + refreshToken, + new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + IssuerSigningKey = key + }, + out SecurityToken _ + ); + } + catch (Exception) + { + throw new SecurityTokenException("Invalid refresh token"); + } + + if (principal.Claims.First(x => x.Type == Claims.Type).Value != "refresh") + throw new SecurityTokenException( + "Invalid token type. The token should be a refresh token." + ); + Claim identifier = principal.Claims.First(x => x.Type == Claims.Id); + if (Guid.TryParse(identifier.Value, out Guid id)) + return id; + throw new SecurityTokenException("Token not associated to any user."); + } +} diff --git a/src/Kyoo.Authentication/Kyoo.Authentication.csproj b/src/Kyoo.Authentication/Kyoo.Authentication.csproj new file mode 100644 index 0000000..9c89f56 --- /dev/null +++ b/src/Kyoo.Authentication/Kyoo.Authentication.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs b/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs new file mode 100644 index 0000000..c1de4fb --- /dev/null +++ b/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs @@ -0,0 +1,77 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.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 + { + set => Sub ??= value; + } + public string? Id + { + set => Sub ??= value; + } + public string? Guid + { + set => Sub ??= value; + } + + 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; + // trakt store their name there (they also store name but that's not the same). + Username ??= value["username"]?.ToString(); + // simkl store their name there. + Username ??= value["name"]?.ToString(); + + Sub ??= value["ids"]?["uuid"]?.ToString(); + } + } + + [JsonExtensionData] + public Dictionary Extra { get; set; } +} diff --git a/src/Kyoo.Authentication/Models/DTO/LoginRequest.cs b/src/Kyoo.Authentication/Models/DTO/LoginRequest.cs new file mode 100644 index 0000000..c93730f --- /dev/null +++ b/src/Kyoo.Authentication/Models/DTO/LoginRequest.cs @@ -0,0 +1,46 @@ +// 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 . + +namespace Kyoo.Authentication.Models.DTO; + +/// +/// A model only used on login requests. +/// +public class LoginRequest +{ + /// + /// The user's username. + /// + public string Username { get; set; } + + /// + /// The user's password. + /// + public string Password { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user's username. + /// The user's password. + public LoginRequest(string username, string password) + { + Username = username; + Password = password; + } +} diff --git a/src/Kyoo.Authentication/Models/DTO/PasswordResetRequest.cs b/src/Kyoo.Authentication/Models/DTO/PasswordResetRequest.cs new file mode 100644 index 0000000..5548dd7 --- /dev/null +++ b/src/Kyoo.Authentication/Models/DTO/PasswordResetRequest.cs @@ -0,0 +1,38 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.ComponentModel.DataAnnotations; + +namespace Kyoo.Authentication.Models.DTO; + +/// +/// A model only used on password resets. +/// +public class PasswordResetRequest +{ + /// + /// The old password + /// + public string? OldPassword { get; set; } + + /// + /// The new password + /// + [MinLength(4, ErrorMessage = "The password must have at least {1} characters")] + public string NewPassword { get; set; } +} diff --git a/src/Kyoo.Authentication/Models/DTO/RegisterRequest.cs b/src/Kyoo.Authentication/Models/DTO/RegisterRequest.cs new file mode 100644 index 0000000..0ee1021 --- /dev/null +++ b/src/Kyoo.Authentication/Models/DTO/RegisterRequest.cs @@ -0,0 +1,76 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.ComponentModel.DataAnnotations; +using Kyoo.Abstractions.Models; +using Kyoo.Utils; +using BCryptNet = BCrypt.Net.BCrypt; + +namespace Kyoo.Authentication.Models.DTO; + +/// +/// A model only used on register requests. +/// +public class RegisterRequest +{ + /// + /// The user email address + /// + [EmailAddress(ErrorMessage = "The email must be a valid email address")] + public string Email { get; set; } + + /// + /// The user's username. + /// + [MinLength(4, ErrorMessage = "The username must have at least {1} characters")] + public string Username { get; set; } + + /// + /// The user's password. + /// + [MinLength(4, ErrorMessage = "The password must have at least {1} characters")] + public string Password { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user email address. + /// The user's username. + /// The user's password. + public RegisterRequest(string email, string username, string password) + { + Email = email; + Username = username; + Password = password; + } + + /// + /// Convert this register request to a new class. + /// + /// A user representing this request. + public User ToUser() + { + return new User + { + Slug = Utility.ToSlug(Username), + Username = Username, + Password = BCryptNet.HashPassword(Password), + Email = Email, + }; + } +} diff --git a/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs b/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs new file mode 100644 index 0000000..c7421a1 --- /dev/null +++ b/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs @@ -0,0 +1,98 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; + +namespace Kyoo.Authentication.Models; + +public class ServerInfo +{ + /// + /// The list of oidc providers configured for this instance of kyoo. + /// + public Dictionary Oidc { get; set; } + + /// + /// The url to reach the homepage of kyoo (add /api for the api). + /// + public string PublicUrl { get; set; } + + /// + /// True if guest accounts are allowed on this instance. + /// + public bool AllowGuests { get; set; } + + /// + /// True if new users needs to be verifed. + /// + public bool RequireVerification { get; set; } + + /// + /// The list of permissions available for the guest account. + /// + public List GuestPermissions { get; set; } + + /// + /// Check if kyoo's setup is finished. + /// + public SetupStep SetupStatus { get; set; } + + /// + /// True if password login is enabled on this instance. + /// + public bool PasswordLoginEnabled { get; set; } + + /// + /// True if registration is enabled on this instance. + /// + public bool RegistrationEnabled { get; set; } +} + +public class OidcInfo +{ + /// + /// The name of this oidc service. Human readable. + /// + public string DisplayName { get; set; } + + /// + /// A url returing a square logo for this provider. + /// + public string? LogoUrl { get; set; } +} + +/// +/// Check if kyoo's setup is finished. +/// +public enum SetupStep +{ + /// + /// No admin account exists, create an account before exposing kyoo to the internet! + /// + MissingAdminAccount, + + /// + /// No video was registered on kyoo, have you configured the rigth library path? + /// + NoVideoFound, + + /// + /// Setup finished! + /// + Done, +} diff --git a/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs b/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs new file mode 100644 index 0000000..74822e0 --- /dev/null +++ b/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs @@ -0,0 +1,24 @@ +// 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 . + +namespace Kyoo.Authentication.Models; + +public class AuthenticationOption +{ + public byte[] Secret { get; set; } +} diff --git a/src/Kyoo.Authentication/Models/Options/PermissionOption.cs b/src/Kyoo.Authentication/Models/Options/PermissionOption.cs new file mode 100644 index 0000000..458003e --- /dev/null +++ b/src/Kyoo.Authentication/Models/Options/PermissionOption.cs @@ -0,0 +1,180 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Authentication.Models.DTO; + +namespace Kyoo.Authentication.Models; + +/// +/// Permission options. +/// +public class PermissionOption +{ + /// + /// The path to get this option from the root configuration. + /// + public const string Path = "authentication:permissions"; + + /// + /// True if new users needs to be verifed. + /// + public bool RequireVerification { get; set; } + + /// + /// The default permissions that will be given to a non-connected user. + /// + public string[] Default { get; set; } = { "overall.read", "overall.play" }; + + /// + /// Permissions applied to a new user. + /// + public string[] NewUser { get; set; } = { "overall.read", "overall.play" }; + + public static string[] Admin => + Enum.GetNames() + .Where(x => x != nameof(Group.None)) + .SelectMany(group => + Enum.GetNames().Select(kind => $"{group}.{kind}".ToLowerInvariant()) + ) + .ToArray(); + + /// + /// The list of available ApiKeys. + /// + public string[] ApiKeys { get; set; } = Array.Empty(); + + public string PublicUrl { get; set; } + + public Dictionary OIDC { get; set; } +} + +public enum AuthMethod +{ + ClientSecretBasic, + ClientSecretPost, + None, +} + +public class OidcProvider +{ + public string DisplayName { get; set; } + public string? LogoUrl { get; set; } + public string AuthorizationUrl { get; set; } + public string TokenUrl { get; set; } + + /// + /// Some token endpoints do net respect the spec and require a json body instead of a form url encoded. + /// + public bool TokenUseJsonBody { get; set; } + + /// + /// The OIDC spec allows multiples ways of authorizing the client. + /// + public AuthMethod ClientAuthMethod { get; set; } = AuthMethod.ClientSecretBasic; + + public string ProfileUrl { get; set; } + public string? Scope { get; set; } + public string ClientId { get; set; } + public string Secret { get; set; } + + public Func? GetProfileUrl { get; init; } + public Func>? GetExtraHeaders { get; init; } + + public bool Enabled => + AuthorizationUrl != null + && TokenUrl != null + && ProfileUrl != null + && ClientId != null + && Secret != null; + + public OidcProvider(string provider) + { + DisplayName = provider; + if (KnownProviders?.ContainsKey(provider) == true) + { + DisplayName = KnownProviders[provider].DisplayName; + LogoUrl = KnownProviders[provider].LogoUrl; + AuthorizationUrl = KnownProviders[provider].AuthorizationUrl; + TokenUrl = KnownProviders[provider].TokenUrl; + ProfileUrl = KnownProviders[provider].ProfileUrl; + Scope = KnownProviders[provider].Scope; + ClientId = KnownProviders[provider].ClientId; + Secret = KnownProviders[provider].Secret; + TokenUseJsonBody = KnownProviders[provider].TokenUseJsonBody; + ClientAuthMethod = KnownProviders[provider].ClientAuthMethod; + GetProfileUrl = KnownProviders[provider].GetProfileUrl; + GetExtraHeaders = KnownProviders[provider].GetExtraHeaders; + } + } + + public static readonly Dictionary KnownProviders = + new() + { + ["google"] = new("google") + { + DisplayName = "Google", + LogoUrl = "https://logo.clearbit.com/google.com", + AuthorizationUrl = "https://accounts.google.com/o/oauth2/v2/auth", + TokenUrl = "https://oauth2.googleapis.com/token", + ProfileUrl = "https://openidconnect.googleapis.com/v1/userinfo", + Scope = "email profile", + }, + ["discord"] = new("discord") + { + DisplayName = "Discord", + LogoUrl = "https://logo.clearbit.com/discord.com", + AuthorizationUrl = "https://discord.com/oauth2/authorize", + 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, + ClientAuthMethod = AuthMethod.ClientSecretPost, + GetProfileUrl = (profile) => $"https://simkl.com/{profile.Sub}/dashboard/", + GetExtraHeaders = (OidcProvider self) => + new() { ["simkl-api-key"] = self.ClientId }, + }, + ["trakt"] = new("trakt") + { + DisplayName = "Trakt", + LogoUrl = "https://logo.clearbit.com/trakt.tv", + AuthorizationUrl = "https://api.trakt.tv/oauth/authorize", + TokenUrl = "https://api.trakt.tv/oauth/token", + ProfileUrl = "https://api.trakt.tv/users/settings", + // does not seems to have scopes + Scope = null, + TokenUseJsonBody = true, + GetProfileUrl = (profile) => $"https://trakt.tv/users/{profile.Username}", + GetExtraHeaders = (OidcProvider self) => + new() { ["trakt-api-key"] = self.ClientId, ["trakt-api-version"] = "2", }, + }, + }; +} diff --git a/src/Kyoo.Authentication/Views/AuthApi.cs b/src/Kyoo.Authentication/Views/AuthApi.cs new file mode 100644 index 0000000..6fa1f4e --- /dev/null +++ b/src/Kyoo.Authentication/Views/AuthApi.cs @@ -0,0 +1,501 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication.Attributes; +using Kyoo.Authentication.Models; +using Kyoo.Authentication.Models.DTO; +using Kyoo.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using static Kyoo.Abstractions.Models.Utils.Constants; +using BCryptNet = BCrypt.Net.BCrypt; + +namespace Kyoo.Authentication.Views; + +/// +/// Sign in, Sign up or refresh tokens. +/// +[ApiController] +[Route("auth")] +[ApiDefinition("Authentication", Group = UsersGroup)] +public class AuthApi( + IUserRepository users, + OidcController oidc, + ITokenController tokenController, + IThumbnailsManager thumbs, + PermissionOption options +) : ControllerBase +{ + /// + /// Create a new Forbidden result from an object. + /// + /// The json value to output on the response. + /// A new forbidden result with the given json object. + public static ObjectResult Forbid(object value) + { + return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden }; + } + + private static string _BuildUrl(string baseUrl, Dictionary queryParams) + { + char querySep = baseUrl.Contains('?') ? '&' : '?'; + foreach ((string key, string? val) in queryParams) + { + if (val is null) + continue; + baseUrl += $"{querySep}{key}={val}"; + querySep = '&'; + } + return baseUrl; + } + + /// + /// Oauth Login. + /// + /// + /// Login via a registered oauth provider. + /// + /// The provider code. + /// + /// A url where you will be redirected with the query params provider, code and error. It can be a deep link. + /// + /// A redirect to the provider's login page. + /// The provider is not register with this instance of kyoo. + [HttpGet("login/{provider}")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))] + public ActionResult LoginVia(string provider, [FromQuery] string redirectUrl) + { + if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled) + { + return NotFound( + new RequestError( + $"Invalid provider. {provider} is not registered no this instance of kyoo." + ) + ); + } + OidcProvider prov = options.OIDC[provider]; + return Redirect( + _BuildUrl( + prov.AuthorizationUrl, + new() + { + ["response_type"] = "code", + ["client_id"] = prov.ClientId, + ["redirect_uri"] = + $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", + ["scope"] = prov.Scope, + ["state"] = redirectUrl, + } + ) + ); + } + + /// + /// Oauth Code Redirect. + /// + /// + /// This route is not meant to be called manually, the user should be redirected automatically here + /// after a successful login on the /login/{provider} page. + /// + /// A redirect to the provider's login page. + /// The provider gave an error. + [HttpGet("logged/{provider}")] + [ProducesResponseType(StatusCodes.Status302Found)] + public ActionResult OauthCodeRedirect(string provider, string code, string state, string? error) + { + return Redirect( + _BuildUrl( + state, + new() + { + ["provider"] = provider, + ["code"] = code, + ["error"] = error, + } + ) + ); + } + + /// + /// Oauth callback + /// + /// + /// This route should be manually called by the page that got redirected to after a call to /login/{provider}. + /// + /// A jwt token + /// Bad provider or code + [HttpPost("callback/{provider}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task> OauthCallback(string provider, string code) + { + if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled) + { + return NotFound( + new RequestError( + $"Invalid provider. {provider} is not registered no this instance of kyoo." + ) + ); + } + if (code == null) + return BadRequest(new RequestError("Invalid code.")); + + Guid? userId = User.GetId(); + User user = userId.HasValue + ? await oidc.LinkAccountOrLogin(userId.Value, provider, code) + : await oidc.LoginViaCode(provider, code); + return new JwtToken( + tokenController.CreateAccessToken(user, out TimeSpan expireIn), + await tokenController.CreateRefreshToken(user), + expireIn + ); + } + + /// + /// Unlink account + /// + /// + /// Unlink your account from an external account. + /// + /// The provider code. + /// Your updated user account + [HttpDelete("login/{provider}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [UserOnly] + public Task UnlinkAccount(string provider) + { + Guid id = User.GetIdOrThrow(); + return users.DeleteExternalToken(id, provider); + } + + /// + /// Login. + /// + /// + /// Login as a user and retrieve an access and a refresh token. + /// + /// The body of the request. + /// A new access and a refresh token. + /// The user and password does not match. + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + [DisableOnEnvVar("AUTHENTICATION_DISABLE_PASSWORD_LOGIN")] + public async Task> Login([FromBody] LoginRequest request) + { + User? user = await users.GetOrDefault( + new Filter.Eq(nameof(Abstractions.Models.User.Username), request.Username) + ); + if (user != null && user.Password == null) + return Forbid( + new RequestError( + "This account was registerd via oidc. Please login via oidc or add a password to your account in the settings first" + ) + ); + if (user == null || !BCryptNet.Verify(request.Password, user.Password)) + return Forbid(new RequestError("The user and password does not match.")); + + return new JwtToken( + tokenController.CreateAccessToken(user, out TimeSpan expireIn), + await tokenController.CreateRefreshToken(user), + expireIn + ); + } + + /// + /// Register. + /// + /// + /// Register a new user and get a new access/refresh token for this new user. + /// + /// The body of the request. + /// A new access and a refresh token. + /// The request is invalid. + /// A user already exists with this username or email address. + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))] + [DisableOnEnvVar("AUTHENTICATION_DISABLE_USER_REGISTRATION")] + public async Task> Register([FromBody] RegisterRequest request) + { + try + { + User user = await users.Create(request.ToUser()); + return new JwtToken( + tokenController.CreateAccessToken(user, out TimeSpan expireIn), + await tokenController.CreateRefreshToken(user), + expireIn + ); + } + catch (DuplicatedItemException) + { + return Conflict(new RequestError("A user already exists with this username.")); + } + } + + /// + /// Refresh a token. + /// + /// + /// Refresh an access token using the given refresh token. A new access and refresh token are generated. + /// + /// A valid refresh token. + /// A new access and refresh token. + /// The given refresh token is invalid. + [HttpGet("refresh")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> Refresh([FromQuery] string token) + { + try + { + Guid userId = tokenController.GetRefreshTokenUserID(token); + User user = await users.Get(userId); + return new JwtToken( + tokenController.CreateAccessToken(user, out TimeSpan expireIn), + await tokenController.CreateRefreshToken(user), + expireIn + ); + } + catch (ItemNotFoundException) + { + return Forbid(new RequestError("Invalid refresh token.")); + } + catch (SecurityTokenException ex) + { + return Forbid(new RequestError(ex.Message)); + } + } + + /// + /// Reset your password + /// + /// + /// Change your password. + /// + /// The old and new password + /// Your account info. + /// The old password is invalid. + [HttpPost("password-reset")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> ResetPassword([FromBody] PasswordResetRequest request) + { + User user = await users.Get(User.GetIdOrThrow()); + if (user.HasPassword && !BCryptNet.Verify(request.OldPassword, user.Password)) + return Forbid(new RequestError("The old password is invalid.")); + return await users.Patch( + user.Id, + (user) => + { + user.Password = BCryptNet.HashPassword(request.NewPassword); + return user; + } + ); + } + + /// + /// Get authenticated user. + /// + /// + /// Get information about the currently authenticated user. This can also be used to ensure that you are + /// logged in. + /// + /// The currently authenticated user. + /// The user is not authenticated. + /// The given access token is invalid. + [HttpGet("me")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> GetMe() + { + try + { + return await users.Get(User.GetIdOrThrow()); + } + catch (ItemNotFoundException) + { + return Forbid(new RequestError("Invalid token")); + } + } + + /// + /// Edit self + /// + /// + /// Edit information about the currently authenticated user. + /// + /// The new data for the current user. + /// The currently authenticated user after modifications. + /// The user is not authenticated. + /// The given access token is invalid. + [HttpPut("me")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> EditMe(User user) + { + try + { + user.Id = User.GetIdOrThrow(); + return await users.Edit(user); + } + catch (ItemNotFoundException) + { + return Forbid(new RequestError("Invalid token")); + } + } + + /// + /// Patch self + /// + /// + /// Edit only provided informations about the currently authenticated user. + /// + /// The new data for the current user. + /// The currently authenticated user after modifications. + /// The user is not authenticated. + /// The given access token is invalid. + [HttpPatch("me")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> PatchMe([FromBody] Patch patch) + { + Guid userId = User.GetIdOrThrow(); + try + { + if (patch.Id.HasValue && patch.Id != userId) + throw new ArgumentException("Can't edit your user id."); + if (patch.ContainsKey(nameof(Abstractions.Models.User.Password))) + throw new ArgumentException( + "Can't edit your password via a PATCH. Use /auth/password-reset" + ); + return await users.Patch(userId, patch.Apply); + } + catch (ItemNotFoundException) + { + return Forbid(new RequestError("Invalid token")); + } + } + + /// + /// Delete account + /// + /// + /// Delete the current account. + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpDelete("me")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> DeleteMe() + { + try + { + await users.Delete(User.GetIdOrThrow()); + return NoContent(); + } + catch (ItemNotFoundException) + { + return Forbid(new RequestError("Invalid token")); + } + } + + /// + /// Get profile picture + /// + /// + /// Get your profile picture + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpGet("me/logo")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task GetProfilePicture() + { + Stream img = await thumbs.GetUserImage(User.GetIdOrThrow()); + // Allow clients to cache the image for 6 month. + Response.Headers.CacheControl = $"public, max-age={60 * 60 * 24 * 31 * 6}"; + return File(img, "image/webp", true); + } + + /// + /// Set profile picture + /// + /// + /// Set your profile picture + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpPost("me/logo")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task SetProfilePicture(IFormFile picture) + { + if (picture == null || picture.Length == 0) + return BadRequest(); + await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream()); + return NoContent(); + } + + /// + /// Delete profile picture + /// + /// + /// Delete your profile picture + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpDelete("me/logo")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task DeleteProfilePicture() + { + await thumbs.SetUserImage(User.GetIdOrThrow(), null); + return NoContent(); + } +} diff --git a/src/Kyoo.Core/.gitignore b/src/Kyoo.Core/.gitignore new file mode 100644 index 0000000..8f2daad --- /dev/null +++ b/src/Kyoo.Core/.gitignore @@ -0,0 +1,234 @@ +## PROJECT CUSTOM IGNORES +libtranscoder.so +wwwroot/ + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +bin/ +Bin/ +obj/ +Obj/ + +# Visual Studio 2015 cache/options directory +.vs/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Microsoft Azure ApplicationInsights config file +ApplicationInsights.config + +# Windows Store app package directory +AppPackages/ +BundleArtifacts/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +orleans.codegen.cs + +/node_modules + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe + +# FAKE - F# Make +.fake/ diff --git a/src/Kyoo.Core/Controllers/Base64RouteConstraint.cs b/src/Kyoo.Core/Controllers/Base64RouteConstraint.cs new file mode 100644 index 0000000..e69177a --- /dev/null +++ b/src/Kyoo.Core/Controllers/Base64RouteConstraint.cs @@ -0,0 +1,42 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Kyoo.Core.Controllers; + +public class Base64RouteConstraint : IRouteConstraint +{ + static Regex Base64Reg = new("^[-A-Za-z0-9+/]*={0,3}$"); + + /// + public bool Match( + HttpContext? httpContext, + IRouter? route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection + ) + { + return values.TryGetValue(routeKey, out object? val) + && val is string str + && Base64Reg.IsMatch(str); + } +} diff --git a/src/Kyoo.Core/Controllers/IdentifierRouteConstraint.cs b/src/Kyoo.Core/Controllers/IdentifierRouteConstraint.cs new file mode 100644 index 0000000..a6ffd77 --- /dev/null +++ b/src/Kyoo.Core/Controllers/IdentifierRouteConstraint.cs @@ -0,0 +1,41 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Kyoo.Core.Controllers; + +/// +/// The route constraint that goes with the . +/// +public class IdentifierRouteConstraint : IRouteConstraint +{ + /// + public bool Match( + HttpContext? httpContext, + IRouter? route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection + ) + { + return values.ContainsKey(routeKey); + } +} diff --git a/src/Kyoo.Core/Controllers/LibraryManager.cs b/src/Kyoo.Core/Controllers/LibraryManager.cs new file mode 100644 index 0000000..c0763b1 --- /dev/null +++ b/src/Kyoo.Core/Controllers/LibraryManager.cs @@ -0,0 +1,105 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Linq; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; + +namespace Kyoo.Core.Controllers; + +/// +/// An class to interact with the database. Every repository is mapped through here. +/// +public class LibraryManager : ILibraryManager +{ + private readonly IBaseRepository[] _repositories; + + public LibraryManager( + IRepository libraryItemRepository, + IRepository newsRepository, + IWatchStatusRepository watchStatusRepository, + IRepository collectionRepository, + IRepository movieRepository, + IRepository showRepository, + IRepository seasonRepository, + IRepository episodeRepository, + IRepository studioRepository, + IRepository userRepository + ) + { + LibraryItems = libraryItemRepository; + News = newsRepository; + WatchStatus = watchStatusRepository; + Collections = collectionRepository; + Movies = movieRepository; + Shows = showRepository; + Seasons = seasonRepository; + Episodes = episodeRepository; + Studios = studioRepository; + Users = userRepository; + + _repositories = + [ + LibraryItems, + News, + Collections, + Movies, + Shows, + Seasons, + Episodes, + Studios, + Users + ]; + } + + /// + public IRepository LibraryItems { get; } + + /// + public IRepository News { get; } + + /// + public IWatchStatusRepository WatchStatus { get; } + + /// + public IRepository Collections { get; } + + /// + public IRepository Movies { get; } + + /// + public IRepository Shows { get; } + + /// + public IRepository Seasons { get; } + + /// + public IRepository Episodes { get; } + + /// + public IRepository Studios { get; } + + /// + public IRepository Users { get; } + + public IRepository Repository() + where T : IResource, IQuery + { + return (IRepository)_repositories.First(x => x.RepositoryType == typeof(T)); + } +} diff --git a/src/Kyoo.Core/Controllers/MiscRepository.cs b/src/Kyoo.Core/Controllers/MiscRepository.cs new file mode 100644 index 0000000..2829562 --- /dev/null +++ b/src/Kyoo.Core/Controllers/MiscRepository.cs @@ -0,0 +1,154 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Authentication.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static System.Text.Json.JsonNamingPolicy; + +namespace Kyoo.Core.Controllers; + +public class MiscRepository( + DatabaseContext context, + DbConnection database, + IThumbnailsManager thumbnails +) +{ + public static async Task DownloadMissingImages(IServiceProvider services) + { + await using AsyncServiceScope scope = services.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService().DownloadMissingImages(); + } + + private async Task> _GetAllImages() + { + string GetSql(string type) => + $""" + select poster from {type} + union all select thumbnail from {type} + union all select logo from {type} + """; + var queries = new string[] + { + "movies", + "collections", + "shows", + "seasons", + "episodes" + }.Select(x => GetSql(x)); + string sql = string.Join(" union all ", queries); + IEnumerable ret = await database.QueryAsync(sql); + return ret.ToArray() as Image[]; + } + + public async Task DownloadMissingImages() + { + ICollection images = await _GetAllImages(); + var tasks = images + .ToAsyncEnumerable() + .WhereAwait(async x => !await thumbnails.IsImageSaved(x.Id, ImageQuality.Low)) + .Select(x => thumbnails.DownloadImage(x, x.Id.ToString())) + .ToEnumerable(); + + // Chunk tasks to prevent http timouts + foreach (IEnumerable batch in tasks.Chunk(30)) + await Task.WhenAll(batch); + } + + public async Task> GetRegisteredPaths() + { + return await context + .Episodes.Select(x => x.Path) + .Concat(context.Movies.Select(x => x.Path)) + .ToListAsync(); + } + + public async Task DeletePath(string path, bool recurse) + { + // Make sure to include a path separator to prevents deletions from things like: + // DeletePath("/video/abc", true) -> /video/abdc (should not be deleted) + string dirPath = path.EndsWith("/") ? path : $"{path}/"; + + int count = await context + .Episodes.Where(x => x.Path == path || (recurse && x.Path.StartsWith(dirPath))) + .ExecuteDeleteAsync(); + count += await context + .Movies.Where(x => x.Path == path || (recurse && x.Path.StartsWith(dirPath))) + .ExecuteDeleteAsync(); + await context + .Issues.Where(x => + x.Domain == "scanner" + && (x.Cause == path || (recurse && x.Cause.StartsWith(dirPath))) + ) + .ExecuteDeleteAsync(); + return count; + } + + public async Task> GetRefreshableItems(DateTime end) + { + IQueryable GetItems() + where T : class, IResource, IRefreshable + { + return context + .Set() + .Select(x => new RefreshableItem + { + Kind = CamelCase.ConvertName(typeof(T).Name), + Id = x.Id, + RefreshDate = x.NextMetadataRefresh!.Value + }); + } + + return await GetItems() + .Concat(GetItems()) + .Concat(GetItems()) + .Concat(GetItems()) + .Concat(GetItems()) + .Where(x => x.RefreshDate <= end) + .OrderBy(x => x.RefreshDate) + .ToListAsync(); + } + + public async Task GetSetupStep() + { + bool hasUser = await context.Users.AnyAsync(); + if (!hasUser) + return SetupStep.MissingAdminAccount; + bool hasItem = await context.Movies.AnyAsync() || await context.Shows.AnyAsync(); + return hasItem ? SetupStep.Done : SetupStep.NoVideoFound; + } +} + +public class RefreshableItem +{ + public string Kind { get; set; } + + public Guid Id { get; set; } + + public DateTime RefreshDate { get; set; } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/CollectionRepository.cs b/src/Kyoo.Core/Controllers/Repositories/CollectionRepository.cs new file mode 100644 index 0000000..0a81a29 --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/CollectionRepository.cs @@ -0,0 +1,71 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle collections +/// +public class CollectionRepository(DatabaseContext database, IThumbnailsManager thumbnails) + : GenericRepository(database) +{ + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(Database.Collections, include) + .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) + .Take(20) + .ToListAsync(); + } + + /// + protected override async Task Validate(Collection resource) + { + await base.Validate(resource); + + if (string.IsNullOrEmpty(resource.Name)) + throw new ArgumentException("The collection's name must be set and not empty"); + resource.NextMetadataRefresh ??= DateTime.UtcNow.AddMonths(2); + await thumbnails.DownloadImages(resource); + } + + public async Task AddMovie(Guid id, Guid movieId) + { + Database.AddLinks(id, movieId); + await Database.SaveChangesAsync(); + } + + public async Task AddShow(Guid id, Guid showId) + { + Database.AddLinks(id, showId); + await Database.SaveChangesAsync(); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs b/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs new file mode 100644 index 0000000..1938316 --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/DapperHelper.cs @@ -0,0 +1,415 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Data; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Dapper; +using InterpolatedSql.Dapper; +using InterpolatedSql.Dapper.SqlBuilders; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication; +using Kyoo.Utils; +using Microsoft.AspNetCore.Http; + +namespace Kyoo.Core.Controllers; + +public static class DapperHelper +{ + public static SqlBuilder ProcessVariables(SqlBuilder sql, SqlVariableContext context) + { + int start = 0; + while ((start = sql.IndexOf("[", start, false)) != -1) + { + int end = sql.IndexOf("]", start, false); + if (end == -1) + throw new ArgumentException("Invalid sql variable substitue (missing ])"); + string var = sql.Format[(start + 1)..end]; + sql.Remove(start, end - start + 1); + sql.Insert(start, $"{context.ReadVar(var)}"); + } + + return sql; + } + + public static string Property(string key, Dictionary config) + { + if (key == "kind") + return "kind"; + string[] keys = config + .Where(x => !x.Key.StartsWith('_')) + // If first char is lower, assume manual sql instead of reflection. + .Where(x => char.IsLower(key.First()) || x.Value.GetProperty(key) != null) + .Select(x => + $"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute()?.Name ?? key.ToSnakeCase()}" + ) + .ToArray(); + if (keys.Length == 1) + return keys.First(); + return $"coalesce({string.Join(", ", keys)})"; + } + + public static string ProcessSort( + Sort sort, + bool reverse, + Dictionary config, + bool recurse = false + ) + where T : IQuery + { + string ret = sort switch + { + Sort.Default(var value) => ProcessSort(value, reverse, config, true), + Sort.By(string key, bool desc) + => $"{Property(key, config)} {(desc ^ reverse ? "desc" : "asc")}", + Sort.Random(var seed) + => $"md5('{seed}' || {Property("id", config)}) {(reverse ? "desc" : "asc")}", + Sort.Conglomerate(var list) + => string.Join(", ", list.Select(x => ProcessSort(x, reverse, config, true))), + _ => throw new SwitchExpressionException(), + }; + if (recurse) + return ret; + // always end query by an id sort. + return $"{ret}, {Property("id", config)} {(reverse ? "desc" : "asc")}"; + } + + public static ( + string projection, + string join, + List types, + Func, T> map + ) ProcessInclude(Include include, Dictionary config) + where T : class + { + int relation = 0; + List types = new(); + StringBuilder projection = new(); + StringBuilder join = new(); + + foreach (Include.Metadata metadata in include.Metadatas) + { + relation++; + switch (metadata) + { + case Include.SingleRelation(var name, var type, var rid): + string tableName = + type.GetCustomAttribute()?.Name + ?? $"{type.Name.ToSnakeCase()}s"; + types.Add(type); + projection.AppendLine($", r{relation}.* -- {type.Name} as r{relation}"); + join.Append( + $"\nleft join {tableName} as r{relation} on r{relation}.id = {Property(rid, config)}" + ); + break; + case Include.CustomRelation(var name, var type, var sql, var on, var declaring): + string owner = config.First(x => x.Value == declaring).Key; + string lateral = sql.Contains("\"this\"") ? " lateral" : string.Empty; + sql = sql.Replace("\"this\"", owner); + on = on?.Replace("\"this\"", owner)?.Replace("\"relation\"", $"r{relation}"); + if (sql.Any(char.IsWhiteSpace)) + sql = $"({sql})"; + types.Add(type); + projection.AppendLine($", r{relation}.*"); + join.Append($"\nleft join{lateral} {sql} as r{relation} on r{relation}.{on}"); + break; + case Include.ProjectedRelation: + continue; + default: + throw new NotImplementedException(); + } + } + + T Map(T item, IEnumerable relations) + { + IEnumerable metadatas = include + .Metadatas.Where(x => x is not Include.ProjectedRelation) + .Select(x => x.Name); + foreach ((string name, object? value) in metadatas.Zip(relations)) + { + if (value == null) + continue; + PropertyInfo? prop = item.GetType().GetProperty(name); + if (prop != null) + prop.SetValue(item, value); + } + return item; + } + + return (projection.ToString(), join.ToString(), types, Map); + } + + public static FormattableString ProcessFilter( + Filter filter, + Dictionary config + ) + { + FormattableString Format(string key, FormattableString op) + { + if (key == "kind") + { + string cases = string.Join( + '\n', + config + .Skip(1) + .Select(x => + $"when {x.Key}.id is not null then '{x.Value.Name.ToLowerInvariant()}'" + ) + ); + return $""" + case + {cases:raw} + else '{config.First().Value.Name.ToLowerInvariant():raw}' + end {op} + """; + } + + IEnumerable properties = config + .Where(x => !x.Key.StartsWith('_')) + // If first char is lower, assume manual sql instead of reflection. + .Where(x => char.IsLower(key.First()) || x.Value.GetProperty(key) != null) + .Select(x => + $"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute()?.Name ?? key.ToSnakeCase()}" + ); + + FormattableString ret = $"{properties.First():raw} {op}"; + foreach (string property in properties.Skip(1)) + ret = $"{ret} or {property:raw} {op}"; + return $"({ret})"; + } + + object P(object value) + { + if (value is Enum) + return new Wrapper(value); + return value; + } + + FormattableString Process(Filter fil) + { + return fil switch + { + Filter.And(var first, var second) => $"({Process(first)} and {Process(second)})", + Filter.Or(var first, var second) => $"({Process(first)} or {Process(second)})", + Filter.Not(var inner) => $"(not {Process(inner)})", + Filter.Eq(var property, var value) when value is null + => Format(property, $"is null"), + Filter.Ne(var property, var value) when value is null + => Format(property, $"is not null"), + Filter.Eq(var property, var value) => Format(property, $"= {P(value!)}"), + Filter.Ne(var property, var value) => Format(property, $"!= {P(value!)}"), + Filter.Gt(var property, var value) => Format(property, $"> {P(value)}"), + Filter.Ge(var property, var value) => Format(property, $">= {P(value)}"), + Filter.Lt(var property, var value) => Format(property, $"< {P(value)}"), + Filter.Le(var property, var value) => Format(property, $"> {P(value)}"), + Filter.Has(var property, var value) + => $"{P(value)} = any({Property(property, config):raw})", + Filter.CmpRandom(var op, var seed, var id) + => $"md5({seed} || coalesce({string.Join(", ", config.Select(x => $"{x.Key}.id")):raw})) {op:raw} md5({seed} || {id.ToString()})", + Filter.Lambda(var lambda) => throw new NotSupportedException(), + _ => throw new NotImplementedException(), + }; + } + return Process(filter); + } + + public static string ExpendProjections(Type type, string? prefix, Include include) + { + IEnumerable projections = include + .Metadatas.Select(x => (x as Include.ProjectedRelation)!) + .Where(x => x != null) + .Where(x => type.GetProperty(x.Name) != null) + .Select(x => x.Sql.Replace("\"this\".", prefix)); + return string.Join(", ", projections); + } + + public static async Task> Query( + this IDbConnection db, + FormattableString command, + Dictionary config, + Func, T> mapper, + Func> get, + SqlVariableContext context, + Include? include, + Filter? filter, + Sort? sort, + Pagination? limit + ) + where T : class, IResource, IQuery + { + SqlBuilder query = new(db, command); + + // Include handling + include ??= new(); + var (includeProjection, includeJoin, includeTypes, mapIncludes) = ProcessInclude( + include, + config + ); + query.Replace("/* includesJoin */", $"{includeJoin:raw}", out bool replaced); + if (!replaced) + query.AppendLiteral(includeJoin); + query.Replace("/* includes */", $"{includeProjection:raw}", out replaced); + if (!replaced) + throw new ArgumentException( + "Missing '/* includes */' placeholder in top level sql select to support includes." + ); + + // Handle pagination, orders and filter. + if (limit?.AfterID != null) + { + T reference = await get(limit.AfterID.Value); + Filter? keysetFilter = RepositoryHelper.KeysetPaginate( + sort, + reference, + !limit.Reverse + ); + filter = Filter.And(filter, keysetFilter); + } + if (filter != null) + { + FormattableString filterSql = ProcessFilter(filter, config); + query.Replace("/* where */", $"and {filterSql}", out replaced); + if (!replaced) + query += $"\nwhere {filterSql}"; + } + if (sort != null) + query += $"\norder by {ProcessSort(sort, limit?.Reverse ?? false, config):raw}"; + if (limit != null) + query += $"\nlimit {limit.Limit}"; + + ProcessVariables(query, context); + + // Build query and prepare to do the query/projections + IDapperSqlCommand cmd = query.Build(); + string sql = cmd.Sql; + List types = config.Select(x => x.Value).Concat(includeTypes).ToList(); + + // Expand projections on every types received. + sql = Regex.Replace( + sql, + @"(,?) -- (\w+)( as (\w+))?", + (match) => + { + string leadingComa = match.Groups[1].Value; + string type = match.Groups[2].Value; + string? prefix = match.Groups[4].Value; + prefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}." : string.Empty; + + Type typeV = types.First(x => x.Name == type); + + // Only project top level items with explicit includes. + string? projection = config.Any(x => x.Value.Name == type) + ? ExpendProjections(typeV, prefix, include) + : null; + + if (string.IsNullOrEmpty(projection)) + return leadingComa; + return $", {projection}{leadingComa}"; + } + ); + + IEnumerable data = await db.QueryAsync( + sql, + types.ToArray(), + items => + { + return mapIncludes(mapper(items), items.Skip(config.Count)); + }, + ParametersDictionary.LoadFrom(cmd), + splitOn: string.Join( + ',', + types.Select(x => x.GetCustomAttribute()?.Name ?? "id") + ) + ); + if (limit?.Reverse == true) + data = data.Reverse(); + return data.ToList(); + } + + public static async Task QuerySingle( + this IDbConnection db, + FormattableString command, + Dictionary config, + Func, T> mapper, + SqlVariableContext context, + Include? include, + Filter? filter, + Sort? sort = null, + bool reverse = false, + Guid? afterId = default + ) + where T : class, IResource, IQuery + { + ICollection ret = await db.Query( + command, + config, + mapper, + get: null!, + context, + include, + filter, + sort, + new Pagination(1, afterId, reverse) + ); + return ret.FirstOrDefault(); + } + + public static async Task Count( + this IDbConnection db, + FormattableString command, + Dictionary config, + SqlVariableContext context, + Filter? filter + ) + where T : class, IResource + { + SqlBuilder query = new(db, command); + + if (filter != null) + query += ProcessFilter(filter, config); + ProcessVariables(query, context); + IDapperSqlCommand cmd = query.Build(); + + // language=postgreSQL + string sql = $"select count(*) from ({cmd.Sql}) as query"; + + return await db.QuerySingleAsync(sql, ParametersDictionary.LoadFrom(cmd)); + } +} + +public class SqlVariableContext(IHttpContextAccessor accessor) +{ + public object? ReadVar(string var) + { + return var switch + { + "current_user" => accessor.HttpContext?.User.GetId(), + _ => throw new ArgumentException($"Invalid sql variable name: {var}") + }; + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs b/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs new file mode 100644 index 0000000..7c37d79 --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/DapperRepository.cs @@ -0,0 +1,221 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Abstractions.Models.Utils; + +namespace Kyoo.Core.Controllers; + +public abstract class DapperRepository : IRepository + where T : class, IResource, IQuery +{ + public Type RepositoryType => typeof(T); + + protected abstract FormattableString Sql { get; } + + protected abstract Dictionary Config { get; } + + protected abstract T Mapper(IList items); + + protected DbConnection Database { get; init; } + + protected SqlVariableContext Context { get; init; } + + public DapperRepository(DbConnection database, SqlVariableContext context) + { + Database = database; + Context = context; + } + + /// + public virtual async Task Get(Guid id, Include? include = default) + { + T? ret = await GetOrDefault(id, include); + if (ret == null) + throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); + return ret; + } + + /// + public virtual async Task Get(string slug, Include? include = default) + { + T? ret = await GetOrDefault(slug, include); + if (ret == null) + throw new ItemNotFoundException($"No {typeof(T).Name} found with the slug {slug}"); + return ret; + } + + /// + public virtual async Task Get( + Filter? filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ) + { + T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId); + if (ret == null) + throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate."); + return ret; + } + + /// + public async Task> FromIds(IList ids, Include? include = null) + { + if (!ids.Any()) + return Array.Empty(); + return ( + await Database.Query( + Sql, + Config, + Mapper, + (id) => Get(id), + Context, + include, + Filter.Or(ids.Select(x => new Filter.Eq("id", x)).ToArray()), + sort: null, + limit: null + ) + ) + .OrderBy(x => ids.IndexOf(x.Id)) + .ToList(); + } + + /// + public Task GetOrDefault(Guid id, Include? include = null) + { + return Database.QuerySingle( + Sql, + Config, + Mapper, + Context, + include, + new Filter.Eq(nameof(IResource.Id), id) + ); + } + + /// + public Task GetOrDefault(string slug, Include? include = null) + { + if (slug == "random") + { + return Database.QuerySingle( + Sql, + Config, + Mapper, + Context, + include, + filter: null, + new Sort.Random() + ); + } + return Database.QuerySingle( + Sql, + Config, + Mapper, + Context, + include, + new Filter.Eq(nameof(IResource.Slug), slug) + ); + } + + /// + public virtual Task GetOrDefault( + Filter? filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ) + { + return Database.QuerySingle( + Sql, + Config, + Mapper, + Context, + include, + filter, + sortBy, + reverse, + afterId + ); + } + + /// + public Task> GetAll( + Filter? filter = default, + Sort? sort = default, + Include? include = default, + Pagination? limit = default + ) + { + return Database.Query( + Sql, + Config, + Mapper, + (id) => Get(id), + Context, + include, + filter, + sort ?? new Sort.Default(), + limit ?? new() + ); + } + + /// + public Task GetCount(Filter? filter = null) + { + return Database.Count(Sql, Config, Context, filter); + } + + /// + public Task> Search(string query, Include? include = null) => + throw new NotImplementedException(); + + /// + public Task Create(T obj) => throw new NotImplementedException(); + + /// + public Task CreateIfNotExists(T obj) => throw new NotImplementedException(); + + /// + public Task Delete(Guid id) => throw new NotImplementedException(); + + /// + public Task Delete(string slug) => throw new NotImplementedException(); + + /// + public Task Delete(T obj) => throw new NotImplementedException(); + + /// + public Task DeleteAll(Filter filter) => throw new NotImplementedException(); + + /// + public Task Edit(T edited) => throw new NotImplementedException(); + + /// + public Task Patch(Guid id, Func patch) => throw new NotImplementedException(); +} diff --git a/src/Kyoo.Core/Controllers/Repositories/EfHelpers.cs b/src/Kyoo.Core/Controllers/Repositories/EfHelpers.cs new file mode 100644 index 0000000..76f0e73 --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/EfHelpers.cs @@ -0,0 +1,140 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Kyoo.Utils; + +namespace Kyoo.Core.Controllers; + +public static class EfHelpers +{ + public static Expression> ToEfLambda(this Filter? filter) + { + if (filter == null) + return x => true; + + ParameterExpression x = Expression.Parameter(typeof(T), "x"); + + Expression CmpRandomHandler(string cmp, string seed, Guid refId) + { + MethodInfo concat = typeof(string).GetMethod( + nameof(string.Concat), + new[] { typeof(string), typeof(string) } + )!; + Expression id = Expression.Call( + Expression.Property(x, "ID"), + nameof(Guid.ToString), + null + ); + Expression xrng = Expression.Call(concat, Expression.Constant(seed), id); + Expression left = Expression.Call( + typeof(DatabaseContext), + nameof(DatabaseContext.MD5), + null, + xrng + ); + Expression right = Expression.Call( + typeof(DatabaseContext), + nameof(DatabaseContext.MD5), + null, + Expression.Constant($"{seed}{refId}") + ); + return cmp switch + { + "=" => Expression.Equal(left, right), + "<" => Expression.GreaterThan(left, right), + ">" => Expression.LessThan(left, right), + _ => throw new NotImplementedException() + }; + } + + BinaryExpression StringCompatibleExpression( + Func operand, + string property, + object value + ) + { + var left = Expression.Property(x, property); + var right = Expression.Constant(value, ((PropertyInfo)left.Member).PropertyType); + if (left.Type != typeof(string)) + return operand(left, right); + MethodCallExpression call = Expression.Call( + typeof(string), + "Compare", + null, + left, + right + ); + return operand(call, Expression.Constant(0)); + } + + Expression Exp( + Func operand, + string property, + object? value + ) + { + var prop = Expression.Property(x, property); + var val = Expression.Constant(value, ((PropertyInfo)prop.Member).PropertyType); + return operand(prop, val); + } + + Expression Parse(Filter f) + { + return f switch + { + Filter.And(var first, var second) + => Expression.AndAlso(Parse(first), Parse(second)), + Filter.Or(var first, var second) + => Expression.OrElse(Parse(first), Parse(second)), + Filter.Not(var inner) => Expression.Not(Parse(inner)), + Filter.Eq(var property, var value) => Exp(Expression.Equal, property, value), + Filter.Ne(var property, var value) => Exp(Expression.NotEqual, property, value), + Filter.Gt(var property, var value) + => StringCompatibleExpression(Expression.GreaterThan, property, value), + Filter.Ge(var property, var value) + => StringCompatibleExpression(Expression.GreaterThanOrEqual, property, value), + Filter.Lt(var property, var value) + => StringCompatibleExpression(Expression.LessThan, property, value), + Filter.Le(var property, var value) + => StringCompatibleExpression(Expression.LessThanOrEqual, property, value), + Filter.Has(var property, var value) + => Expression.Call( + typeof(Enumerable), + "Contains", + new[] { value.GetType() }, + Expression.Property(x, property), + Expression.Constant(value) + ), + Filter.CmpRandom(var op, var seed, var refId) + => CmpRandomHandler(op, seed, refId), + Filter.Lambda(var lambda) + => ExpressionArgumentReplacer.ReplaceParams(lambda.Body, lambda.Parameters, x), + _ => throw new NotImplementedException(), + }; + } + + Expression body = Parse(filter); + return Expression.Lambda>(body, x); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/EpisodeRepository.cs b/src/Kyoo.Core/Controllers/Repositories/EpisodeRepository.cs new file mode 100644 index 0000000..500972f --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/EpisodeRepository.cs @@ -0,0 +1,140 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle episodes. +/// +public class EpisodeRepository( + DatabaseContext database, + IRepository shows, + IThumbnailsManager thumbnails +) : GenericRepository(database) +{ + static EpisodeRepository() + { + // Edit episode slugs when the show's slug changes. + IRepository.OnEdited += async (show) => + { + await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); + DatabaseContext database = scope.ServiceProvider.GetRequiredService(); + List episodes = await database + .Episodes.AsTracking() + .Where(x => x.ShowId == show.Id) + .ToListAsync(); + foreach (Episode ep in episodes) + { + ep.ShowSlug = show.Slug; + await database.SaveChangesAsync(); + await IRepository.OnResourceEdited(ep); + } + }; + } + + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(Database.Episodes, include) + .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) + .Take(20) + .ToListAsync(); + } + + /// + protected override async Task Validate(Episode resource) + { + await base.Validate(resource); + resource.Show = null; + if (resource.ShowId == Guid.Empty) + throw new ValidationException("Missing show id"); + // This is storred in db so it needs to be set before every create/edit (and before events) + resource.ShowSlug = (await shows.Get(resource.ShowId)).Slug; + + resource.Season = null; + if (resource.SeasonId == null && resource.SeasonNumber != null) + { + resource.SeasonId = await Database + .Seasons.Where(x => + x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber + ) + .Select(x => x.Id) + .FirstOrDefaultAsync(); + } + + resource.NextMetadataRefresh ??= IRefreshable.ComputeNextRefreshDate( + resource.ReleaseDate ?? DateOnly.FromDateTime(resource.AddedDate) + ); + await thumbnails.DownloadImages(resource); + } + + /// + public override async Task Delete(Episode obj) + { + int epCount = await Database + .Episodes.Where(x => x.ShowId == obj.ShowId) + .Take(2) + .CountAsync(); + if (epCount == 1) + await shows.Delete(obj.ShowId); + else + await base.Delete(obj); + } + + /// + public override async Task DeleteAll(Filter filter) + { + ICollection items = await GetAll(filter); + Guid[] ids = items.Select(x => x.Id).ToArray(); + + await Database.Set().Where(x => ids.Contains(x.Id)).ExecuteDeleteAsync(); + foreach (Episode resource in items) + await IRepository.OnResourceDeleted(resource); + + Guid[] showIds = await Database + .Set() + .Where(filter.ToEfLambda()) + .Select(x => x.Show!) + .Where(x => !x.Episodes!.Any()) + .Select(x => x.Id) + .ToArrayAsync(); + + if (!showIds.Any()) + return; + + Filter[] showFilters = showIds + .Select(x => new Filter.Eq(nameof(Show.Id), x)) + .ToArray(); + await shows.DeleteAll(Filter.Or(showFilters)!); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/GenericRepository.cs b/src/Kyoo.Core/Controllers/Repositories/GenericRepository.cs new file mode 100644 index 0000000..36019ff --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/GenericRepository.cs @@ -0,0 +1,369 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Core.Controllers; + +public abstract class GenericRepository(DatabaseContext database) : IRepository + where T : class, IResource, IQuery +{ + public DatabaseContext Database => database; + + /// + public Type RepositoryType => typeof(T); + + /// + /// Sort the given query. + /// + /// The query to sort. + /// How to sort the query. + /// The newly sorted query. + protected IOrderedQueryable Sort(IQueryable query, Sort? sortBy) + { + sortBy ??= new Sort.Default(); + + IOrderedQueryable _SortBy( + IQueryable qr, + Expression> sort, + bool desc, + bool then + ) + { + if (then && qr is IOrderedQueryable qro) + { + return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort); + } + return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort); + } + + IOrderedQueryable _Sort(IQueryable query, Sort sortBy, bool then) + { + switch (sortBy) + { + case Sort.Default(var value): + return _Sort(query, value, then); + case Sort.By(var key, var desc): + return _SortBy(query, x => EF.Property(x, key), desc, then); + case Sort.Random(var seed): + // NOTE: To edit this, don't forget to edit the random handiling inside the KeysetPaginate function + return _SortBy( + query, + x => DatabaseContext.MD5(seed + x.Id.ToString()), + false, + then + ); + case Sort.Conglomerate(var sorts): + IOrderedQueryable nQuery = _Sort(query, sorts.First(), false); + foreach (Sort sort in sorts.Skip(1)) + nQuery = _Sort(nQuery, sort, true); + return nQuery; + default: + // The language should not require me to do this... + throw new SwitchExpressionException(); + } + } + return _Sort(query, sortBy, false).ThenBy(x => x.Id); + } + + protected IQueryable AddIncludes(IQueryable query, Include? include) + { + if (include == null) + return query; + foreach (string field in include.Fields) + query = query.Include(field); + return query; + } + + protected virtual async Task GetWithTracking(Guid id) + { + T? ret = await Database.Set().AsTracking().FirstOrDefaultAsync(x => x.Id == id); + if (ret == null) + throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); + return ret; + } + + /// + public virtual async Task Get(Guid id, Include? include = default) + { + T? ret = await GetOrDefault(id, include); + if (ret == null) + throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); + return ret; + } + + /// + public virtual async Task Get(string slug, Include? include = default) + { + T? ret = await GetOrDefault(slug, include); + if (ret == null) + throw new ItemNotFoundException($"No {typeof(T).Name} found with the slug {slug}"); + return ret; + } + + /// + public virtual async Task Get( + Filter filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ) + { + T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId); + if (ret == null) + throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate."); + return ret; + } + + /// + public virtual Task GetOrDefault(Guid id, Include? include = default) + { + return AddIncludes(Database.Set(), include).FirstOrDefaultAsync(x => x.Id == id); + } + + /// + public virtual Task GetOrDefault(string slug, Include? include = default) + { + if (slug == "random") + { + return AddIncludes(Database.Set(), include) + .OrderBy(x => EF.Functions.Random()) + .FirstOrDefaultAsync(); + } + return AddIncludes(Database.Set(), include).FirstOrDefaultAsync(x => x.Slug == slug); + } + + /// + public virtual async Task GetOrDefault( + Filter? filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ) + { + IQueryable query = await ApplyFilters( + Database.Set(), + filter, + sortBy, + new Pagination(1, afterId, reverse), + include + ); + return await query.FirstOrDefaultAsync(); + } + + /// + public virtual async Task> FromIds( + IList ids, + Include? include = default + ) + { + return ( + await AddIncludes(Database.Set(), include) + .Where(x => ids.Contains(x.Id)) + .ToListAsync() + ) + .OrderBy(x => ids.IndexOf(x.Id)) + .ToList(); + } + + /// + public abstract Task> Search(string query, Include? include = default); + + /// + public virtual async Task> GetAll( + Filter? filter = null, + Sort? sort = default, + Include? include = default, + Pagination? limit = default + ) + { + IQueryable query = await ApplyFilters(Database.Set(), filter, sort, limit, include); + return await query.ToListAsync(); + } + + /// + /// Apply filters to a query to ease sort, pagination and where queries for resources of this repository + /// + /// The base query to filter. + /// An expression to filter based on arbitrary conditions + /// The sort settings (sort order and sort by) + /// Pagination information (where to start and how many to get) + /// Related fields to also load with this query. + /// The filtered query + protected async Task> ApplyFilters( + IQueryable query, + Filter? filter = null, + Sort? sort = default, + Pagination? limit = default, + Include? include = default + ) + { + query = AddIncludes(query, include); + query = Sort(query, sort); + limit ??= new(); + + if (limit.AfterID != null) + { + T reference = await Get(limit.AfterID.Value); + Filter? keysetFilter = RepositoryHelper.KeysetPaginate( + sort, + reference, + !limit.Reverse + ); + filter = Filter.And(filter, keysetFilter); + } + if (filter != null) + query = query.Where(filter.ToEfLambda()); + + if (limit.Reverse) + query = query.Reverse(); + if (limit.Limit > 0) + query = query.Take(limit.Limit); + if (limit.Reverse) + query = query.Reverse(); + + return query; + } + + /// + public virtual Task GetCount(Filter? filter = null) + { + IQueryable query = Database.Set(); + if (filter != null) + query = query.Where(filter.ToEfLambda()); + return query.CountAsync(); + } + + /// + public virtual async Task Create(T obj) + { + await Validate(obj); + Database.Add(obj); + await Database.SaveChangesAsync(() => Get(obj.Slug)); + await IRepository.OnResourceCreated(obj); + return obj; + } + + /// + public virtual async Task CreateIfNotExists(T obj) + { + try + { + T? old = await GetOrDefault(obj.Slug); + if (old != null) + return old; + + return await Create(obj); + } + catch (DuplicatedItemException) + { + return await Get(obj.Slug); + } + } + + /// + public virtual async Task Edit(T edited) + { + await Validate(edited); + Database.Update(edited); + if (edited is IAddedDate date) + Database.Entry(date).Property(p => p.AddedDate).IsModified = false; + await Database.SaveChangesAsync(); + await IRepository.OnResourceEdited(edited); + return edited; + } + + /// + public virtual async Task Patch(Guid id, Func patch) + { + T resource = await GetWithTracking(id); + + resource = patch(resource); + if (resource is IAddedDate date) + Database.Entry(date).Property(p => p.AddedDate).IsModified = false; + + await Database.SaveChangesAsync(); + await IRepository.OnResourceEdited(resource); + return resource; + } + + /// + /// You can throw this if the resource is illegal and should not be saved. + /// + protected virtual Task Validate(T resource) + { + if ( + typeof(T).GetProperty(nameof(resource.Slug))!.GetCustomAttribute() + != null + ) + return Task.CompletedTask; + if (string.IsNullOrEmpty(resource.Slug)) + throw new ValidationException("Resource can't have null as a slug."); + if (resource.Slug == "random") + throw new ValidationException("Resources slug can't be the literal \"random\"."); + return Task.CompletedTask; + } + + /// + public virtual async Task Delete(Guid id) + { + T resource = await Get(id); + await Delete(resource); + } + + /// + public virtual async Task Delete(string slug) + { + T resource = await Get(slug); + await Delete(resource); + } + + /// + public virtual async Task Delete(T obj) + { + await Database.Set().Where(x => x.Id == obj.Id).ExecuteDeleteAsync(); + await IRepository.OnResourceDeleted(obj); + } + + /// + public virtual async Task DeleteAll(Filter filter) + { + ICollection items = await GetAll(filter); + Guid[] ids = items.Select(x => x.Id).ToArray(); + await Database.Set().Where(x => ids.Contains(x.Id)).ExecuteDeleteAsync(); + + foreach (T resource in items) + await IRepository.OnResourceDeleted(resource); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/IssueRepository.cs b/src/Kyoo.Core/Controllers/Repositories/IssueRepository.cs new file mode 100644 index 0000000..f4cb64a --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/IssueRepository.cs @@ -0,0 +1,54 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Core.Controllers; + +public class IssueRepository(DatabaseContext database) : IIssueRepository +{ + public async Task> GetAll(Filter? filter = null) + { + return await database.Issues.Where(filter.ToEfLambda()).ToListAsync(); + } + + public Task GetCount(Filter? filter = null) + { + return database.Issues.Where(filter.ToEfLambda()).CountAsync(); + } + + public async Task Upsert(Issue issue) + { + issue.AddedDate = DateTime.UtcNow; + await database.Issues.Upsert(issue).RunAsync(); + return issue; + } + + public Task DeleteAll(Filter? filter = null) + { + return database.Issues.Where(filter.ToEfLambda()).ExecuteDeleteAsync(); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs b/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs new file mode 100644 index 0000000..27d6e84 --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs @@ -0,0 +1,124 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; + +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle library items. +/// +public class LibraryItemRepository(DbConnection database, SqlVariableContext context) + : DapperRepository(database, context) +{ + // language=PostgreSQL + protected override FormattableString Sql => + $""" + select + s.*, -- Show as s + m.*, + c.* + /* includes */ + from + shows as s + full outer join ( + select + * -- Movie + from + movies) as m on false + full outer join( + select + c.* -- Collection as c + from + collections as c + left join link_collection_show as ls on ls.collection_id = c.id + left join link_collection_movie as lm on lm.collection_id = c.id + group by c.id + having count(*) > 1 + ) as c on false + """; + + protected override Dictionary Config => + new() + { + { "s", typeof(Show) }, + { "m", typeof(Movie) }, + { "c", typeof(Collection) } + }; + + protected override ILibraryItem Mapper(IList items) + { + if (items[0] is Show show && show.Id != Guid.Empty) + return show; + if (items[1] is Movie movie && movie.Id != Guid.Empty) + return movie; + if (items[2] is Collection collection && collection.Id != Guid.Empty) + return collection; + throw new InvalidDataException(); + } + + public async Task> GetAllOfCollection( + Guid collectionId, + Filter? filter = default, + Sort? sort = default, + Include? include = default, + Pagination? limit = default + ) + { + // language=PostgreSQL + FormattableString sql = $""" + select + s.*, + m.* + /* includes */ + from ( + select + * -- Show + from + shows + inner join link_collection_show as ls on ls.show_id = id and ls.collection_id = {collectionId} + ) as s + full outer join ( + select + * -- Movie + from + movies + inner join link_collection_movie as lm on lm.movie_id = id and lm.collection_id = {collectionId} + ) as m on false + """; + + return await Database.Query( + sql, + new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, }, + Mapper, + (id) => Get(id), + Context, + include, + filter, + sort ?? new Sort.Default(), + limit ?? new() + ); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/MovieRepository.cs b/src/Kyoo.Core/Controllers/Repositories/MovieRepository.cs new file mode 100644 index 0000000..e33501e --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/MovieRepository.cs @@ -0,0 +1,83 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Core.Controllers; + +public class MovieRepository( + DatabaseContext database, + IRepository studios, + IThumbnailsManager thumbnails +) : GenericRepository(database) +{ + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(Database.Movies, include) + .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) + .Take(20) + .ToListAsync(); + } + + /// + public override Task Create(Movie obj) + { + try + { + return base.Create(obj); + } + catch (DuplicatedItemException ex) + when (ex.Existing is Movie existing + && existing.Slug == obj.Slug + && obj.AirDate is not null + && existing.AirDate?.Year != obj.AirDate?.Year + ) + { + obj.Slug = $"{obj.Slug}-{obj.AirDate!.Value.Year}"; + return base.Create(obj); + } + } + + /// + protected override async Task Validate(Movie resource) + { + await base.Validate(resource); + if (resource.Studio != null) + { + resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id; + resource.Studio = null; + } + resource.NextMetadataRefresh ??= IRefreshable.ComputeNextRefreshDate( + resource.AirDate ?? DateOnly.FromDateTime(resource.AddedDate) + ); + await thumbnails.DownloadImages(resource); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs b/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs new file mode 100644 index 0000000..c91c2ee --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs @@ -0,0 +1,63 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using Kyoo.Abstractions.Models; + +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle shows +/// +public class NewsRepository : DapperRepository +{ + // language=PostgreSQL + protected override FormattableString Sql => + $""" + select + e.*, -- Episode as e + m.* + /* includes */ + from + episodes as e + full outer join ( + select + * -- Movie + from + movies + ) as m on false + """; + + protected override Dictionary Config => + new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, }; + + protected override INews Mapper(IList items) + { + if (items[0] is Episode episode && episode.Id != Guid.Empty) + return episode; + if (items[1] is Movie movie && movie.Id != Guid.Empty) + return movie; + throw new InvalidDataException(); + } + + public NewsRepository(DbConnection database, SqlVariableContext context) + : base(database, context) { } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/RepositoryHelper.cs b/src/Kyoo.Core/Controllers/Repositories/RepositoryHelper.cs new file mode 100644 index 0000000..cebb0c9 --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/RepositoryHelper.cs @@ -0,0 +1,128 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Utils; + +namespace Kyoo.Core; + +public class RepositoryHelper +{ + private record SortIndicator(string Key, bool Desc, string? Seed); + + /// + /// Create a filter (where) expression on the query to skip everything before/after the referenceID. + /// The generalized expression for this in pseudocode is: + /// (x > a) OR + /// (x = a AND y > b) OR + /// (x = a AND y = b AND z > c) OR... + /// + /// Of course, this will be a bit more complex when ASC and DESC are mixed. + /// Assume x is ASC, y is DESC, and z is ASC: + /// (x > a) OR + /// (x = a AND y < b) OR + /// (x = a AND y = b AND z > c) OR... + /// + /// How items are sorted in the query + /// The reference item (the AfterID query) + /// True if the following page should be returned, false for the previous. + /// The type to paginate for. + /// An expression ready to be added to a Where close of a sorted query to handle the AfterID + public static Filter? KeysetPaginate(Sort? sort, T reference, bool next = true) + where T : class, IResource, IQuery + { + sort ??= new Sort.Default(); + + static IEnumerable GetSortsBy(Sort sort) + { + return sort switch + { + Sort.Default(var value) => GetSortsBy(value), + Sort.By @sortBy + => new[] { new SortIndicator(sortBy.Key, sortBy.Desendant, null) }, + Sort.Conglomerate(var list) => list.SelectMany(GetSortsBy), + Sort.Random(var seed) + => new[] { new SortIndicator("random", false, seed.ToString()) }, + _ => Array.Empty(), + }; + } + + // Don't forget that every sorts must end with a ID sort (to differentiate equalities). + IEnumerable sorts = GetSortsBy(sort) + .Append(new SortIndicator("Id", false, null)); + + Filter? ret = null; + List previousSteps = new(); + // TODO: Add an outer query >= for perf + // PERF: See https://use-the-index-luke.com/sql/partial-results/fetch-next-page#sb-equivalent-logic + foreach ((string key, bool desc, string? seed) in sorts) + { + object? value = reference.GetType().GetProperty(key)?.GetValue(reference); + // Comparing a value with null always return false so we short opt < > comparisons with null. + if (key != "random" && value == null) + { + previousSteps.Add(new SortIndicator(key, desc, seed)); + continue; + } + + // Create all the equality statements for previous sorts. + Filter? equals = null; + foreach ((string pKey, bool pDesc, string? pSeed) in previousSteps) + { + Filter pEquals = + pSeed == null + ? new Filter.Eq( + pKey, + reference.GetType().GetProperty(pKey)?.GetValue(reference) + ) + : new Filter.CmpRandom("=", pSeed, reference.Id); + equals = Filter.And(equals, pEquals); + } + + bool greaterThan = desc ^ next; + Func> comparer = greaterThan + ? (prop, val) => new Filter.Gt(prop, val) + : (prop, val) => new Filter.Lt(prop, val); + Filter last = + seed == null + ? comparer(key, value!) + : new Filter.CmpRandom(greaterThan ? ">" : "<", seed, reference.Id); + + if (key != "random") + { + Type[] types = + typeof(T).GetCustomAttribute()?.Types ?? new[] { typeof(T) }; + PropertyInfo property = types + .Select(x => x.GetProperty(key)!) + .First(x => x != null); + if (Nullable.GetUnderlyingType(property.PropertyType) != null) + last = new Filter.Or(last, new Filter.Eq(key, null)); + } + + ret = Filter.Or(ret, Filter.And(equals, last)); + previousSteps.Add(new SortIndicator(key, desc, seed)); + } + return ret; + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/SeasonRepository.cs b/src/Kyoo.Core/Controllers/Repositories/SeasonRepository.cs new file mode 100644 index 0000000..a7f8233 --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/SeasonRepository.cs @@ -0,0 +1,85 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Kyoo.Core.Controllers; + +public class SeasonRepository( + DatabaseContext database, + IRepository shows, + IThumbnailsManager thumbnails +) : GenericRepository(database) +{ + static SeasonRepository() + { + // Edit seasons slugs when the show's slug changes. + IRepository.OnEdited += async (show) => + { + await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); + DatabaseContext database = scope.ServiceProvider.GetRequiredService(); + List seasons = await database + .Seasons.AsTracking() + .Where(x => x.ShowId == show.Id) + .ToListAsync(); + foreach (Season season in seasons) + { + season.ShowSlug = show.Slug; + await database.SaveChangesAsync(); + await IRepository.OnResourceEdited(season); + } + }; + } + + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(Database.Seasons, include) + .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) + .Take(20) + .ToListAsync(); + } + + /// + protected override async Task Validate(Season resource) + { + await base.Validate(resource); + resource.Show = null; + if (resource.ShowId == Guid.Empty) + throw new ValidationException("Missing show id"); + // This is storred in db so it needs to be set before every create/edit (and before events) + resource.ShowSlug = (await shows.Get(resource.ShowId)).Slug; + resource.NextMetadataRefresh ??= IRefreshable.ComputeNextRefreshDate( + resource.StartDate ?? DateOnly.FromDateTime(resource.AddedDate) + ); + await thumbnails.DownloadImages(resource); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/ShowRepository.cs b/src/Kyoo.Core/Controllers/Repositories/ShowRepository.cs new file mode 100644 index 0000000..51c9f76 --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/ShowRepository.cs @@ -0,0 +1,83 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Core.Controllers; + +public class ShowRepository( + DatabaseContext database, + IRepository studios, + IThumbnailsManager thumbnails +) : GenericRepository(database) +{ + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(Database.Shows, include) + .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) + .Take(20) + .ToListAsync(); + } + + /// + public override Task Create(Show obj) + { + try + { + return base.Create(obj); + } + catch (DuplicatedItemException ex) + when (ex.Existing is Show existing + && existing.Slug == obj.Slug + && obj.StartAir is not null + && existing.StartAir?.Year != obj.StartAir?.Year + ) + { + obj.Slug = $"{obj.Slug}-{obj.AirDate!.Value.Year}"; + return base.Create(obj); + } + } + + /// + protected override async Task Validate(Show resource) + { + await base.Validate(resource); + if (resource.Studio != null) + { + resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id; + resource.Studio = null; + } + resource.NextMetadataRefresh ??= IRefreshable.ComputeNextRefreshDate( + resource.StartAir ?? DateOnly.FromDateTime(resource.AddedDate) + ); + await thumbnails.DownloadImages(resource); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/StudioRepository.cs b/src/Kyoo.Core/Controllers/Repositories/StudioRepository.cs new file mode 100644 index 0000000..91aba67 --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/StudioRepository.cs @@ -0,0 +1,45 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle studios +/// +public class StudioRepository(DatabaseContext database) : GenericRepository(database) +{ + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(Database.Studios, include) + .Where(x => EF.Functions.ILike(x.Name, $"%{query}%")) + .Take(20) + .ToListAsync(); + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/UserRepository.cs b/src/Kyoo.Core/Controllers/Repositories/UserRepository.cs new file mode 100644 index 0000000..c98d8eb --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/UserRepository.cs @@ -0,0 +1,113 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Core.Controllers; + +/// +/// A repository for users. +/// +/// +/// Create a new +/// +public class UserRepository( + DatabaseContext database, + DbConnection db, + SqlVariableContext context, + PermissionOption options +) : GenericRepository(database), IUserRepository +{ + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(Database.Users, include) + .Where(x => EF.Functions.ILike(x.Username, $"%{query}%")) + .Take(20) + .ToListAsync(); + } + + /// + public override async Task Create(User obj) + { + // If no users exists, the new one will be an admin. Give it every permissions. + if (!await Database.Users.AnyAsync()) + obj.Permissions = PermissionOption.Admin; + else if (!options.RequireVerification) + obj.Permissions = options.NewUser; + else + obj.Permissions = Array.Empty(); + + return await base.Create(obj); + } + + public Task GetByExternalId(string provider, string id) + { + // language=PostgreSQL + return db.QuerySingle( + $""" + select + u.* -- User as u + /* includes */ + from + users as u + where + u.external_id->{provider}->>'Id' = {id} + """, + new() { ["u"] = typeof(User) }, + (items) => (items[0] as User)!, + context, + null, + null, + null + ); + } + + public async Task AddExternalToken(Guid userId, string provider, ExternalToken token) + { + User user = await GetWithTracking(userId); + user.ExternalId[provider] = token; + // without that, the change tracker does not find the modification. /shrug + Database.Entry(user).Property(x => x.ExternalId).IsModified = true; + await Database.SaveChangesAsync(); + return user; + } + + public async Task DeleteExternalToken(Guid userId, string provider) + { + User user = await GetWithTracking(userId); + user.ExternalId.Remove(provider); + // without that, the change tracker does not find the modification. /shrug + Database.Entry(user).Property(x => x.ExternalId).IsModified = true; + await Database.SaveChangesAsync(); + return user; + } +} diff --git a/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs b/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs new file mode 100644 index 0000000..3159cbf --- /dev/null +++ b/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs @@ -0,0 +1,564 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Data.Common; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Kyoo.Core.Controllers; + +public class WatchStatusRepository( + DatabaseContext database, + IRepository movies, + IRepository shows, + IRepository episodes, + IRepository users, + DbConnection db, + SqlVariableContext context +) : IWatchStatusRepository +{ + /// + /// If the watch percent is below this value, don't consider the item started. + /// + public const int MinWatchPercent = 5; + + /// + /// If the watch percent is higher than this value, consider the item completed. + /// + /// + /// This value is lower to account credits in movies that can last really long. + /// + public const int MaxWatchPercent = 90; + + // Those two are defined here because x => WatchingStatus.Watching complies to x => 1 + // but x => Watching compiles to x => Convert.ToInt(WatchingStatus.Watching) + // The second one can be converted to sql wherase the first can't (tries to compare WatchStatus with int). + private WatchStatus Watching = WatchStatus.Watching; + private WatchStatus Completed = WatchStatus.Completed; + private WatchStatus Planned = WatchStatus.Planned; + + static WatchStatusRepository() + { + IRepository.OnCreated += async (ep) => + { + await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); + DatabaseContext db = scope.ServiceProvider.GetRequiredService(); + WatchStatusRepository repo = + scope.ServiceProvider.GetRequiredService(); + List users = await db + .ShowWatchStatus.IgnoreQueryFilters() + .Where(x => x.ShowId == ep.ShowId && x.Status == WatchStatus.Completed) + .Select(x => x.UserId) + .ToListAsync(); + foreach (Guid userId in users) + await repo._SetShowStatus( + ep.ShowId, + userId, + WatchStatus.Watching, + newEpisode: true + ); + }; + } + + // language=PostgreSQL + protected FormattableString Sql => + $""" + select + s.*, + swe.*, -- Episode as swe + m.* + /* includes */ + from ( + select + s.*, -- Show as s + sw.*, + sw.added_date as order, + sw.status as watch_status + from + shows as s + inner join show_watch_status as sw on sw.show_id = s.id + and sw.user_id = [current_user]) as s + full outer join ( + select + m.*, -- Movie as m + mw.*, + mw.added_date as order, + mw.status as watch_status + from + movies as m + inner join movie_watch_status as mw on mw.movie_id = m.id + and mw.user_id = [current_user]) as m on false + left join episodes as swe on swe.id = s.next_episode_id + /* includesJoin */ + where + (coalesce(s.watch_status, m.watch_status) = 'watching'::watch_status + or coalesce(s.watch_status, m.watch_status) = 'planned'::watch_status) + /* where */ + order by + coalesce(s.order, m.order) desc, + coalesce(s.id, m.id) asc + """; + + protected Dictionary Config => + new() + { + { "s", typeof(Show) }, + { "_sw", typeof(ShowWatchStatus) }, + { "_swe", typeof(Episode) }, + { "m", typeof(Movie) }, + { "_mw", typeof(MovieWatchStatus) }, + }; + + protected IWatchlist Mapper(IList items) + { + if (items[0] is Show show && show.Id != Guid.Empty) + { + show.WatchStatus = items[1] as ShowWatchStatus; + if (show.WatchStatus != null) + show.WatchStatus.NextEpisode = items[2] as Episode; + return show; + } + if (items[3] is Movie movie && movie.Id != Guid.Empty) + { + movie.WatchStatus = items[4] as MovieWatchStatus; + return movie; + } + throw new InvalidDataException(); + } + + /// + public virtual async Task Get(Guid id, Include? include = default) + { + IWatchlist? ret = await GetOrDefault(id, include); + if (ret == null) + throw new ItemNotFoundException($"No {nameof(IWatchlist)} found with the id {id}"); + return ret; + } + + /// + public Task GetOrDefault(Guid id, Include? include = null) + { + return db.QuerySingle( + Sql, + Config, + Mapper, + context, + include, + new Filter.Eq(nameof(IResource.Id), id) + ); + } + + /// + public async Task> GetAll( + Filter? filter = default, + Include? include = default, + Pagination? limit = default + ) + { + if (include != null) + include.Metadatas = include + .Metadatas.Where(x => x.Name != nameof(Show.WatchStatus)) + .ToList(); + + // We can't use the generic after id hanler since the sort depends on a relation. + if (limit?.AfterID != null) + { + dynamic cursor = await Get(limit.AfterID.Value); + filter = Filter.And( + filter, + Filter.Or( + new Filter.Lt("order", cursor.WatchStatus.AddedDate), + Filter.And( + new Filter.Eq("order", cursor.WatchStatus.AddedDate), + new Filter.Gt("Id", cursor.Id) + ) + ) + ); + limit.AfterID = null; + } + + return await db.Query( + Sql, + Config, + Mapper, + (id) => Get(id), + context, + include, + filter, + null, + limit ?? new() + ); + } + + /// + public Task GetMovieStatus(Guid movieId, Guid userId) + { + return database.MovieWatchStatus.FirstOrDefaultAsync(x => + x.MovieId == movieId && x.UserId == userId + ); + } + + /// + public async Task SetMovieStatus( + Guid movieId, + Guid userId, + WatchStatus status, + int? watchedTime, + int? percent + ) + { + 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); + + if (percent < MinWatchPercent) + return null; + if (percent > MaxWatchPercent) + { + status = WatchStatus.Completed; + watchedTime = null; + percent = null; + } + + if (watchedTime.HasValue && status != WatchStatus.Watching) + throw new ValidationException( + "Can't have a watched time if the status is not watching." + ); + + if (watchedTime.HasValue != percent.HasValue) + throw new ValidationException( + "Can't specify watched time without specifing percent (or vise-versa)." + + "Percent could not be guessed since duration is unknown." + ); + + MovieWatchStatus ret = + new() + { + UserId = userId, + MovieId = movieId, + Status = status, + WatchedTime = watchedTime, + WatchedPercent = percent, + AddedDate = DateTime.UtcNow, + PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, + }; + await database + .MovieWatchStatus.Upsert(ret) + .UpdateIf(x => status != Watching || x.Status != Completed) + .RunAsync(); + await IWatchStatusRepository.OnMovieStatusChanged( + new() + { + User = await users.Get(ret.UserId), + Resource = await movies.Get(ret.MovieId), + Status = ret.Status, + WatchedTime = ret.WatchedTime, + WatchedPercent = ret.WatchedPercent, + AddedDate = ret.AddedDate, + PlayedDate = ret.PlayedDate, + } + ); + return ret; + } + + /// + public async Task DeleteMovieStatus(Guid movieId, Guid userId) + { + await database + .MovieWatchStatus.Where(x => x.MovieId == movieId && x.UserId == userId) + .ExecuteDeleteAsync(); + await IWatchStatusRepository.OnMovieStatusChanged( + new() + { + User = await users.Get(userId), + Resource = await movies.Get(movieId), + AddedDate = DateTime.UtcNow, + Status = WatchStatus.Deleted, + } + ); + } + + /// + public Task GetShowStatus(Guid showId, Guid userId) + { + return database.ShowWatchStatus.FirstOrDefaultAsync(x => + x.ShowId == showId && x.UserId == userId + ); + } + + /// + public Task SetShowStatus(Guid showId, Guid userId, WatchStatus status) => + _SetShowStatus(showId, userId, status); + + private async Task _SetShowStatus( + Guid showId, + Guid userId, + WatchStatus status, + bool newEpisode = false, + bool skipStatusUpdate = false + ) + { + int unseenEpisodeCount = + status != WatchStatus.Completed + ? await database + .Episodes.Where(x => x.ShowId == showId) + .Where(x => + x.Watched!.First(x => x.UserId == userId)!.Status != WatchStatus.Completed + ) + .CountAsync() + : 0; + if (unseenEpisodeCount == 0) + status = WatchStatus.Completed; + + EpisodeWatchStatus? cursorWatchStatus = null; + Guid? nextEpisodeId = null; + if (status == WatchStatus.Watching) + { + var cursor = await database + .Episodes.IgnoreQueryFilters() + .Where(x => x.ShowId == showId) + .OrderByDescending(x => x.AbsoluteNumber) + .ThenByDescending(x => x.SeasonNumber) + .ThenByDescending(x => x.EpisodeNumber) + .Select(x => new + { + x.Id, + x.AbsoluteNumber, + x.SeasonNumber, + x.EpisodeNumber, + Status = x.Watched!.First(x => x.UserId == userId) + }) + .FirstOrDefaultAsync(x => + x.Status.Status == WatchStatus.Completed + || x.Status.Status == WatchStatus.Watching + ); + cursorWatchStatus = cursor?.Status; + nextEpisodeId = + cursor?.Status.Status == WatchStatus.Watching + ? cursor.Id + : await database + .Episodes.IgnoreQueryFilters() + .Where(x => x.ShowId == showId) + .OrderBy(x => x.AbsoluteNumber) + .ThenBy(x => x.SeasonNumber) + .ThenBy(x => x.EpisodeNumber) + .Where(x => + cursor == null + || x.AbsoluteNumber > cursor.AbsoluteNumber + || x.SeasonNumber > cursor.SeasonNumber + || ( + x.SeasonNumber == cursor.SeasonNumber + && x.EpisodeNumber > cursor.EpisodeNumber + ) + ) + .Select(x => new + { + x.Id, + Status = x.Watched!.FirstOrDefault(x => x.UserId == userId) + }) + .Where(x => x.Status == null || x.Status.Status != WatchStatus.Completed) + // The as Guid? is here to add the nullability status of the queryable. + // Without this, FirstOrDefault returns new Guid() when no result is found (which is 16 0s and invalid in sql). + .Select(x => x.Id as Guid?) + .FirstOrDefaultAsync(); + } + else if (status == WatchStatus.Completed) + { + List episodes = await database + .Episodes.Where(x => x.ShowId == showId) + .Select(x => x.Id) + .ToListAsync(); + await database + .EpisodeWatchStatus.UpsertRange( + episodes.Select(episodeId => new EpisodeWatchStatus + { + UserId = userId, + EpisodeId = episodeId, + Status = WatchStatus.Completed, + AddedDate = DateTime.UtcNow, + PlayedDate = DateTime.UtcNow + }) + ) + .UpdateIf(x => x.Status == Watching || x.Status == Planned) + .RunAsync(); + } + + ShowWatchStatus ret = + new() + { + UserId = userId, + ShowId = showId, + Status = status, + AddedDate = DateTime.UtcNow, + NextEpisodeId = nextEpisodeId, + WatchedTime = + cursorWatchStatus?.Status == WatchStatus.Watching + ? cursorWatchStatus.WatchedTime + : null, + WatchedPercent = + cursorWatchStatus?.Status == WatchStatus.Watching + ? cursorWatchStatus.WatchedPercent + : null, + UnseenEpisodesCount = unseenEpisodeCount, + PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, + }; + await database + .ShowWatchStatus.Upsert(ret) + .UpdateIf(x => status != Watching || x.Status != Completed || newEpisode) + .RunAsync(); + if (!skipStatusUpdate) + { + await IWatchStatusRepository.OnShowStatusChanged( + new() + { + User = await users.Get(ret.UserId), + Resource = await shows.Get(ret.ShowId), + Status = ret.Status, + WatchedTime = ret.WatchedTime, + WatchedPercent = ret.WatchedPercent, + AddedDate = ret.AddedDate, + PlayedDate = ret.PlayedDate, + } + ); + } + return ret; + } + + /// + public async Task DeleteShowStatus(Guid showId, Guid userId) + { + await database + .ShowWatchStatus.IgnoreAutoIncludes() + .Where(x => x.ShowId == showId && x.UserId == userId) + .ExecuteDeleteAsync(); + await database + .EpisodeWatchStatus.Where(x => x.Episode.ShowId == showId && x.UserId == userId) + .ExecuteDeleteAsync(); + await IWatchStatusRepository.OnShowStatusChanged( + new() + { + User = await users.Get(userId), + Resource = await shows.Get(showId), + AddedDate = DateTime.UtcNow, + Status = WatchStatus.Deleted, + } + ); + } + + /// + public Task GetEpisodeStatus(Guid episodeId, Guid userId) + { + return database.EpisodeWatchStatus.FirstOrDefaultAsync(x => + x.EpisodeId == episodeId && x.UserId == userId + ); + } + + /// + public async Task SetEpisodeStatus( + Guid episodeId, + Guid userId, + WatchStatus status, + int? watchedTime, + int? percent + ) + { + Episode episode = await episodes.Get(episodeId); + + if (percent == null && watchedTime != null && episode.Runtime > 0) + percent = (int)Math.Round(watchedTime.Value / (episode.Runtime.Value * 60f) * 100f); + + if (percent < MinWatchPercent) + return null; + if (percent > MaxWatchPercent) + { + status = WatchStatus.Completed; + watchedTime = null; + percent = null; + } + + if (watchedTime.HasValue && status != WatchStatus.Watching) + throw new ValidationException( + "Can't have a watched time if the status is not watching." + ); + + if (watchedTime.HasValue != percent.HasValue) + throw new ValidationException( + "Can't specify watched time without specifing percent (or vise-versa)." + + "Percent could not be guessed since duration is unknown." + ); + + EpisodeWatchStatus ret = + new() + { + UserId = userId, + EpisodeId = episodeId, + Status = status, + WatchedTime = watchedTime, + WatchedPercent = percent, + AddedDate = DateTime.UtcNow, + PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, + }; + await database + .EpisodeWatchStatus.Upsert(ret) + .UpdateIf(x => status != Watching || x.Status != Completed) + .RunAsync(); + await IWatchStatusRepository.OnEpisodeStatusChanged( + new() + { + User = await users.Get(ret.UserId), + Resource = await episodes.Get(episodeId, new(nameof(Episode.Show))), + Status = ret.Status, + WatchedTime = ret.WatchedTime, + WatchedPercent = ret.WatchedPercent, + AddedDate = ret.AddedDate, + PlayedDate = ret.PlayedDate, + } + ); + await _SetShowStatus(episode.ShowId, userId, WatchStatus.Watching, skipStatusUpdate: true); + return ret; + } + + /// + public async Task DeleteEpisodeStatus(Guid episodeId, Guid userId) + { + await database + .EpisodeWatchStatus.Where(x => x.EpisodeId == episodeId && x.UserId == userId) + .ExecuteDeleteAsync(); + await IWatchStatusRepository.OnEpisodeStatusChanged( + new() + { + User = await users.Get(userId), + Resource = await episodes.Get(episodeId, new(nameof(Episode.Show))), + AddedDate = DateTime.UtcNow, + Status = WatchStatus.Deleted, + } + ); + } +} diff --git a/src/Kyoo.Core/Controllers/ThumbnailsManager.cs b/src/Kyoo.Core/Controllers/ThumbnailsManager.cs new file mode 100644 index 0000000..211452e --- /dev/null +++ b/src/Kyoo.Core/Controllers/ThumbnailsManager.cs @@ -0,0 +1,219 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Blurhash.SkiaSharp; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Core.Storage; +using Microsoft.Extensions.Logging; +using SkiaSharp; +using SKSvg = SkiaSharp.Extended.Svg.SKSvg; + +namespace Kyoo.Core.Controllers; + +/// +/// Download images and retrieve the path of those images for a resource. +/// +public class ThumbnailsManager( + IHttpClientFactory clientFactory, + ILogger logger, + IStorage storage, + Lazy> users +) : IThumbnailsManager +{ + private async Task _SaveImage(SKBitmap bitmap, string path, int quality) + { + SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality); + await using Stream reader = data.AsStream(); + await storage.Write(reader, path); + } + + private SKBitmap _SKBitmapFrom(Stream reader, bool isSvg) + { + if (isSvg) + { + SKSvg svg = new(); + svg.Load(reader); + SKBitmap bitmap = new((int)svg.CanvasSize.Width, (int)svg.CanvasSize.Height); + using SKCanvas canvas = new(bitmap); + canvas.DrawPicture(svg.Picture); + return bitmap; + } + + using SKCodec codec = SKCodec.Create(reader); + if (codec == null) + throw new NotSupportedException("Unsupported codec"); + + SKImageInfo info = codec.Info; + info.ColorType = SKColorType.Rgba8888; + return SKBitmap.Decode(codec, info); + } + + public async Task DownloadImage(Image? image, string what) + { + if (image == null) + return; + try + { + if (image.Id == Guid.Empty) + { + // Ensure stable ids to prevent duplicated images being stored on the fs. + using MD5 md5 = MD5.Create(); + image.Id = new Guid(md5.ComputeHash(Encoding.UTF8.GetBytes(image.Source))); + } + + logger.LogInformation("Downloading image {What}", what); + + HttpClient client = clientFactory.CreateClient(); + HttpResponseMessage response = await client.GetAsync(image.Source); + response.EnsureSuccessStatusCode(); + await using Stream reader = await response.Content.ReadAsStreamAsync(); + using SKBitmap original = _SKBitmapFrom( + reader, + isSvg: response.Content.Headers.ContentType?.MediaType?.Contains("svg") == true + ); + + using SKBitmap high = original.Resize( + new SKSizeI(original.Width, original.Height), + SKFilterQuality.High + ); + await _SaveImage(original, _GetImagePath(image.Id, ImageQuality.High), 90); + + using SKBitmap medium = high.Resize( + new SKSizeI((int)(high.Width / 1.5), (int)(high.Height / 1.5)), + SKFilterQuality.Medium + ); + await _SaveImage(medium, _GetImagePath(image.Id, ImageQuality.Medium), 75); + + using SKBitmap low = medium.Resize( + new SKSizeI(original.Width / 2, original.Height / 2), + SKFilterQuality.Low + ); + await _SaveImage(low, _GetImagePath(image.Id, ImageQuality.Low), 50); + + image.Blurhash = Blurhasher.Encode(low, 4, 3); + } + catch (Exception ex) + { + logger.LogError(ex, "{What} could not be downloaded", what); + } + } + + /// + public async Task DownloadImages(T item) + where T : IThumbnails + { + string name = item is IResource res ? res.Slug : "???"; + + await DownloadImage(item.Poster, $"The poster of {name}"); + await DownloadImage(item.Thumbnail, $"The thumbnail of {name}"); + await DownloadImage(item.Logo, $"The logo of {name}"); + } + + public async Task IsImageSaved(Guid imageId, ImageQuality quality) => + await storage.DoesExist(_GetImagePath(imageId, quality)); + + public async Task GetImage(Guid imageId, ImageQuality quality) + { + string path = _GetImagePath(imageId, quality); + if (await storage.DoesExist(path)) + return await storage.Read(path); + + throw new ItemNotFoundException(); + } + + /// + private string _GetImagePath(Guid imageId, ImageQuality quality) + { + return $"/metadata/{imageId}.{quality.ToString().ToLowerInvariant()}.webp"; + } + + /// + public Task DeleteImages(T item) + where T : IThumbnails + { + var imageDeletionTasks = new[] { item.Poster?.Id, item.Thumbnail?.Id, item.Logo?.Id } + .Where(x => x is not null) + .SelectMany(x => $"/metadata/{x}") + .SelectMany(x => + new[] + { + ImageQuality.High.ToString().ToLowerInvariant(), + ImageQuality.Medium.ToString().ToLowerInvariant(), + ImageQuality.Low.ToString().ToLowerInvariant(), + }.Select(quality => $"{x}.{quality}.webp") + ) + .Select(storage.Delete); + + return Task.WhenAll(imageDeletionTasks); + } + + public async Task GetUserImage(Guid userId) + { + var filePath = $"/metadata/user/{userId}.webp"; + if (await storage.DoesExist(filePath)) + return await storage.Read(filePath); + + User user = await users.Value.Get(userId); + if (user.Email == null) + throw new ItemNotFoundException(); + using MD5 md5 = MD5.Create(); + string hash = Convert + .ToHexString(md5.ComputeHash(Encoding.ASCII.GetBytes(user.Email))) + .ToLower(); + try + { + HttpClient client = clientFactory.CreateClient(); + HttpResponseMessage response = await client.GetAsync( + $"https://www.gravatar.com/avatar/{hash}.jpg?d=404&s=250" + ); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStreamAsync(); + } + catch + { + throw new ItemNotFoundException(); + } + } + + public async Task SetUserImage(Guid userId, Stream? image) + { + var filePath = $"/metadata/user/{userId}.webp"; + if (image == null) + { + if (await storage.DoesExist(filePath)) + await storage.Delete(filePath); + return; + } + + using SKCodec codec = SKCodec.Create(image); + SKImageInfo info = codec.Info; + info.ColorType = SKColorType.Rgba8888; + using SKBitmap original = SKBitmap.Decode(codec, info); + using SKBitmap ret = original.Resize(new SKSizeI(250, 250), SKFilterQuality.High); + await _SaveImage(ret, filePath, 75); + } +} diff --git a/src/Kyoo.Core/CoreModule.cs b/src/Kyoo.Core/CoreModule.cs new file mode 100644 index 0000000..a8d61b9 --- /dev/null +++ b/src/Kyoo.Core/CoreModule.cs @@ -0,0 +1,94 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Linq; +using Amazon.S3; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Core.Controllers; +using Kyoo.Core.Storage; +using Kyoo.Postgresql; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Kyoo.Core; + +public static class CoreModule +{ + /// + /// A service provider to access services in static context (in events for example). + /// + /// Don't forget to create a scope. + public static IServiceProvider Services { get; set; } + + public static void AddRepository(this IServiceCollection services) + where T : IResource + where TRepo : class, IRepository + { + services.AddScoped(); + services.AddScoped>(x => x.GetRequiredService()); + services.AddScoped>>(x => new(() => x.GetRequiredService())); + } + + public static void ConfigureKyoo(this WebApplicationBuilder builder) + { + builder._AddStorage(); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddRepository(); + builder.Services.AddRepository(); + builder.Services.AddRepository(); + builder.Services.AddRepository(); + builder.Services.AddRepository(); + builder.Services.AddRepository(); + builder.Services.AddRepository(); + builder.Services.AddRepository(); + builder.Services.AddRepository(); + builder.Services.AddScoped(x => x.GetRequiredService()); + builder.Services.AddScoped(); + builder.Services.AddScoped(x => + x.GetRequiredService() + ); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } + + private static void _AddStorage(this WebApplicationBuilder builder) + { + var shouldUseS3 = !string.IsNullOrEmpty( + builder.Configuration.GetValue(S3Storage.S3BucketEnvironmentVariable) + ); + + if (!shouldUseS3) + { + builder.Services.AddScoped(); + return; + } + + // Configuration (credentials, endpoint, etc.) are done via standard AWS env vars + // See https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-netcore.html + builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions()); + builder.Services.AddAWSService(); + builder.Services.AddScoped(); + } +} diff --git a/src/Kyoo.Core/ExceptionFilter.cs b/src/Kyoo.Core/ExceptionFilter.cs new file mode 100644 index 0000000..77ea102 --- /dev/null +++ b/src/Kyoo.Core/ExceptionFilter.cs @@ -0,0 +1,82 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.ComponentModel.DataAnnotations; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace Kyoo.Core; + +/// +/// A middleware to handle errors globally. +/// +/// +/// Initializes a new instance of the class. +/// +/// The logger used to log errors. +public class ExceptionFilter(ILogger logger) : IExceptionFilter +{ + /// + public void OnException(ExceptionContext context) + { + switch (context.Exception) + { + case ValidationException ex: + context.Result = new BadRequestObjectResult(new RequestError(ex.Message)); + break; + case ItemNotFoundException ex: + context.Result = new NotFoundObjectResult(new RequestError(ex.Message)); + break; + case DuplicatedItemException ex when ex.Existing is not null: + context.Result = new ConflictObjectResult(ex.Existing); + break; + case DuplicatedItemException: + // Should not happen but if it does, it is better than returning a 409 with no body since clients expect json content + context.Result = new ConflictObjectResult(new RequestError("Duplicated item")); + break; + case UnauthorizedException ex: + context.Result = new UnauthorizedObjectResult(new RequestError(ex.Message)); + break; + case Exception ex: + logger.LogError(ex, "Unhandled error"); + context.Result = new ServerErrorObjectResult( + new RequestError("Internal Server Error") + ); + break; + } + } + + /// + public class ServerErrorObjectResult : ObjectResult + { + /// + /// Initializes a new instance of the class. + /// + /// The object to return. + public ServerErrorObjectResult(object value) + : base(value) + { + StatusCode = StatusCodes.Status500InternalServerError; + } + } +} diff --git a/src/Kyoo.Core/Extensions/ServiceExtensions.cs b/src/Kyoo.Core/Extensions/ServiceExtensions.cs new file mode 100644 index 0000000..5dd4930 --- /dev/null +++ b/src/Kyoo.Core/Extensions/ServiceExtensions.cs @@ -0,0 +1,90 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using AspNetCore.Proxy; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication; +using Kyoo.Core.Api; +using Kyoo.Core.Controllers; +using Kyoo.Utils; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Kyoo.Core.Extensions; + +public static class ServiceExtensions +{ + public static void ConfigureMvc(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + + services + .AddMvcCore(options => + { + options.Filters.Add(); + options.ModelBinderProviders.Insert(0, new SortBinder.Provider()); + options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider()); + options.ModelBinderProviders.Insert(0, new FilterBinder.Provider()); + }) + .AddApplicationPart(typeof(CoreModule).Assembly) + .AddApplicationPart(typeof(AuthenticationModule).Assembly) + .AddJsonOptions(x => + { + x.JsonSerializerOptions.TypeInfoResolver = new JsonKindResolver() + { + Modifiers = { IncludeBinder.HandleLoadableFields } + }; + x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + x.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }) + .AddDataAnnotations() + .AddControllersAsServices() + .AddApiExplorer() + .ConfigureApiBehaviorOptions(options => + { + options.SuppressMapClientErrors = true; + options.InvalidModelStateResponseFactory = ctx => + { + string[] errors = ctx + .ModelState.SelectMany(x => x.Value!.Errors) + .Select(x => x.ErrorMessage) + .ToArray(); + return new BadRequestObjectResult(new RequestError(errors)); + }; + }); + + services.Configure(x => + { + x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint)); + x.ConstraintMap.Add("base64", typeof(Base64RouteConstraint)); + }); + + services.AddResponseCompression(x => + { + x.EnableForHttps = true; + }); + + services.AddProxies(); + services.AddHttpClient(); + } +} diff --git a/src/Kyoo.Core/Kyoo.Core.csproj b/src/Kyoo.Core/Kyoo.Core.csproj new file mode 100644 index 0000000..bb7faf3 --- /dev/null +++ b/src/Kyoo.Core/Kyoo.Core.csproj @@ -0,0 +1,44 @@ + + + Kyoo.Core + Kyoo.Core + Exe + kyoo + + 50 + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/Kyoo.Core/Program.cs b/src/Kyoo.Core/Program.cs new file mode 100644 index 0000000..9233e8c --- /dev/null +++ b/src/Kyoo.Core/Program.cs @@ -0,0 +1,113 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using Kyoo.Authentication; +using Kyoo.Core; +using Kyoo.Core.Controllers; +using Kyoo.Core.Extensions; +using Kyoo.Meiliseach; +using Kyoo.Postgresql; +using Kyoo.RabbitMq; +using Kyoo.Swagger; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Serilog.Events; +using Serilog.Templates; +using Serilog.Templates.Themes; + +#if DEBUG +const string EnvironmentName = "Development"; +#else +const string EnvironmentName = "Production"; +#endif + +WebApplicationBuilder builder = WebApplication.CreateBuilder( + new WebApplicationOptions() + { + Args = args, + EnvironmentName = EnvironmentName, + ApplicationName = "Kyoo", + ContentRootPath = AppDomain.CurrentDomain.BaseDirectory, + } +); +builder.WebHost.UseKestrel(opt => +{ + opt.AddServerHeader = false; +}); + +const string template = + "[{@t:HH:mm:ss} {@l:u3} {Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), 25} " + + "({@i:D10})] {@m}{#if not EndsWith(@m, '\n')}\n{#end}{@x}"; +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Warning() + .MinimumLevel.Override("Kyoo", LogEventLevel.Verbose) + .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Verbose) + .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Fatal) + .WriteTo.Console(new ExpressionTemplate(template, theme: TemplateTheme.Code)) + .Enrich.WithThreadId() + .Enrich.FromLogContext() + .CreateLogger(); +AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush(); +AppDomain.CurrentDomain.UnhandledException += (_, ex) => + Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception"); +builder.Host.UseSerilog(); + +builder.Services.ConfigureMvc(); +builder.Services.ConfigureOpenApi(); + +// configure postgres first to allow other services to depend on db config +builder.ConfigurePostgres(); +builder.ConfigureKyoo(); +builder.ConfigureAuthentication(); +builder.ConfigureMeilisearch(); +builder.ConfigureRabbitMq(); + +WebApplication app = builder.Build(); +CoreModule.Services = app.Services; + +app.UsePathBase(new PathString(builder.Configuration.GetValue("KYOO_PREFIX", ""))); +app.UseHsts(); +app.UseKyooOpenApi(); +app.UseResponseCompression(); +app.UseRouting(); +app.UseAuthentication(); +app.MapControllers(); + +// Activate services that always run in the background +app.Services.GetRequiredService(); +app.Services.GetRequiredService(); + +// Only run sync on the main instance +if (app.Configuration.GetValue("RUN_MIGRATIONS", true)) +{ + await using (AsyncServiceScope scope = app.Services.CreateAsyncScope()) + { + await MeilisearchModule.Initialize(app.Services); + } + + // The methods takes care of creating a scope and will download images on the background. + _ = MeilisearchModule.SyncDatabase(app.Services); + _ = MiscRepository.DownloadMissingImages(app.Services); +} + +await app.RunAsync(Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000"); diff --git a/src/Kyoo.Core/Storage/FileStorage.cs b/src/Kyoo.Core/Storage/FileStorage.cs new file mode 100644 index 0000000..45687bd --- /dev/null +++ b/src/Kyoo.Core/Storage/FileStorage.cs @@ -0,0 +1,51 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Kyoo.Core.Storage; + +/// +/// File-backed storage. +/// +public class FileStorage : IStorage +{ + public Task DoesExist(string path) => Task.FromResult(File.Exists(path)); + + public async Task Read(string path) => + await Task.FromResult(File.Open(path, FileMode.Open, FileAccess.Read)); + + public async Task Write(Stream reader, string path) + { + Directory.CreateDirectory( + Path.GetDirectoryName(path) ?? throw new InvalidOperationException() + ); + await using Stream file = File.Create(path); + await reader.CopyToAsync(file); + } + + public Task Delete(string path) + { + if (File.Exists(path)) + File.Delete(path); + + return Task.CompletedTask; + } +} diff --git a/src/Kyoo.Core/Storage/IStorage.cs b/src/Kyoo.Core/Storage/IStorage.cs new file mode 100644 index 0000000..2253661 --- /dev/null +++ b/src/Kyoo.Core/Storage/IStorage.cs @@ -0,0 +1,33 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.IO; +using System.Threading.Tasks; + +namespace Kyoo.Core.Storage; + +/// +/// Interface for storage operations. +/// +public interface IStorage +{ + Task DoesExist(string path); + Task Read(string path); + Task Write(Stream stream, string path); + Task Delete(string path); +} diff --git a/src/Kyoo.Core/Storage/S3Storage.cs b/src/Kyoo.Core/Storage/S3Storage.cs new file mode 100644 index 0000000..5c78c69 --- /dev/null +++ b/src/Kyoo.Core/Storage/S3Storage.cs @@ -0,0 +1,78 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.IO; +using System.Threading.Tasks; +using Amazon.S3; +using Microsoft.Extensions.Configuration; + +namespace Kyoo.Core.Storage; + +/// +/// S3-backed storage. +/// +public class S3Storage(IAmazonS3 s3Client, IConfiguration configuration) : IStorage +{ + public const string S3BucketEnvironmentVariable = "S3_BUCKET_NAME"; + + public Task DoesExist(string path) + { + return s3Client + .GetObjectMetadataAsync(_GetBucketName(), path) + .ContinueWith(t => + { + if (t.IsFaulted) + return false; + + return t.Result.HttpStatusCode == System.Net.HttpStatusCode.OK; + }); + } + + public async Task Read(string path) + { + var response = await s3Client.GetObjectAsync(_GetBucketName(), path); + return response.ResponseStream; + } + + public Task Write(Stream reader, string path) + { + return s3Client.PutObjectAsync( + new Amazon.S3.Model.PutObjectRequest + { + BucketName = _GetBucketName(), + Key = path, + InputStream = reader + } + ); + } + + public Task Delete(string path) + { + return s3Client.DeleteObjectAsync(_GetBucketName(), path); + } + + private string _GetBucketName() + { + var bucketName = configuration.GetValue(S3BucketEnvironmentVariable); + if (string.IsNullOrEmpty(bucketName)) + throw new InvalidOperationException("S3 bucket name is not configured."); + + return bucketName; + } +} diff --git a/src/Kyoo.Core/Views/Admin/Health.cs b/src/Kyoo.Core/Views/Admin/Health.cs new file mode 100644 index 0000000..3fa1d7a --- /dev/null +++ b/src/Kyoo.Core/Views/Admin/Health.cs @@ -0,0 +1,63 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Threading.Tasks; +using Kyoo.Abstractions.Models.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Kyoo.Core.Api; + +/// +/// An API endpoint to check the health. +/// +[Route("health")] +[ApiController] +[ApiDefinition("Health")] +public class Health(HealthCheckService healthCheckService) : BaseApi +{ + /// + /// Check if the api is ready to accept requests. + /// + /// A status indicating the health of the api. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task CheckHealth() + { + IHeaderDictionary headers = HttpContext.Response.Headers; + headers.CacheControl = "no-store, no-cache"; + headers.Pragma = "no-cache"; + headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT"; + + HealthReport result = await healthCheckService.CheckHealthAsync(); + return result.Status switch + { + HealthStatus.Healthy => Ok(new HealthResult("Healthy")), + HealthStatus.Unhealthy => Ok(new HealthResult("Unstable")), + HealthStatus.Degraded => StatusCode(StatusCodes.Status503ServiceUnavailable), + _ => StatusCode(StatusCodes.Status500InternalServerError), + }; + } + + /// + /// The result of a health operation. + /// + public record HealthResult(string Status); +} diff --git a/src/Kyoo.Core/Views/Admin/Misc.cs b/src/Kyoo.Core/Views/Admin/Misc.cs new file mode 100644 index 0000000..edd5351 --- /dev/null +++ b/src/Kyoo.Core/Views/Admin/Misc.cs @@ -0,0 +1,97 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Core.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Kyoo.Core.Api; + +/// +/// Private APIs only used for other services. Can change at any time without notice. +/// +[ApiController] +[PartialPermission(nameof(Misc), Group = Group.Admin)] +public class Misc(MiscRepository repo) : BaseApi +{ + /// + /// List all registered paths. + /// + /// The list of paths known to Kyoo. + [HttpGet("/paths")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + public Task> GetAllPaths() + { + return repo.GetRegisteredPaths(); + } + + /// + /// Delete item at path. + /// + /// The path to delete. + /// + /// If true, the path will be considered as a directory and every children will be removed. + /// + /// Nothing + [HttpDelete("/paths")] + [PartialPermission(Kind.Delete)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task DeletePath( + [FromQuery] string path, + [FromQuery] bool recursive = false + ) + { + await repo.DeletePath(path, recursive); + return NoContent(); + } + + /// + /// Rescan library + /// + /// + /// Trigger a complete library rescan + /// + /// Nothing + [HttpPost("/rescan")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task RescanLibrary([FromServices] IScanner scanner) + { + await scanner.SendRescanRequest(); + return NoContent(); + } + + /// + /// List items to refresh. + /// + /// The upper limit for the refresh date. + /// The items that should be refreshed before the given date + [HttpGet("/refreshables")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + public Task> GetAllPaths([FromQuery] DateTime? date) + { + return repo.GetRefreshableItems(date ?? DateTime.UtcNow); + } +} diff --git a/src/Kyoo.Core/Views/Content/ThumbnailsApi.cs b/src/Kyoo.Core/Views/Content/ThumbnailsApi.cs new file mode 100644 index 0000000..1693804 --- /dev/null +++ b/src/Kyoo.Core/Views/Content/ThumbnailsApi.cs @@ -0,0 +1,68 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.IO; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Retrive images. +/// +[ApiController] +[Route("thumbnails")] +[Route("images", Order = AlternativeRoute)] +[Route("image", Order = AlternativeRoute)] +[Permission(nameof(Image), Kind.Read, Group = Group.Overall)] +[ApiDefinition("Images", Group = OtherGroup)] +public class ThumbnailsApi(IThumbnailsManager thumbs) : BaseApi +{ + /// + /// Get Image + /// + /// + /// Get an image from it's id. You can select a specefic quality. + /// + /// The ID of the image to retrive. + /// The quality of the image to retrieve. + /// The image asked. + /// + /// The image does not exists on kyoo. + /// + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetPoster(Guid id, [FromQuery] ImageQuality? quality) + { + quality ??= ImageQuality.High; + if (!await thumbs.IsImageSaved(id, quality.Value)) + return NotFound(); + + // Allow clients to cache the image for 6 month. + Response.Headers.CacheControl = $"public, max-age={60 * 60 * 24 * 31 * 6}"; + return File(await thumbs.GetImage(id, quality.Value), "image/webp", true); + } +} diff --git a/src/Kyoo.Core/Views/Content/VideoApi.cs b/src/Kyoo.Core/Views/Content/VideoApi.cs new file mode 100644 index 0000000..aa1b9b0 --- /dev/null +++ b/src/Kyoo.Core/Views/Content/VideoApi.cs @@ -0,0 +1,145 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Threading.Tasks; +using AspNetCore.Proxy; +using AspNetCore.Proxy.Options; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Private routes of the transcoder. +/// Url for these routes will be returned from /info or /master.m3u8 routes. +/// This should not be called manually +/// +[ApiController] +[Route("videos")] +[Route("video", Order = AlternativeRoute)] +[Permission("video", Kind.Read, Group = Group.Overall)] +[ApiDefinition("Video", Group = OtherGroup)] +public class VideoApi : Controller +{ + public static string TranscoderUrl = + Environment.GetEnvironmentVariable("TRANSCODER_URL") ?? "http://transcoder:7666"; + + private Task _Proxy(string route) + { + HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder + .Instance.WithHandleFailure( + async (context, exception) => + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsJsonAsync( + new RequestError("Service unavailable") + ); + } + ) + .Build(); + return this.HttpProxyAsync($"{TranscoderUrl}/{route}", proxyOptions); + } + + [HttpGet("{path:base64}/direct")] + [PartialPermission(Kind.Play)] + [ProducesResponseType(StatusCodes.Status206PartialContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetDirectStream(string path) + { + await _Proxy($"{path}/direct"); + } + + [HttpGet("{path:base64}/direct/{identifier}")] + [PartialPermission(Kind.Play)] + [ProducesResponseType(StatusCodes.Status206PartialContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetDirectStream(string path, string identifier) + { + await _Proxy($"{path}/direct/{identifier}"); + } + + [HttpGet("{path:base64}/master.m3u8")] + [PartialPermission(Kind.Play)] + [ProducesResponseType(StatusCodes.Status206PartialContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMaster(string path) + { + await _Proxy($"{path}/master.m3u8"); + } + + [HttpGet("{path:base64}/{video:int}/{quality}/index.m3u8")] + [PartialPermission(Kind.Play)] + public async Task GetVideoIndex(string path, int video, string quality) + { + await _Proxy($"{path}/{video}/{quality}/index.m3u8"); + } + + [HttpGet("{path:base64}/{video:int}/{quality}/{segment}")] + [PartialPermission(Kind.Play)] + public async Task GetVideoSegment(string path, int video, string quality, string segment) + { + await _Proxy($"{path}/{video}/{quality}/{segment}"); + } + + [HttpGet("{path:base64}/audio/{audio}/index.m3u8")] + [PartialPermission(Kind.Play)] + public async Task GetAudioIndex(string path, string audio) + { + await _Proxy($"{path}/audio/{audio}/index.m3u8"); + } + + [HttpGet("{path:base64}/audio/{audio}/{segment}")] + [PartialPermission(Kind.Play)] + public async Task GetAudioSegment(string path, string audio, string segment) + { + await _Proxy($"{path}/audio/{audio}/{segment}"); + } + + [HttpGet("{path:base64}/attachment/{name}")] + [PartialPermission(Kind.Play)] + public async Task GetAttachment(string path, string name) + { + await _Proxy($"{path}/attachment/{name}"); + } + + [HttpGet("{path:base64}/subtitle/{name}")] + [PartialPermission(Kind.Play)] + public async Task GetSubtitle(string path, string name) + { + await _Proxy($"{path}/subtitle/{name}"); + } + + [HttpGet("{path:base64}/thumbnails.png")] + [PartialPermission(Kind.Read)] + public async Task GetThumbnails(string path) + { + await _Proxy($"{path}/thumbnails.png"); + } + + [HttpGet("{path:base64}/thumbnails.vtt")] + [PartialPermission(Kind.Read)] + public async Task GetThumbnailsVtt(string path) + { + await _Proxy($"{path}/thumbnails.vtt"); + } +} diff --git a/src/Kyoo.Core/Views/Helper/BaseApi.cs b/src/Kyoo.Core/Views/Helper/BaseApi.cs new file mode 100644 index 0000000..9d1c7fa --- /dev/null +++ b/src/Kyoo.Core/Views/Helper/BaseApi.cs @@ -0,0 +1,99 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Utils; +using Microsoft.AspNetCore.Mvc; + +namespace Kyoo.Core.Api; + +/// +/// A common API containing custom methods to help inheritors. +/// +public abstract class BaseApi : ControllerBase +{ + /// + /// Construct and return a page from an api. + /// + /// The list of resources that should be included in the current page. + /// + /// The max number of items that should be present per page. This should be the same as in the request, + /// it is used to calculate if this is the last page and so on. + /// + /// The type of items on the page. + /// A Page representing the response. + protected Page Page(ICollection resources, int limit) + where TResult : IResource + { + Dictionary query = Request.Query.ToDictionary( + x => x.Key, + x => x.Value.ToString(), + StringComparer.InvariantCultureIgnoreCase + ); + + // If the query was sorted randomly, add the seed to the url to get reproducible links (next,prev,first...) + if (query.ContainsKey("sortBy")) + { + object seed = HttpContext.Items["seed"]!; + + query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}"); + } + return new Page(resources, Request.Path, query, limit); + } + + protected SearchPage SearchPage(SearchPage.SearchResult result) + where TResult : IResource + { + Dictionary query = Request.Query.ToDictionary( + x => x.Key, + x => x.Value.ToString(), + StringComparer.InvariantCultureIgnoreCase + ); + + string self = Request.Path + query.ToQueryString(); + string? previous = null; + string? next = null; + string first; + int limit = query.TryGetValue("limit", out string? limitStr) + ? int.Parse(limitStr) + : new SearchPagination().Limit; + int? skip = query.TryGetValue("skip", out string? skipStr) ? int.Parse(skipStr) : null; + + if (skip != null) + { + query["skip"] = Math.Max(0, skip.Value - limit).ToString(); + previous = Request.Path + query.ToQueryString(); + } + if (result.Items.Count == limit && limit > 0) + { + int newSkip = skip.HasValue ? skip.Value + limit : limit; + query["skip"] = newSkip.ToString(); + next = Request.Path + query.ToQueryString(); + } + + query.Remove("skip"); + first = Request.Path + query.ToQueryString(); + + return new SearchPage(result, self, previous, next, first); + } +} diff --git a/src/Kyoo.Core/Views/Helper/CrudApi.cs b/src/Kyoo.Core/Views/Helper/CrudApi.cs new file mode 100644 index 0000000..44e4447 --- /dev/null +++ b/src/Kyoo.Core/Views/Helper/CrudApi.cs @@ -0,0 +1,299 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Kyoo.Core.Api; + +/// +/// A base class to handle CRUD operations on a specific resource type . +/// +/// The type of resource to make CRUD apis for. +[ApiController] +public class CrudApi : BaseApi + where T : class, IResource, IQuery +{ + /// + /// The repository of the resource, used to retrieve, save and do operations on the baking store. + /// + protected IRepository Repository { get; } + + /// + /// Create a new using the given repository and base url. + /// + /// + /// The repository to use as a baking store for the type . + /// + public CrudApi(IRepository repository) + { + Repository = repository; + } + + /// + /// Get item + /// + /// + /// Get a specific resource via it's ID or it's slug. + /// + /// The ID or slug of the resource to retrieve. + /// The aditional fields to include in the result. + /// The retrieved resource. + /// A resource with the given ID or slug does not exist. + [HttpGet("{identifier:id}")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(Identifier identifier, [FromQuery] Include? fields) + { + T? ret = await identifier.Match( + id => Repository.GetOrDefault(id, fields), + slug => Repository.GetOrDefault(slug, fields) + ); + if (ret == null) + return NotFound(); + return ret; + } + + /// + /// Get count + /// + /// + /// Get the number of resources that match the filters. + /// + /// A list of filters to respect. + /// How many resources matched that filter. + /// Invalid filters. + [HttpGet("count")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task> GetCount([FromQuery] Filter filter) + { + return await Repository.GetCount(filter); + } + + /// + /// Get all + /// + /// + /// Get all resources that match the given filter. + /// + /// Sort information about the query (sort by, sort order). + /// Filter the returned items. + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of resources that match every filters. + /// Invalid filters or sort information. + [HttpGet] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task>> GetAll( + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) + { + ICollection resources = await Repository.GetAll(filter, sortBy, fields, pagination); + + return Page(resources, pagination.Limit); + } + + /// + /// Create new + /// + /// + /// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo. + /// + /// The resource to create. + /// The created resource. + /// The resource in the request body is invalid. + /// This item already exists (maybe a duplicated slug). + [HttpPost] + [PartialPermission(Kind.Create)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))] + public virtual async Task> Create([FromBody] T resource) + { + return await Repository.Create(resource); + } + + /// + /// Edit + /// + /// + /// Edit an item. If the ID is specified it will be used to identify the resource. + /// If not, the slug will be used to identify it. + /// + /// The resource to edit. + /// The edited resource. + /// The resource in the request body is invalid. + /// No item found with the specified ID (or slug). + [HttpPut] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Edit([FromBody] T resource) + { + if (resource.Id != Guid.Empty) + return await Repository.Edit(resource); + + T old = await Repository.Get(resource.Slug); + resource.Id = old.Id; + return await Repository.Edit(resource); + } + + /// + /// Edit + /// + /// + /// Edit an item. If the ID is specified it will be used to identify the resource. + /// If not, the slug will be used to identify it. + /// + /// The id or slug of the resource. + /// The resource to edit. + /// The edited resource. + /// The resource in the request body is invalid. + /// No item found with the specified ID (or slug). + [HttpPut("{identifier:id}")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Edit(Identifier identifier, [FromBody] T resource) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await Repository.Get(slug)).Id + ); + resource.Id = id; + return await Repository.Edit(resource); + } + + /// + /// Patch + /// + /// + /// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource. + /// If not, the slug will be used to identify it. + /// + /// The resource to patch. + /// The edited resource. + /// The resource in the request body is invalid. + /// No item found with the specified ID (or slug). + [HttpPatch] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Patch([FromBody] Patch patch) + { + if (patch.Id.HasValue) + return await Repository.Patch(patch.Id.Value, patch.Apply); + if (patch.Slug == null) + throw new ArgumentException( + "Either the Id or the slug of the resource has to be defined to edit it." + ); + + T old = await Repository.Get(patch.Slug); + return await Repository.Patch(old.Id, patch.Apply); + } + + /// + /// Patch + /// + /// + /// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource. + /// If not, the slug will be used to identify it. + /// + /// The id or slug of the resource. + /// The resource to patch. + /// The edited resource. + /// The resource in the request body is invalid. + /// No item found with the specified ID (or slug). + [HttpPatch("{identifier:id}")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Patch(Identifier identifier, [FromBody] Patch patch) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await Repository.Get(slug)).Id + ); + if (patch.Id.HasValue && patch.Id.Value != id) + throw new ArgumentException("Can not edit id of a resource."); + return await Repository.Patch(id, patch.Apply); + } + + /// + /// Delete an item + /// + /// + /// Delete one item via it's ID or it's slug. + /// + /// The ID or slug of the resource to delete. + /// The item has successfully been deleted. + /// No item could be found with the given id or slug. + [HttpDelete("{identifier:id}")] + [PartialPermission(Kind.Delete)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(Identifier identifier) + { + await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug)); + return NoContent(); + } + + /// + /// Delete all where + /// + /// + /// Delete all items matching the given filters. If no filter is specified, delete all items. + /// + /// The list of filters. + /// The item(s) has successfully been deleted. + /// One or multiple filters are invalid. + [HttpDelete] + [PartialPermission(Kind.Delete)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task Delete([FromQuery] Filter filter) + { + if (filter == null) + return BadRequest( + new RequestError("Incule a filter to delete items, all items won't be deleted.") + ); + + await Repository.DeleteAll(filter); + return NoContent(); + } +} diff --git a/src/Kyoo.Core/Views/Helper/CrudThumbsApi.cs b/src/Kyoo.Core/Views/Helper/CrudThumbsApi.cs new file mode 100644 index 0000000..bf8aa7c --- /dev/null +++ b/src/Kyoo.Core/Views/Helper/CrudThumbsApi.cs @@ -0,0 +1,124 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +[ApiController] +public class CrudThumbsApi(IRepository repository) : CrudApi(repository) + where T : class, IResource, IThumbnails, IQuery +{ + private async Task _GetImage( + Identifier identifier, + string image, + ImageQuality? quality + ) + { + T? resource = await identifier.Match( + id => Repository.GetOrDefault(id), + slug => Repository.GetOrDefault(slug) + ); + if (resource == null) + return NotFound(); + + Image? img = image switch + { + "poster" => resource.Poster, + "thumbnail" => resource.Thumbnail, + "logo" => resource.Logo, + _ => throw new ArgumentException(nameof(image)), + }; + if (img is null) + return NotFound(); + + // TODO: Remove the /api and use a proxy rewrite instead. + return Redirect($"/api/thumbnails/{img.Id}"); + } + + /// + /// Get Poster + /// + /// + /// Get the poster for the specified item. + /// + /// The ID or slug of the resource to get the image for. + /// The quality of the image to retrieve. + /// The image asked. + /// + /// No item exist with the specific identifier or the image does not exists on kyoo. + /// + [HttpGet("{identifier:id}/poster")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task GetPoster(Identifier identifier, [FromQuery] ImageQuality? quality) + { + return _GetImage(identifier, "poster", quality); + } + + /// + /// Get Logo + /// + /// + /// Get the logo for the specified item. + /// + /// The ID or slug of the resource to get the image for. + /// The quality of the image to retrieve. + /// The image asked. + /// + /// No item exist with the specific identifier or the image does not exists on kyoo. + /// + [HttpGet("{identifier:id}/logo")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality) + { + return _GetImage(identifier, "logo", quality); + } + + /// + /// Get Thumbnail + /// + /// + /// Get the thumbnail for the specified item. + /// + /// The ID or slug of the resource to get the image for. + /// The quality of the image to retrieve. + /// The image asked. + /// + /// No item exist with the specific identifier or the image does not exists on kyoo. + /// + [HttpGet("{identifier:id}/thumbnail")] + [HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task GetBackdrop(Identifier identifier, [FromQuery] ImageQuality? quality) + { + return _GetImage(identifier, "thumbnail", quality); + } +} diff --git a/src/Kyoo.Core/Views/Helper/FilterBinder.cs b/src/Kyoo.Core/Views/Helper/FilterBinder.cs new file mode 100644 index 0000000..3be01fa --- /dev/null +++ b/src/Kyoo.Core/Views/Helper/FilterBinder.cs @@ -0,0 +1,60 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Reflection; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +namespace Kyoo.Core.Api; + +public class FilterBinder : IModelBinder +{ + public Task BindModelAsync(ModelBindingContext bindingContext) + { + ValueProviderResult fields = bindingContext.ValueProvider.GetValue( + bindingContext.FieldName + ); + try + { + object? filter = bindingContext + .ModelType.GetMethod(nameof(Filter.From))! + .Invoke(null, new object?[] { fields.FirstValue }); + bindingContext.Result = ModelBindingResult.Success(filter); + return Task.CompletedTask; + } + catch (TargetInvocationException ex) + { + throw ex.InnerException!; + } + } + + public class Provider : IModelBinderProvider + { + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context.Metadata.ModelType.Name == "Filter`1") + { + return new BinderTypeModelBinder(typeof(FilterBinder)); + } + + return null!; + } + } +} diff --git a/src/Kyoo.Core/Views/Helper/IncludeBinder.cs b/src/Kyoo.Core/Views/Helper/IncludeBinder.cs new file mode 100644 index 0000000..fd78394 --- /dev/null +++ b/src/Kyoo.Core/Views/Helper/IncludeBinder.cs @@ -0,0 +1,89 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.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; + +namespace Kyoo.Core.Api; + +public class IncludeBinder : IModelBinder +{ + private readonly Random _rng = new(); + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + ValueProviderResult fields = bindingContext.ValueProvider.GetValue( + bindingContext.FieldName + ); + try + { + object include = bindingContext + .ModelType.GetMethod(nameof(Include.From))! + .Invoke(null, new object?[] { fields.FirstValue })!; + bindingContext.Result = ModelBindingResult.Success(include); + bindingContext.HttpContext.Items["fields"] = ((dynamic)include).Fields; + return Task.CompletedTask; + } + catch (TargetInvocationException ex) + { + throw ex.InnerException!; + } + } + + private static readonly IHttpContextAccessor _accessor = new HttpContextAccessor(); + + public static void HandleLoadableFields(JsonTypeInfo info) + { + foreach (JsonPropertyInfo prop in info.Properties) + { + object[] attributes = + prop.AttributeProvider?.GetCustomAttributes(typeof(LoadableRelationAttribute), true) + ?? []; + if (attributes.FirstOrDefault() is not LoadableRelationAttribute relation) + continue; + prop.ShouldSerialize = (_, _) => + { + if (_accessor?.HttpContext?.Items["fields"] is not ICollection fields) + return false; + return fields.Contains(prop.Name, StringComparer.InvariantCultureIgnoreCase); + }; + } + } + + public class Provider : IModelBinderProvider + { + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context.Metadata.ModelType.Name == "Include`1") + { + return new BinderTypeModelBinder(typeof(IncludeBinder)); + } + + return null!; + } + } +} diff --git a/src/Kyoo.Core/Views/Helper/SortBinder.cs b/src/Kyoo.Core/Views/Helper/SortBinder.cs new file mode 100644 index 0000000..fb39e0b --- /dev/null +++ b/src/Kyoo.Core/Views/Helper/SortBinder.cs @@ -0,0 +1,69 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +namespace Kyoo.Core.Api; + +public class SortBinder : IModelBinder +{ + private readonly Random _rng = new(); + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + ValueProviderResult sortBy = bindingContext.ValueProvider.GetValue( + bindingContext.FieldName + ); + uint seed = BitConverter.ToUInt32( + BitConverter.GetBytes(_rng.Next(int.MinValue, int.MaxValue)), + 0 + ); + try + { + object sort = bindingContext + .ModelType.GetMethod(nameof(Sort.From))! + .Invoke(null, [sortBy.FirstValue, seed])!; + bindingContext.Result = ModelBindingResult.Success(sort); + bindingContext.HttpContext.Items["seed"] = seed; + return Task.CompletedTask; + } + catch (TargetInvocationException ex) + { + throw ex.InnerException!; + } + } + + public class Provider : IModelBinderProvider + { + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context.Metadata.ModelType.Name == "Sort`1") + { + return new BinderTypeModelBinder(typeof(SortBinder)); + } + + return null!; + } + } +} diff --git a/src/Kyoo.Core/Views/Helper/Transcoder.cs b/src/Kyoo.Core/Views/Helper/Transcoder.cs new file mode 100644 index 0000000..6a7ceb8 --- /dev/null +++ b/src/Kyoo.Core/Views/Helper/Transcoder.cs @@ -0,0 +1,150 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Text; +using System.Threading.Tasks; +using AspNetCore.Proxy; +using AspNetCore.Proxy.Options; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; + +namespace Kyoo.Core.Api; + +public abstract class TranscoderApi(IRepository repository) : CrudThumbsApi(repository) + where T : class, IResource, IThumbnails, IQuery +{ + private Task _Proxy(string route) + { + HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder + .Instance.WithHandleFailure( + async (context, exception) => + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsJsonAsync( + new RequestError("Service unavailable") + ); + } + ) + .Build(); + return this.HttpProxyAsync($"{VideoApi.TranscoderUrl}/{route}", proxyOptions); + } + + protected abstract Task GetPath(Identifier identifier); + + private async Task _GetPath64(Identifier identifier) + { + string path = await GetPath(identifier); + return WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(path)); + } + + /// + /// Direct stream + /// + /// + /// Retrieve the raw video stream, in the same container as the one on the server. No transcoding or + /// transmuxing is done. + /// + /// The ID or slug of the . + /// The video file of this episode. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/direct")] + [PartialPermission(Kind.Play)] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetDirectStream(Identifier identifier) + { + // TODO: Remove the /api and use a proxy rewrite instead. + return Redirect($"/api/video/{await _GetPath64(identifier)}/direct"); + } + + /// + /// Get master playlist + /// + /// + /// Get a master playlist containing all possible video qualities and audios available for this resource. + /// Note that the direct stream is missing (since the direct is not an hls stream) and + /// subtitles/fonts are not included to support more codecs than just webvtt. + /// + /// The ID or slug of the . + /// The master playlist of this episode. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/master.m3u8")] + [PartialPermission(Kind.Play)] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMaster(Identifier identifier) + { + // TODO: Remove the /api and use a proxy rewrite instead. + return Redirect($"/api/video/{await _GetPath64(identifier)}/master.m3u8"); + } + + /// + /// Get file info + /// + /// + /// Identify metadata about a file. + /// + /// The ID or slug of the . + /// The media infos of the file. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/info")] + [PartialPermission(Kind.Read)] + public async Task GetInfo(Identifier identifier) + { + await _Proxy($"{await _GetPath64(identifier)}/info"); + } + + /// + /// Get thumbnail sprite + /// + /// + /// Get a sprite file containing all the thumbnails of the show. + /// + /// The ID or slug of the . + /// A sprite with an image for every X seconds of the video file. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/thumbnails.png")] + [PartialPermission(Kind.Read)] + public async Task GetThumbnails(Identifier identifier) + { + await _Proxy($"{await _GetPath64(identifier)}/thumbnails.png"); + } + + /// + /// Get thumbnail vtt + /// + /// + /// Get a vtt file containing timing/position of thumbnails inside the sprite file. + /// https://developer.bitmovin.com/playback/docs/webvtt-based-thumbnails for more info. + /// + /// The ID or slug of the . + /// A vtt file containing metadata about timing and x/y/width/height of the sprites of /thumbnails.png. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/thumbnails.vtt")] + [PartialPermission(Kind.Read)] + public async Task GetThumbnailsVtt(Identifier identifier) + { + await _Proxy($"{await _GetPath64(identifier)}/thumbnails.vtt"); + } +} diff --git a/src/Kyoo.Core/Views/InfoApi.cs b/src/Kyoo.Core/Views/InfoApi.cs new file mode 100644 index 0000000..ac0e2f2 --- /dev/null +++ b/src/Kyoo.Core/Views/InfoApi.cs @@ -0,0 +1,67 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Authentication.Models; +using Kyoo.Core.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Authentication.Views; + +/// +/// Info about the current instance +/// +[ApiController] +[Route("info")] +[ApiDefinition("Info", Group = UsersGroup)] +public class InfoApi(PermissionOption options, MiscRepository info, IConfiguration configuration) + : ControllerBase +{ + public async Task> GetInfo() + { + return Ok( + new ServerInfo() + { + AllowGuests = options.Default.Any(), + RequireVerification = options.RequireVerification, + GuestPermissions = options.Default.ToList(), + PublicUrl = options.PublicUrl, + Oidc = options + .OIDC.Select(x => new KeyValuePair( + x.Key, + new() { DisplayName = x.Value.DisplayName, LogoUrl = x.Value.LogoUrl, } + )) + .ToDictionary(x => x.Key, x => x.Value), + SetupStatus = await info.GetSetupStep(), + PasswordLoginEnabled = !configuration.GetValue( + "AUTHENTICATION_DISABLE_PASSWORD_LOGIN", + false + ), + RegistrationEnabled = !configuration.GetValue( + "AUTHENTICATION_DISABLE_USER_REGISTRATION", + false + ), + } + ); + } +} diff --git a/src/Kyoo.Core/Views/Metadata/IssueApi.cs b/src/Kyoo.Core/Views/Metadata/IssueApi.cs new file mode 100644 index 0000000..f1d0a83 --- /dev/null +++ b/src/Kyoo.Core/Views/Metadata/IssueApi.cs @@ -0,0 +1,119 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Create or list issues on the instance +/// +[Route("issues")] +[Route("issue", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Issue), Group = Group.Admin)] +[ApiDefinition("Issue", Group = AdminGroup)] +public class IssueApi(IIssueRepository issues) : Controller +{ + /// + /// Get count + /// + /// + /// Get the number of issues that match the filters. + /// + /// A list of filters to respect. + /// How many issues matched that filter. + /// Invalid filters. + [HttpGet("count")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task> GetCount([FromQuery] Filter filter) + { + return await issues.GetCount(filter); + } + + /// + /// Get all issues + /// + /// + /// Get all issues that match the given filter. + /// + /// Filter the returned items. + /// A list of issues that match every filters. + /// Invalid filters or sort information. + [HttpGet] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task>> GetAll([FromQuery] Filter? filter) + { + return Ok(await issues.GetAll(filter)); + } + + /// + /// Upsert issue + /// + /// + /// Create or update an issue. + /// + /// The issue to create. + /// The created issue. + /// The issue in the request body is invalid. + [HttpPost] + [PartialPermission(Kind.Create)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task> Create([FromBody] Issue issue) + { + return await issues.Upsert(issue); + } + + /// + /// Delete issues + /// + /// + /// Delete all issues matching the given filters. + /// + /// The list of filters. + /// The item(s) has successfully been deleted. + /// One or multiple filters are invalid. + [HttpDelete] + [PartialPermission(Kind.Delete)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task Delete([FromQuery] Filter filter) + { + if (filter == null) + return BadRequest( + new RequestError("Incule a filter to delete items, all items won't be deleted.") + ); + + await issues.DeleteAll(filter); + return NoContent(); + } +} diff --git a/src/Kyoo.Core/Views/Metadata/StudioApi.cs b/src/Kyoo.Core/Views/Metadata/StudioApi.cs new file mode 100644 index 0000000..a314835 --- /dev/null +++ b/src/Kyoo.Core/Views/Metadata/StudioApi.cs @@ -0,0 +1,102 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("studios")] +[Route("studio", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Show))] +[ApiDefinition("Studios", Group = MetadataGroup)] +public class StudioApi : CrudApi +{ + /// + /// The library manager used to modify or retrieve information in the data store. + /// + private readonly ILibraryManager _libraryManager; + + /// + /// Create a new . + /// + /// + /// The library manager used to modify or retrieve information in the data store. + /// + public StudioApi(ILibraryManager libraryManager) + : base(libraryManager.Studios) + { + _libraryManager = libraryManager; + } + + /// + /// Get shows + /// + /// + /// List shows that were made by this specific studio. + /// + /// The ID or slug of the . + /// A key to sort shows by. + /// An optional list of filters. + /// The number of shows to return. + /// The aditional fields to include in the result. + /// A page of shows. + /// The filters or the sort parameters are invalid. + /// No studio with the given ID or slug could be found. + [HttpGet("{identifier:id}/shows")] + [HttpGet("{identifier:id}/show", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetShows( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await _libraryManager.Shows.GetAll( + Filter.And(filter, identifier.Matcher(x => x.StudioId, x => x.Studio!.Slug)), + sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await _libraryManager.Studios.GetOrDefault(identifier.IsSame()) == null + ) + return NotFound(); + return Page(resources, pagination.Limit); + } +} diff --git a/src/Kyoo.Core/Views/Resources/CollectionApi.cs b/src/Kyoo.Core/Views/Resources/CollectionApi.cs new file mode 100644 index 0000000..a8a7a73 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/CollectionApi.cs @@ -0,0 +1,270 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Core.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("collections")] +[Route("collection", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Collection))] +[ApiDefinition("Collections", Group = ResourcesGroup)] +public class CollectionApi( + IRepository movies, + IRepository shows, + CollectionRepository collections, + LibraryItemRepository items +) : CrudThumbsApi(collections) +{ + /// + /// Refresh + /// + /// + /// Ask a metadata refresh. + /// + /// The ID or slug of the . + /// Nothing + /// No episode with the given ID or slug could be found. + [HttpPost("{identifier:id}/refresh")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Refresh(Identifier identifier, [FromServices] IScanner scanner) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await collections.Get(slug)).Id + ); + await scanner.SendRefreshRequest(nameof(Collection), id); + return NoContent(); + } + + /// + /// Add a movie + /// + /// + /// Add a movie in the collection. + /// + /// The ID or slug of the . + /// The ID or slug of the to add. + /// Nothing if successful. + /// No collection or movie with the given ID could be found. + /// The specified movie is already in this collection. + [HttpPut("{identifier:id}/movies/{movie:id}")] + [HttpPut("{identifier:id}/movie/{movie:id}", Order = AlternativeRoute)] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task AddMovie(Identifier identifier, Identifier movie) + { + Guid collectionId = await identifier.Match( + async id => (await collections.Get(id)).Id, + async slug => (await collections.Get(slug)).Id + ); + Guid movieId = await movie.Match( + async id => (await movies.Get(id)).Id, + async slug => (await movies.Get(slug)).Id + ); + await collections.AddMovie(collectionId, movieId); + return NoContent(); + } + + /// + /// Add a show + /// + /// + /// Add a show in the collection. + /// + /// The ID or slug of the . + /// The ID or slug of the to add. + /// Nothing if successful. + /// No collection or show with the given ID could be found. + /// The specified show is already in this collection. + [HttpPut("{identifier:id}/shows/{show:id}")] + [HttpPut("{identifier:id}/show/{show:id}", Order = AlternativeRoute)] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task AddShow(Identifier identifier, Identifier show) + { + Guid collectionId = await identifier.Match( + async id => (await collections.Get(id)).Id, + async slug => (await collections.Get(slug)).Id + ); + Guid showId = await show.Match( + async id => (await shows.Get(id)).Id, + async slug => (await shows.Get(slug)).Id + ); + await collections.AddShow(collectionId, showId); + return NoContent(); + } + + /// + /// Get items in collection + /// + /// + /// Lists the items that are contained in the collection with the given id or slug. + /// + /// The ID or slug of the . + /// A key to sort items by. + /// An optional list of filters. + /// The number of items to return. + /// The aditional fields to include in the result. + /// A page of items. + /// The filters or the sort parameters are invalid. + /// No collection with the given ID could be found. + [HttpGet("{identifier:id}/items")] + [HttpGet("{identifier:id}/item", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetItems( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) + { + Guid collectionId = await identifier.Match( + id => Task.FromResult(id), + async slug => (await collections.Get(slug)).Id + ); + ICollection resources = await items.GetAllOfCollection( + collectionId, + filter, + sortBy == new Sort.Default() + ? new Sort.By(nameof(Movie.AirDate)) + : sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await collections.GetOrDefault(identifier.IsSame()) == null + ) + return NotFound(); + return Page(resources, pagination.Limit); + } + + /// + /// Get shows in collection + /// + /// + /// Lists the shows that are contained in the collection with the given id or slug. + /// + /// The ID or slug of the . + /// A key to sort shows by. + /// An optional list of filters. + /// The number of shows to return. + /// The additional fields to include in the result. + /// A page of shows. + /// The filters or the sort parameters are invalid. + /// No collection with the given ID could be found. + [HttpGet("{identifier:id}/shows")] + [HttpGet("{identifier:id}/show", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetShows( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) + { + ICollection resources = await shows.GetAll( + Filter.And(filter, identifier.IsContainedIn(x => x.Collections)), + sortBy == new Sort.Default() ? new Sort.By(x => x.AirDate) : sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await collections.GetOrDefault(identifier.IsSame()) == null + ) + return NotFound(); + return Page(resources, pagination.Limit); + } + + /// + /// Get movies in collection + /// + /// + /// Lists the movies that are contained in the collection with the given id or slug. + /// + /// The ID or slug of the . + /// A key to sort movies by. + /// An optional list of filters. + /// The number of movies to return. + /// The aditional fields to include in the result. + /// A page of movies. + /// The filters or the sort parameters are invalid. + /// No collection with the given ID could be found. + [HttpGet("{identifier:id}/movies")] + [HttpGet("{identifier:id}/movie", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetMovies( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) + { + ICollection resources = await movies.GetAll( + Filter.And(filter, identifier.IsContainedIn(x => x.Collections)), + sortBy == new Sort.Default() ? new Sort.By(x => x.AirDate) : sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await collections.GetOrDefault(identifier.IsSame()) == null + ) + return NotFound(); + return Page(resources, pagination.Limit); + } +} diff --git a/src/Kyoo.Core/Views/Resources/EpisodeApi.cs b/src/Kyoo.Core/Views/Resources/EpisodeApi.cs new file mode 100644 index 0000000..b26c196 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/EpisodeApi.cs @@ -0,0 +1,221 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("episodes")] +[Route("episode", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Episode))] +[ApiDefinition("Episodes", Group = ResourcesGroup)] +public class EpisodeApi(ILibraryManager libraryManager) + : TranscoderApi(libraryManager.Episodes) +{ + /// + /// Refresh + /// + /// + /// Ask a metadata refresh. + /// + /// The ID or slug of the . + /// Nothing + /// No episode with the given ID or slug could be found. + [HttpPost("{identifier:id}/refresh")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Refresh(Identifier identifier, [FromServices] IScanner scanner) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Episodes.Get(slug)).Id + ); + await scanner.SendRefreshRequest(nameof(Episode), id); + return NoContent(); + } + + /// + /// Get episode's show + /// + /// + /// Get the show that this episode is part of. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The show that contains this episode. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/show")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetShow( + Identifier identifier, + [FromQuery] Include fields + ) + { + return await libraryManager.Shows.Get( + identifier.IsContainedIn(x => x.Episodes!), + fields + ); + } + + /// + /// Get episode's season + /// + /// + /// Get the season that this episode is part of. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The season that contains this episode. + /// The episode is not part of a season. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/season")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetSeason( + Identifier identifier, + [FromQuery] Include fields + ) + { + Season? ret = await libraryManager.Seasons.GetOrDefault( + identifier.IsContainedIn(x => x.Episodes!), + fields + ); + if (ret != null) + return ret; + Episode? episode = await identifier.Match( + id => libraryManager.Episodes.GetOrDefault(id), + slug => libraryManager.Episodes.GetOrDefault(slug) + ); + return episode == null ? NotFound() : NoContent(); + } + + /// + /// Get watch status + /// + /// + /// Get when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The status. + /// This episode does not have a specific status. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetWatchStatus(Identifier identifier) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Episodes.Get(slug)).Id + ); + return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow()); + } + + /// + /// Set watch status + /// + /// + /// Set when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The new watch status. + /// Where the user stopped watching (in seconds). + /// Where the user stopped watching (in percent). + /// The newly set status. + /// The status has been set + /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). + /// No episode with the given ID or slug could be found. + [HttpPost("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetWatchStatus( + Identifier identifier, + WatchStatus status, + int? watchedTime, + int? percent + ) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Episodes.Get(slug)).Id + ); + return await libraryManager.WatchStatus.SetEpisodeStatus( + id, + User.GetIdOrThrow(), + status, + watchedTime, + percent + ); + } + + /// + /// Delete watch status + /// + /// + /// Delete watch status (to rewatch for example). + /// + /// The ID or slug of the . + /// The newly set status. + /// The status has been deleted. + /// No episode with the given ID or slug could be found. + [HttpDelete("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteWatchStatus(Identifier identifier) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Episodes.Get(slug)).Id + ); + await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow()); + } + + protected override async Task GetPath(Identifier identifier) + { + string path = await identifier.Match( + async id => (await Repository.Get(id)).Path, + async slug => (await Repository.Get(slug)).Path + ); + return path; + } +} diff --git a/src/Kyoo.Core/Views/Resources/LibraryItemApi.cs b/src/Kyoo.Core/Views/Resources/LibraryItemApi.cs new file mode 100644 index 0000000..37f28c2 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/LibraryItemApi.cs @@ -0,0 +1,38 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Endpoint for items that are not part of a specific library. +/// An item can ether represent a collection or a show. +/// +[Route("items")] +[Route("item", Order = AlternativeRoute)] +[ApiController] +[PartialPermission("LibraryItem")] +[ApiDefinition("Items", Group = ResourcesGroup)] +public class LibraryItemApi(IRepository libraryItems) + : CrudThumbsApi(libraryItems) { } diff --git a/src/Kyoo.Core/Views/Resources/MovieApi.cs b/src/Kyoo.Core/Views/Resources/MovieApi.cs new file mode 100644 index 0000000..274b4e3 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/MovieApi.cs @@ -0,0 +1,232 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("movies")] +[Route("movie", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Movie))] +[ApiDefinition("Movie", Group = ResourcesGroup)] +public class MovieApi(ILibraryManager libraryManager) : TranscoderApi(libraryManager.Movies) +{ + /// + /// Refresh + /// + /// + /// Ask a metadata refresh. + /// + /// The ID or slug of the . + /// Nothing + /// No episode with the given ID or slug could be found. + [HttpPost("{identifier:id}/refresh")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Refresh(Identifier identifier, [FromServices] IScanner scanner) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Movies.Get(slug)).Id + ); + await scanner.SendRefreshRequest(nameof(Movie), id); + return NoContent(); + } + + /// + /// Get studio that made the show + /// + /// + /// Get the studio that made the show. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The studio that made the show. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/studio")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetStudio( + Identifier identifier, + [FromQuery] Include fields + ) + { + return await libraryManager.Studios.Get( + identifier.IsContainedIn(x => x.Movies!), + fields + ); + } + + /// + /// Get collections containing this show + /// + /// + /// List the collections that contain this show. + /// + /// The ID or slug of the . + /// A key to sort collections by. + /// An optional list of filters. + /// The number of collections to return. + /// The aditional fields to include in the result. + /// A page of collections. + /// The filters or the sort parameters are invalid. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/collections")] + [HttpGet("{identifier:id}/collection", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetCollections( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await libraryManager.Collections.GetAll( + Filter.And(filter, identifier.IsContainedIn(x => x.Movies)), + sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await libraryManager.Movies.GetOrDefault(identifier.IsSame()) == null + ) + return NotFound(); + return Page(resources, pagination.Limit); + } + + /// + /// Get watch status + /// + /// + /// Get when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The status. + /// This movie does not have a specific status. + /// No movie with the given ID or slug could be found. + [HttpGet("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetWatchStatus(Identifier identifier) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Movies.Get(slug)).Id + ); + return await libraryManager.WatchStatus.GetMovieStatus(id, User.GetIdOrThrow()); + } + + /// + /// Set watch status + /// + /// + /// Set when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The new watch status. + /// Where the user stopped watching. + /// Where the user stopped watching (in percent). + /// The newly set status. + /// The status has been set + /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). + /// WatchedTime can't be specified if status is not watching. + /// No movie with the given ID or slug could be found. + [HttpPost("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetWatchStatus( + Identifier identifier, + WatchStatus status, + int? watchedTime, + int? percent + ) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Movies.Get(slug)).Id + ); + return await libraryManager.WatchStatus.SetMovieStatus( + id, + User.GetIdOrThrow(), + status, + watchedTime, + percent + ); + } + + /// + /// Delete watch status + /// + /// + /// Delete watch status (to rewatch for example). + /// + /// The ID or slug of the . + /// The newly set status. + /// The status has been deleted. + /// No movie with the given ID or slug could be found. + [HttpDelete("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteWatchStatus(Identifier identifier) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Movies.Get(slug)).Id + ); + await libraryManager.WatchStatus.DeleteMovieStatus(id, User.GetIdOrThrow()); + } + + protected override async Task GetPath(Identifier identifier) + { + string path = await identifier.Match( + async id => (await Repository.Get(id)).Path, + async slug => (await Repository.Get(slug)).Path + ); + return path; + } +} diff --git a/src/Kyoo.Core/Views/Resources/NewsApi.cs b/src/Kyoo.Core/Views/Resources/NewsApi.cs new file mode 100644 index 0000000..84aa8f3 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/NewsApi.cs @@ -0,0 +1,36 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// List new items added to kyoo. +/// +[Route("news")] +[Route("new", Order = AlternativeRoute)] +[ApiController] +[PartialPermission("LibraryItem")] +[ApiDefinition("News", Group = ResourcesGroup)] +public class NewsApi(IRepository news) : CrudThumbsApi(news) { } diff --git a/src/Kyoo.Core/Views/Resources/SearchApi.cs b/src/Kyoo.Core/Views/Resources/SearchApi.cs new file mode 100644 index 0000000..1405038 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/SearchApi.cs @@ -0,0 +1,216 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource +/// is available on the said endpoint. +/// +[Route("search")] +[ApiController] +[ApiDefinition("Search", Group = OtherGroup)] +public class SearchApi : BaseApi +{ + private readonly ISearchManager _searchManager; + + public SearchApi(ISearchManager searchManager) + { + _searchManager = searchManager; + } + + // TODO: add facets + + /// + /// Search collections + /// + /// + /// Search for collections + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of collections found for the specified query. + [HttpGet("collections")] + [HttpGet("collection", Order = AlternativeRoute)] + [Permission(nameof(Collection), Kind.Read)] + [ApiDefinition("Collections", Group = ResourcesGroup)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchCollections( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage( + await _searchManager.SearchCollections(q, sortBy, filter, pagination, fields) + ); + } + + /// + /// Search shows + /// + /// + /// Search for shows + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of shows found for the specified query. + [HttpGet("shows")] + [HttpGet("show", Order = AlternativeRoute)] + [Permission(nameof(Show), Kind.Read)] + [ApiDefinition("Shows", Group = ResourcesGroup)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchShows( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage(await _searchManager.SearchShows(q, sortBy, filter, pagination, fields)); + } + + /// + /// Search movie + /// + /// + /// Search for movie + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of movies found for the specified query. + [HttpGet("movies")] + [HttpGet("movie", Order = AlternativeRoute)] + [Permission(nameof(Movie), Kind.Read)] + [ApiDefinition("Movies", Group = ResourcesGroup)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchMovies( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage(await _searchManager.SearchMovies(q, sortBy, filter, pagination, fields)); + } + + /// + /// Search items + /// + /// + /// Search for items + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of items found for the specified query. + [HttpGet("items")] + [HttpGet("item", Order = AlternativeRoute)] + [Permission(nameof(ILibraryItem), Kind.Read)] + [ApiDefinition("Items", Group = ResourcesGroup)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchItems( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage(await _searchManager.SearchItems(q, sortBy, filter, pagination, fields)); + } + + /// + /// Search episodes + /// + /// + /// Search for episodes + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of episodes found for the specified query. + [HttpGet("episodes")] + [HttpGet("episode", Order = AlternativeRoute)] + [Permission(nameof(Episode), Kind.Read)] + [ApiDefinition("Episodes", Group = ResourcesGroup)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchEpisodes( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage( + await _searchManager.SearchEpisodes(q, sortBy, filter, pagination, fields) + ); + } + + /// + /// Search studios + /// + /// + /// Search for studios + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of studios found for the specified query. + [HttpGet("studios")] + [HttpGet("studio", Order = AlternativeRoute)] + [Permission(nameof(Studio), Kind.Read)] + [ApiDefinition("Studios", Group = MetadataGroup)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchStudios( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage( + await _searchManager.SearchStudios(q, sortBy, filter, pagination, fields) + ); + } +} diff --git a/src/Kyoo.Core/Views/Resources/SeasonApi.cs b/src/Kyoo.Core/Views/Resources/SeasonApi.cs new file mode 100644 index 0000000..dcbdae8 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/SeasonApi.cs @@ -0,0 +1,138 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("seasons")] +[Route("season", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Season))] +[ApiDefinition("Seasons", Group = ResourcesGroup)] +public class SeasonApi(ILibraryManager libraryManager) + : CrudThumbsApi(libraryManager.Seasons) +{ + /// + /// Refresh + /// + /// + /// Ask a metadata refresh. + /// + /// The ID or slug of the . + /// Nothing + /// No episode with the given ID or slug could be found. + [HttpPost("{identifier:id}/refresh")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Refresh(Identifier identifier, [FromServices] IScanner scanner) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Seasons.Get(slug)).Id + ); + await scanner.SendRefreshRequest(nameof(Season), id); + return NoContent(); + } + + /// + /// Get episodes in the season + /// + /// + /// List the episodes that are part of the specified season. + /// + /// The ID or slug of the . + /// A key to sort episodes by. + /// An optional list of filters. + /// The number of episodes to return. + /// The aditional fields to include in the result. + /// A page of episodes. + /// The filters or the sort parameters are invalid. + /// No season with the given ID or slug could be found. + [HttpGet("{identifier:id}/episodes")] + [HttpGet("{identifier:id}/episode", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetEpisode( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await libraryManager.Episodes.GetAll( + Filter.And(filter, identifier.Matcher(x => x.SeasonId, x => x.Season!.Slug)), + sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await libraryManager.Seasons.GetOrDefault(identifier.IsSame()) == null + ) + return NotFound(); + return Page(resources, pagination.Limit); + } + + /// + /// Get season's show + /// + /// + /// Get the show that this season is part of. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The show that contains this season. + /// No season with the given ID or slug could be found. + [HttpGet("{identifier:id}/show")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetShow( + Identifier identifier, + [FromQuery] Include fields + ) + { + Show? ret = await libraryManager.Shows.GetOrDefault( + identifier.IsContainedIn(x => x.Seasons!), + fields + ); + if (ret == null) + return NotFound(); + return ret; + } +} diff --git a/src/Kyoo.Core/Views/Resources/ShowApi.cs b/src/Kyoo.Core/Views/Resources/ShowApi.cs new file mode 100644 index 0000000..0517095 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/ShowApi.cs @@ -0,0 +1,295 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("shows")] +[Route("show", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Show))] +[ApiDefinition("Shows", Group = ResourcesGroup)] +public class ShowApi(ILibraryManager libraryManager) : CrudThumbsApi(libraryManager.Shows) +{ + /// + /// Refresh + /// + /// + /// Ask a metadata refresh. + /// + /// The ID or slug of the . + /// Nothing + /// No show with the given ID or slug could be found. + [HttpPost("{identifier:id}/refresh")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Refresh(Identifier identifier, [FromServices] IScanner scanner) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Shows.Get(slug)).Id + ); + await scanner.SendRefreshRequest(nameof(Show), id); + return NoContent(); + } + + /// + /// Get seasons of this show + /// + /// + /// List the seasons that are part of the specified show. + /// + /// The ID or slug of the . + /// A key to sort seasons by. + /// An optional list of filters. + /// The number of seasons to return. + /// The aditional fields to include in the result. + /// A page of seasons. + /// The filters or the sort parameters are invalid. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/seasons")] + [HttpGet("{identifier:id}/season", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetSeasons( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await libraryManager.Seasons.GetAll( + Filter.And(filter, identifier.Matcher(x => x.ShowId, x => x.Show!.Slug)), + sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await libraryManager.Shows.GetOrDefault(identifier.IsSame()) == null + ) + return NotFound(); + return Page(resources, pagination.Limit); + } + + /// + /// Get episodes of this show + /// + /// + /// List the episodes that are part of the specified show. + /// + /// The ID or slug of the . + /// A key to sort episodes by. + /// An optional list of filters. + /// The number of episodes to return. + /// The aditional fields to include in the result. + /// A page of episodes. + /// The filters or the sort parameters are invalid. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/episodes")] + [HttpGet("{identifier:id}/episode", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetEpisodes( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await libraryManager.Episodes.GetAll( + Filter.And(filter, identifier.Matcher(x => x.ShowId, x => x.Show!.Slug)), + sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await libraryManager.Shows.GetOrDefault(identifier.IsSame()) == null + ) + return NotFound(); + return Page(resources, pagination.Limit); + } + + /// + /// Get studio that made the show + /// + /// + /// Get the studio that made the show. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The studio that made the show. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/studio")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetStudio( + Identifier identifier, + [FromQuery] Include fields + ) + { + return await libraryManager.Studios.Get( + identifier.IsContainedIn(x => x.Shows!), + fields + ); + } + + /// + /// Get collections containing this show + /// + /// + /// List the collections that contain this show. + /// + /// The ID or slug of the . + /// A key to sort collections by. + /// An optional list of filters. + /// The number of collections to return. + /// The aditional fields to include in the result. + /// A page of collections. + /// The filters or the sort parameters are invalid. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/collections")] + [HttpGet("{identifier:id}/collection", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetCollections( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await libraryManager.Collections.GetAll( + Filter.And(filter, identifier.IsContainedIn(x => x.Shows!)), + sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await libraryManager.Shows.GetOrDefault(identifier.IsSame()) == null + ) + return NotFound(); + return Page(resources, pagination.Limit); + } + + /// + /// Get watch status + /// + /// + /// Get when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The status. + /// This show does not have a specific status. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetWatchStatus(Identifier identifier) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Shows.Get(slug)).Id + ); + return await libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow()); + } + + /// + /// Set watch status + /// + /// + /// Set when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The new watch status. + /// The newly set status. + /// The status has been set + /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). + /// No movie with the given ID or slug could be found. + [HttpPost("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetWatchStatus(Identifier identifier, WatchStatus status) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Shows.Get(slug)).Id + ); + return await libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status); + } + + /// + /// Delete watch status + /// + /// + /// Delete watch status (to rewatch for example). + /// + /// The ID or slug of the . + /// The newly set status. + /// The status has been deleted. + /// No show with the given ID or slug could be found. + [HttpDelete("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteWatchStatus(Identifier identifier) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Shows.Get(slug)).Id + ); + await libraryManager.WatchStatus.DeleteShowStatus(id, User.GetIdOrThrow()); + } +} diff --git a/src/Kyoo.Core/Views/Resources/UserApi.cs b/src/Kyoo.Core/Views/Resources/UserApi.cs new file mode 100644 index 0000000..63d00d7 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/UserApi.cs @@ -0,0 +1,116 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.IO; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("users")] +[Route("user", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(User), Group = Group.Admin)] +[ApiDefinition("Users", Group = ResourcesGroup)] +public class UserApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) + : CrudApi(libraryManager!.Users) +{ + /// + /// Get profile picture + /// + /// + /// Get the profile picture of someone + /// + [HttpGet("{identifier:id}/logo")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task GetProfilePicture(Identifier identifier) + { + Guid gid = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Users.Get(slug)).Id + ); + Stream img = await thumbs.GetUserImage(gid); + if (identifier.Is("random")) + Response.Headers.CacheControl = $"public, no-store"; + else + { + // Allow clients to cache the image for 6 month. + Response.Headers.CacheControl = $"public, max-age={60 * 60 * 24 * 31 * 6}"; + } + return File(img, "image/webp", true); + } + + /// + /// Set profile picture + /// + /// + /// Set user profile picture + /// + [HttpPost("{identifier:id}/logo")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task SetProfilePicture(Identifier identifier, IFormFile picture) + { + if (picture == null || picture.Length == 0) + return BadRequest(); + Guid gid = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Users.Get(slug)).Id + ); + await thumbs.SetUserImage(gid, picture.OpenReadStream()); + return NoContent(); + } + + /// + /// Delete profile picture + /// + /// + /// Delete your profile picture + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpDelete("{identifier:id}/logo")] + [PartialPermission(Kind.Delete)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task DeleteProfilePicture(Identifier identifier) + { + Guid gid = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Users.Get(slug)).Id + ); + await thumbs.SetUserImage(gid, null); + return NoContent(); + } +} diff --git a/src/Kyoo.Core/Views/Resources/WatchlistApi.cs b/src/Kyoo.Core/Views/Resources/WatchlistApi.cs new file mode 100644 index 0000000..0e733d6 --- /dev/null +++ b/src/Kyoo.Core/Views/Resources/WatchlistApi.cs @@ -0,0 +1,71 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// List times on the user's watchlist +/// +[Route("watchlist")] +[ApiController] +[PartialPermission("Watchlist")] +[ApiDefinition("Watchlist", Group = ResourcesGroup)] +[UserOnly] +public class WatchlistApi(IWatchStatusRepository repository) : BaseApi +{ + /// + /// Get all + /// + /// + /// Get all resources in the user's watchlist + /// + /// Filter the returned items. + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of resources that match every filters. + /// Invalid filters or sort information. + [HttpGet] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task>> GetAll( + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) + { + if (User.GetId() == null) + throw new UnauthorizedException(); + ICollection resources = await repository.GetAll(filter, fields, pagination); + + return Page(resources, pagination.Limit); + } +} diff --git a/src/Kyoo.Meilisearch/FilterExtensionMethods.cs b/src/Kyoo.Meilisearch/FilterExtensionMethods.cs new file mode 100644 index 0000000..d32d2a6 --- /dev/null +++ b/src/Kyoo.Meilisearch/FilterExtensionMethods.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using Kyoo.Abstractions.Models.Utils; +using static System.Text.Json.JsonNamingPolicy; + +namespace Kyoo.Meiliseach; + +internal static class FilterExtensionMethods +{ + public static string? CreateMeilisearchFilter(this Filter? filter) + { + return filter switch + { + Filter.And and + => $"({and.First.CreateMeilisearchFilter()}) AND ({and.Second.CreateMeilisearchFilter()})", + Filter.Or or + => $"({or.First.CreateMeilisearchFilter()}) OR ({or.Second.CreateMeilisearchFilter()})", + Filter.Gt gt => CreateBasicFilterString(gt.Property, ">", gt.Value), + Filter.Lt lt => CreateBasicFilterString(lt.Property, "<", lt.Value), + Filter.Ge ge => CreateBasicFilterString(ge.Property, ">=", ge.Value), + Filter.Le le => CreateBasicFilterString(le.Property, "<=", le.Value), + Filter.Eq eq => CreateBasicFilterString(eq.Property, "=", eq.Value), + Filter.Has has => CreateBasicFilterString(has.Property, "=", has.Value), + Filter.Ne ne => CreateBasicFilterString(ne.Property, "!=", ne.Value), + Filter.Not not => $"NOT ({not.Filter.CreateMeilisearchFilter()})", + Filter.CmpRandom + => throw new ValidationException("Random comparison is not supported."), + _ => null + }; + } + + private static string CreateBasicFilterString(string property, string @operator, object? value) + { + return $"{CamelCase.ConvertName(property)} {@operator} {value.InMeilsearchFilterFormat()}"; + } + + private static object? InMeilsearchFilterFormat(this object? value) + { + return value switch + { + null => null, + string s => s.Any(char.IsWhiteSpace) ? $"\"{s.Replace("\"", "\\\"")}\"" : s, + DateTimeOffset dateTime => dateTime.ToUnixTimeSeconds(), + DateOnly date => date.ToUnixTimeSeconds(), + _ => value + }; + } + + public static long ToUnixTimeSeconds(this DateOnly date) + { + return new DateTimeOffset(date.ToDateTime(new TimeOnly())).ToUnixTimeSeconds(); + } +} diff --git a/src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj b/src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj new file mode 100644 index 0000000..e081479 --- /dev/null +++ b/src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj @@ -0,0 +1,15 @@ + + + enable + Kyoo.Meilisearch + + + + + + + + + + + diff --git a/src/Kyoo.Meilisearch/MeiliSync.cs b/src/Kyoo.Meilisearch/MeiliSync.cs new file mode 100644 index 0000000..e995ce6 --- /dev/null +++ b/src/Kyoo.Meilisearch/MeiliSync.cs @@ -0,0 +1,112 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections; +using System.Dynamic; +using System.Reflection; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Meilisearch; +using static System.Text.Json.JsonNamingPolicy; + +namespace Kyoo.Meiliseach; + +public class MeiliSync +{ + private readonly MeilisearchClient _client; + + public MeiliSync(MeilisearchClient client) + { + _client = client; + + IRepository.OnCreated += (x) => CreateOrUpdate("items", x, nameof(Movie)); + IRepository.OnEdited += (x) => CreateOrUpdate("items", x, nameof(Movie)); + IRepository.OnDeleted += (x) => _Delete("items", x.Id, nameof(Movie)); + IRepository.OnCreated += (x) => CreateOrUpdate("items", x, nameof(Show)); + IRepository.OnEdited += (x) => CreateOrUpdate("items", x, nameof(Show)); + IRepository.OnDeleted += (x) => _Delete("items", x.Id, nameof(Show)); + IRepository.OnCreated += (x) => CreateOrUpdate("items", x, nameof(Collection)); + IRepository.OnEdited += (x) => CreateOrUpdate("items", x, nameof(Collection)); + IRepository.OnDeleted += (x) => _Delete("items", x.Id, nameof(Collection)); + + IRepository.OnCreated += (x) => CreateOrUpdate(nameof(Episode), x); + IRepository.OnEdited += (x) => CreateOrUpdate(nameof(Episode), x); + IRepository.OnDeleted += (x) => _Delete(nameof(Episode), x.Id); + + IRepository.OnCreated += (x) => CreateOrUpdate(nameof(Studio), x); + IRepository.OnEdited += (x) => CreateOrUpdate(nameof(Studio), x); + IRepository.OnDeleted += (x) => _Delete(nameof(Studio), x.Id); + } + + public Task CreateOrUpdate(string index, IResource item, string? kind = null) + { + if (kind != null) + { + dynamic expando = new ExpandoObject(); + var dictionary = (IDictionary)expando; + + foreach (PropertyInfo property in item.GetType().GetProperties()) + dictionary.Add( + CamelCase.ConvertName(property.Name), + ConvertToMeilisearchFormat(property.GetValue(item)) + ); + dictionary.Add("ref", $"{kind}-{item.Id}"); + expando.kind = kind; + return _client.Index(index).AddDocumentsAsync(new[] { expando }); + } + return _client.Index(index).AddDocumentsAsync(new[] { item }); + } + + private Task _Delete(string index, Guid id, string? kind = null) + { + if (kind != null) + { + return _client.Index(index).DeleteOneDocumentAsync($"{kind}/{id}"); + } + return _client.Index(index).DeleteOneDocumentAsync(id.ToString()); + } + + private object? ConvertToMeilisearchFormat(object? value) + { + return value switch + { + null => null, + string => value, + Enum => value.ToString(), + IEnumerable enumerable + => enumerable.Cast().Select(ConvertToMeilisearchFormat).ToArray(), + DateTimeOffset dateTime => dateTime.ToUnixTimeSeconds(), + DateOnly date => date.ToUnixTimeSeconds(), + _ => value + }; + } + + public async Task SyncEverything(ILibraryManager database) + { + foreach (Movie movie in await database.Movies.GetAll(limit: 0)) + await CreateOrUpdate("items", movie, nameof(Movie)); + foreach (Show show in await database.Shows.GetAll(limit: 0)) + await CreateOrUpdate("items", show, nameof(Show)); + foreach (Collection collection in await database.Collections.GetAll(limit: 0)) + await CreateOrUpdate("items", collection, nameof(Collection)); + foreach (Episode episode in await database.Episodes.GetAll(limit: 0)) + await CreateOrUpdate(nameof(Episode), episode); + foreach (Studio studio in await database.Studios.GetAll(limit: 0)) + await CreateOrUpdate(nameof(Studio), studio); + } +} diff --git a/src/Kyoo.Meilisearch/MeilisearchModule.cs b/src/Kyoo.Meilisearch/MeilisearchModule.cs new file mode 100644 index 0000000..d3e6f47 --- /dev/null +++ b/src/Kyoo.Meilisearch/MeilisearchModule.cs @@ -0,0 +1,160 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Meilisearch; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using static System.Text.Json.JsonNamingPolicy; + +namespace Kyoo.Meiliseach; + +public static class MeilisearchModule +{ + public static Dictionary IndexSettings => + new() + { + { + "items", + new Settings() + { + SearchableAttributes = new[] + { + CamelCase.ConvertName(nameof(Movie.Name)), + CamelCase.ConvertName(nameof(Movie.Slug)), + CamelCase.ConvertName(nameof(Movie.Aliases)), + CamelCase.ConvertName(nameof(Movie.Path)), + CamelCase.ConvertName(nameof(Movie.Tags)), + CamelCase.ConvertName(nameof(Movie.Overview)), + }, + FilterableAttributes = new[] + { + CamelCase.ConvertName(nameof(Movie.Genres)), + CamelCase.ConvertName(nameof(Movie.Status)), + CamelCase.ConvertName(nameof(Movie.AirDate)), + CamelCase.ConvertName(nameof(Show.StartAir)), + CamelCase.ConvertName(nameof(Show.EndAir)), + CamelCase.ConvertName(nameof(Movie.StudioId)), + "kind" + }, + SortableAttributes = new[] + { + CamelCase.ConvertName(nameof(Movie.AirDate)), + CamelCase.ConvertName(nameof(Movie.AddedDate)), + CamelCase.ConvertName(nameof(Movie.Rating)), + CamelCase.ConvertName(nameof(Movie.Runtime)), + }, + DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Movie.Id)), "kind" }, + RankingRules = new[] + { + "words", + "typo", + "proximity", + "attribute", + "sort", + "exactness", + $"{CamelCase.ConvertName(nameof(Movie.Rating))}:desc", + } + // TODO: Add stopwords + } + }, + { + nameof(Episode), + new Settings() + { + SearchableAttributes = new[] + { + CamelCase.ConvertName(nameof(Episode.Name)), + CamelCase.ConvertName(nameof(Episode.Overview)), + CamelCase.ConvertName(nameof(Episode.Slug)), + CamelCase.ConvertName(nameof(Episode.Path)), + }, + FilterableAttributes = new[] + { + CamelCase.ConvertName(nameof(Episode.SeasonNumber)), + }, + SortableAttributes = new[] + { + CamelCase.ConvertName(nameof(Episode.ReleaseDate)), + CamelCase.ConvertName(nameof(Episode.AddedDate)), + CamelCase.ConvertName(nameof(Episode.SeasonNumber)), + CamelCase.ConvertName(nameof(Episode.EpisodeNumber)), + CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)), + }, + DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Episode.Id)), }, + // TODO: Add stopwords + } + }, + { + nameof(Studio), + new Settings() + { + SearchableAttributes = new[] + { + CamelCase.ConvertName(nameof(Studio.Name)), + CamelCase.ConvertName(nameof(Studio.Slug)), + }, + FilterableAttributes = Array.Empty(), + SortableAttributes = Array.Empty(), + DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), }, + // TODO: Add stopwords + } + }, + }; + + public static async Task Initialize(IServiceProvider provider) + { + MeilisearchClient client = provider.GetRequiredService(); + + await _CreateIndex(client, "items", true); + await _CreateIndex(client, nameof(Episode), false); + await _CreateIndex(client, nameof(Studio), false); + } + + public static async Task SyncDatabase(IServiceProvider provider) + { + await using AsyncServiceScope scope = provider.CreateAsyncScope(); + ILibraryManager database = scope.ServiceProvider.GetRequiredService(); + await scope.ServiceProvider.GetRequiredService().SyncEverything(database); + } + + private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind) + { + TaskInfo task = await client.CreateIndexAsync( + index, + hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id)) + ); + await client.WaitForTaskAsync(task.TaskUid); + await client.Index(index).UpdateSettingsAsync(IndexSettings[index]); + } + + /// + public static void ConfigureMeilisearch(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton( + new MeilisearchClient( + builder.Configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"), + builder.Configuration.GetValue("MEILI_MASTER_KEY") + ) + ); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + } +} diff --git a/src/Kyoo.Meilisearch/SearchManager.cs b/src/Kyoo.Meilisearch/SearchManager.cs new file mode 100644 index 0000000..7f7ccaf --- /dev/null +++ b/src/Kyoo.Meilisearch/SearchManager.cs @@ -0,0 +1,229 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.ComponentModel.DataAnnotations; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; +using Meilisearch; +using static System.Text.Json.JsonNamingPolicy; + +namespace Kyoo.Meiliseach; + +public class SearchManager : ISearchManager +{ + private readonly MeilisearchClient _client; + private readonly ILibraryManager _libraryManager; + + private static IEnumerable _GetSortsBy(string index, Sort? sort) + where T : IQuery + { + return sort switch + { + Sort.Default => Array.Empty(), + Sort.By @sortBy + => MeilisearchModule + .IndexSettings[index] + .SortableAttributes.Contains( + sortBy.Key, + StringComparer.InvariantCultureIgnoreCase + ) + ? new[] + { + $"{CamelCase.ConvertName(sortBy.Key)}:{(sortBy.Desendant ? "desc" : "asc")}" + } + : throw new ValidationException($"Invalid sorting mode: {sortBy.Key}"), + Sort.Conglomerate(var list) => list.SelectMany(x => _GetSortsBy(index, x)), + Sort.Random + => throw new ValidationException( + "Random sorting is not supported while searching." + ), + _ => Array.Empty(), + }; + } + + public SearchManager(MeilisearchClient client, ILibraryManager libraryManager) + { + _client = client; + _libraryManager = libraryManager; + } + + private async Task.SearchResult> _Search( + string index, + string? query, + string? where = null, + Sort? sortBy = default, + SearchPagination? pagination = default, + Include? include = default + ) + where T : class, IResource, IQuery + { + // TODO: add filters and facets + ISearchable res = await _client + .Index(index) + .SearchAsync( + query, + new SearchQuery() + { + Filter = where, + Sort = _GetSortsBy(index, sortBy), + Limit = pagination?.Limit ?? 50, + Offset = pagination?.Skip ?? 0, + } + ); + return new SearchPage.SearchResult + { + Query = query, + Items = await _libraryManager + .Repository() + .FromIds(res.Hits.Select(x => x.Id).ToList(), include), + }; + } + + /// + public Task.SearchResult> SearchItems( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ) + { + return _Search( + "items", + query, + filter.CreateMeilisearchFilter(), + sortBy, + pagination, + include + ); + } + + /// + public Task.SearchResult> SearchMovies( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ) + { + return _Search( + "items", + query, + _CreateMediaTypeFilter(filter), + sortBy, + pagination, + include + ); + } + + /// + public Task.SearchResult> SearchShows( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ) + { + return _Search( + "items", + query, + _CreateMediaTypeFilter(filter), + sortBy, + pagination, + include + ); + } + + /// + public Task.SearchResult> SearchCollections( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ) + { + return _Search( + "items", + query, + _CreateMediaTypeFilter(filter), + sortBy, + pagination, + include + ); + } + + /// + public Task.SearchResult> SearchEpisodes( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ) + { + return _Search( + nameof(Episode), + query, + filter.CreateMeilisearchFilter(), + sortBy, + pagination, + include + ); + } + + /// + public Task.SearchResult> SearchStudios( + string? query, + Sort sortBy, + Filter? filter, + SearchPagination pagination, + Include? include = default + ) + { + return _Search( + nameof(Studio), + query, + filter.CreateMeilisearchFilter(), + sortBy, + pagination, + include + ); + } + + private string _CreateMediaTypeFilter(Filter? filter) + where T : ILibraryItem + { + string filterString = $"kind = {typeof(T).Name}"; + if (filter is not null) + { + filterString += $" AND ({filter.CreateMeilisearchFilter()})"; + } + return filterString; + } + + private class IdResource + { + public Guid Id { get; set; } + + public string? Kind { get; set; } + } +} diff --git a/src/Kyoo.Postgresql/DatabaseContext.cs b/src/Kyoo.Postgresql/DatabaseContext.cs new file mode 100644 index 0000000..7a717cc --- /dev/null +++ b/src/Kyoo.Postgresql/DatabaseContext.cs @@ -0,0 +1,476 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Exceptions; +using Kyoo.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Kyoo.Postgresql; + +public abstract class DatabaseContext : DbContext +{ + private readonly IHttpContextAccessor _accessor; + + /// + /// Calculate the MD5 of a string, can only be used in database context. + /// + /// The string to hash + /// The hash + public static string MD5(string str) => throw new NotSupportedException(); + + public Guid? CurrentUserId => _accessor.HttpContext?.User.GetId(); + + public DbSet Collections { get; set; } + + public DbSet Movies { get; set; } + + public DbSet Shows { get; set; } + + public DbSet Seasons { get; set; } + + public DbSet Episodes { get; set; } + + public DbSet Studios { get; set; } + + public DbSet Users { get; set; } + + public DbSet MovieWatchStatus { get; set; } + + public DbSet ShowWatchStatus { get; set; } + + public DbSet EpisodeWatchStatus { get; set; } + + public DbSet Issues { get; set; } + + public DbSet Options { get; set; } + + /// + /// Add a many to many link between two resources. + /// + /// Types are order dependant. You can't inverse the order. Please always put the owner first. + /// The ID of the first resource. + /// The ID of the second resource. + /// The first resource type of the relation. It is the owner of the second + /// The second resource type of the relation. It is the contained resource. + public void AddLinks(Guid first, Guid second) + where T1 : class, IResource + where T2 : class, IResource + { + Set>(LinkName()) + .Add( + new Dictionary + { + [LinkNameFk()] = first, + [LinkNameFk()] = second + } + ); + } + + protected DatabaseContext(IHttpContextAccessor accessor) + { + _accessor = accessor; + } + + protected DatabaseContext(DbContextOptions options, IHttpContextAccessor accessor) + : base(options) + { + _accessor = accessor; + } + + protected abstract string LinkName() + where T : IResource + where T2 : IResource; + + protected abstract string LinkNameFk() + where T : IResource; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + } + + private static void _HasJson( + ModelBuilder builder, + Expression>> property + ) + where T : class + { + // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 + // modelBuilder.Entity() + // .OwnsOne(x => x.ExternalId, x => + // { + // x.ToJson(); + // }); + builder + .Entity() + .Property(property) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => + JsonSerializer.Deserialize>( + v, + (JsonSerializerOptions?)null + )! + ) + .HasColumnType("json"); + builder + .Entity() + .Property(property) + .Metadata.SetValueComparer( + new ValueComparer>( + (c1, c2) => c1!.SequenceEqual(c2!), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())) + ) + ); + } + + private static void _HasMetadata(ModelBuilder modelBuilder) + where T : class, IMetadata + { + _HasJson(modelBuilder, x => x.ExternalId); + } + + private static void _HasImages(ModelBuilder modelBuilder) + where T : class, IThumbnails + { + modelBuilder.Entity().OwnsOne(x => x.Poster, x => x.ToJson()); + modelBuilder.Entity().OwnsOne(x => x.Thumbnail, x => x.ToJson()); + modelBuilder.Entity().OwnsOne(x => x.Logo, x => x.ToJson()); + } + + private static void _HasAddedDate(ModelBuilder modelBuilder) + where T : class, IAddedDate + { + modelBuilder + .Entity() + .Property(x => x.AddedDate) + .HasDefaultValueSql("now() at time zone 'utc'") + .ValueGeneratedOnAdd(); + } + + private static void _HasRefreshDate(ModelBuilder builder) + where T : class, IRefreshable + { + // schedule a refresh soon since metadata can change frequently for recently added items ond online databases + builder + .Entity() + .Property(x => x.NextMetadataRefresh) + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'") + .ValueGeneratedOnAdd(); + } + + private void _HasManyToMany( + ModelBuilder modelBuilder, + Expression?>> firstNavigation, + Expression?>> secondNavigation + ) + where T : class, IResource + where T2 : class, IResource + { + modelBuilder + .Entity() + .HasMany(secondNavigation) + .WithMany(firstNavigation) + .UsingEntity>( + LinkName(), + x => + x.HasOne() + .WithMany() + .HasForeignKey(LinkNameFk()) + .OnDelete(DeleteBehavior.Cascade), + x => + x.HasOne() + .WithMany() + .HasForeignKey(LinkNameFk()) + .OnDelete(DeleteBehavior.Cascade) + ); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity().Ignore(x => x.FirstEpisode).Ignore(x => x.AirDate); + modelBuilder.Entity().Ignore(x => x.PreviousEpisode).Ignore(x => x.NextEpisode); + + modelBuilder + .Entity() + .HasMany(x => x.Seasons) + .WithOne(x => x.Show) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasMany(x => x.Episodes) + .WithOne(x => x.Show) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasMany(x => x.Episodes) + .WithOne(x => x.Season) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder + .Entity() + .HasOne(x => x.Studio) + .WithMany(x => x.Movies) + .OnDelete(DeleteBehavior.SetNull); + modelBuilder + .Entity() + .HasOne(x => x.Studio) + .WithMany(x => x.Shows) + .OnDelete(DeleteBehavior.SetNull); + + _HasManyToMany(modelBuilder, x => x.Movies, x => x.Collections); + _HasManyToMany(modelBuilder, x => x.Shows, x => x.Collections); + + _HasMetadata(modelBuilder); + _HasMetadata(modelBuilder); + _HasMetadata(modelBuilder); + _HasMetadata(modelBuilder); + _HasMetadata(modelBuilder); + _HasJson(modelBuilder, x => x.ExternalId); + + _HasImages(modelBuilder); + _HasImages(modelBuilder); + _HasImages(modelBuilder); + _HasImages(modelBuilder); + _HasImages(modelBuilder); + + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + + _HasRefreshDate(modelBuilder); + _HasRefreshDate(modelBuilder); + _HasRefreshDate(modelBuilder); + _HasRefreshDate(modelBuilder); + _HasRefreshDate(modelBuilder); + + modelBuilder + .Entity() + .HasKey(x => new { User = x.UserId, Movie = x.MovieId }); + modelBuilder + .Entity() + .HasKey(x => new { User = x.UserId, Show = x.ShowId }); + modelBuilder + .Entity() + .HasKey(x => new { User = x.UserId, Episode = x.EpisodeId }); + + modelBuilder + .Entity() + .HasOne(x => x.Movie) + .WithMany(x => x.Watched) + .HasForeignKey(x => x.MovieId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasOne(x => x.Show) + .WithMany(x => x.Watched) + .HasForeignKey(x => x.ShowId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasOne(x => x.NextEpisode) + .WithMany() + .HasForeignKey(x => x.NextEpisodeId) + .OnDelete(DeleteBehavior.SetNull); + modelBuilder + .Entity() + .HasOne(x => x.Episode) + .WithMany(x => x.Watched) + .HasForeignKey(x => x.EpisodeId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); + + modelBuilder.Entity().Navigation(x => x.NextEpisode).AutoInclude(); + + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + + modelBuilder.Entity().Ignore(x => x.WatchStatus); + modelBuilder.Entity().Ignore(x => x.WatchStatus); + modelBuilder.Entity().Ignore(x => x.WatchStatus); + + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder + .Entity() + .HasIndex(x => new { ShowID = x.ShowId, x.SeasonNumber }) + .IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder + .Entity() + .HasIndex(x => new + { + ShowID = x.ShowId, + x.SeasonNumber, + x.EpisodeNumber, + x.AbsoluteNumber + }) + .IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Username).IsUnique(); + + modelBuilder.Entity().Ignore(x => x.Links); + + modelBuilder.Entity().HasKey(x => new { x.Domain, x.Cause }); + + _HasJson(modelBuilder, x => x.Settings); + _HasJson(modelBuilder, x => x.ExternalId); + _HasJson(modelBuilder, x => x.Extra); + + modelBuilder.Entity().HasKey(x => x.Key); + } + + public override int SaveChanges() + { + try + { + return base.SaveChanges(); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(); + throw; + } + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + try + { + return base.SaveChanges(acceptAllChangesOnSuccess); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(); + throw; + } + } + + public override async Task SaveChangesAsync( + bool acceptAllChangesOnSuccess, + CancellationToken cancellationToken = default + ) + { + try + { + return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(); + throw; + } + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + try + { + return await base.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(); + throw; + } + } + + public async Task SaveChangesAsync( + Func> getExisting, + CancellationToken cancellationToken = default + ) + { + try + { + return await SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(await getExisting()); + throw; + } + catch (DuplicatedItemException) + { + throw new DuplicatedItemException(await getExisting()); + } + } + + public async Task SaveIfNoDuplicates(CancellationToken cancellationToken = default) + { + try + { + return await SaveChangesAsync(cancellationToken); + } + catch (DuplicatedItemException) + { + return -1; + } + } + + public T? LocalEntity(string slug) + where T : class, IResource + { + return ChangeTracker.Entries().FirstOrDefault(x => x.Entity.Slug == slug)?.Entity; + } + + protected abstract bool IsDuplicateException(Exception ex); + + public void DiscardChanges() + { + foreach ( + EntityEntry entry in ChangeTracker.Entries().Where(x => x.State != EntityState.Detached) + ) + { + entry.State = EntityState.Detached; + } + } +} diff --git a/src/Kyoo.Postgresql/DbConfigurationProvider.cs b/src/Kyoo.Postgresql/DbConfigurationProvider.cs new file mode 100644 index 0000000..9fa1b1e --- /dev/null +++ b/src/Kyoo.Postgresql/DbConfigurationProvider.cs @@ -0,0 +1,28 @@ +using System; +using System.Linq; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +public class DbConfigurationProvider(Action action) : ConfigurationProvider +{ + public override void Load() + { + DbContextOptionsBuilder builder = new(); + action(builder); + using var context = new PostgresContext(builder.Options, null!); + Data = context.Options.ToDictionary(c => c.Key, c => c.Value)!; + } +} + +public class DbConfigurationSource(Action action) : IConfigurationSource +{ + public IConfigurationProvider Build(IConfigurationBuilder builder) => + new DbConfigurationProvider(action); +} + +public class ServerOption +{ + public string Key { get; set; } + public string Value { get; set; } +} diff --git a/src/Kyoo.Postgresql/Kyoo.Postgresql.csproj b/src/Kyoo.Postgresql/Kyoo.Postgresql.csproj new file mode 100644 index 0000000..d68944f --- /dev/null +++ b/src/Kyoo.Postgresql/Kyoo.Postgresql.csproj @@ -0,0 +1,28 @@ + + + Kyoo.Postgresql + Kyoo.Postgresql + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/Kyoo.Postgresql/Migrations/20231128171554_Initial.Designer.cs b/src/Kyoo.Postgresql/Migrations/20231128171554_Initial.Designer.cs new file mode 100644 index 0000000..aa0ea92 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20231128171554_Initial.Designer.cs @@ -0,0 +1,1082 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20231128171554_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("UserId"); + + b1.ToTable("users"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_id"); + }); + + b.Navigation("Logo"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20231128171554_Initial.cs b/src/Kyoo.Postgresql/Migrations/20231128171554_Initial.cs new file mode 100644 index 0000000..f1c7b92 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20231128171554_Initial.cs @@ -0,0 +1,560 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class Initial : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder + .AlterDatabase() + .Annotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned"); + + migrationBuilder.CreateTable( + name: "collections", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: false + ), + name = table.Column(type: "text", nullable: false), + overview = table.Column(type: "text", nullable: true), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ), + poster_source = table.Column(type: "text", nullable: true), + poster_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + thumbnail_source = table.Column(type: "text", nullable: true), + thumbnail_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + logo_source = table.Column(type: "text", nullable: true), + logo_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + external_id = table.Column(type: "json", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_collections", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "studios", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: false + ), + name = table.Column(type: "text", nullable: false), + external_id = table.Column(type: "json", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_studios", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: false + ), + username = table.Column(type: "text", nullable: false), + email = table.Column(type: "text", nullable: false), + password = table.Column(type: "text", nullable: false), + permissions = table.Column(type: "text[]", nullable: false), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ), + logo_source = table.Column(type: "text", nullable: true), + logo_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + } + ); + + migrationBuilder.CreateTable( + name: "movies", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: false + ), + name = table.Column(type: "text", nullable: false), + tagline = table.Column(type: "text", nullable: true), + aliases = table.Column(type: "text[]", nullable: false), + path = table.Column(type: "text", nullable: false), + overview = table.Column(type: "text", nullable: true), + tags = table.Column(type: "text[]", nullable: false), + genres = table.Column(type: "genre[]", nullable: false), + status = table.Column(type: "status", nullable: false), + rating = table.Column(type: "integer", nullable: false), + runtime = table.Column(type: "integer", nullable: false), + air_date = table.Column(type: "timestamp with time zone", nullable: true), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ), + poster_source = table.Column(type: "text", nullable: true), + poster_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + thumbnail_source = table.Column(type: "text", nullable: true), + thumbnail_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + logo_source = table.Column(type: "text", nullable: true), + logo_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + trailer = table.Column(type: "text", nullable: true), + external_id = table.Column(type: "json", nullable: false), + studio_id = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_movies", x => x.id); + table.ForeignKey( + name: "fk_movies_studios_studio_id", + column: x => x.studio_id, + principalTable: "studios", + principalColumn: "id", + onDelete: ReferentialAction.SetNull + ); + } + ); + + migrationBuilder.CreateTable( + name: "shows", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: false + ), + name = table.Column(type: "text", nullable: false), + tagline = table.Column(type: "text", nullable: true), + aliases = table.Column>(type: "text[]", nullable: false), + overview = table.Column(type: "text", nullable: true), + tags = table.Column>(type: "text[]", nullable: false), + genres = table.Column>(type: "genre[]", nullable: false), + status = table.Column(type: "status", nullable: false), + rating = table.Column(type: "integer", nullable: false), + start_air = table.Column( + type: "timestamp with time zone", + nullable: true + ), + end_air = table.Column(type: "timestamp with time zone", nullable: true), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ), + poster_source = table.Column(type: "text", nullable: true), + poster_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + thumbnail_source = table.Column(type: "text", nullable: true), + thumbnail_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + logo_source = table.Column(type: "text", nullable: true), + logo_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + trailer = table.Column(type: "text", nullable: true), + external_id = table.Column(type: "json", nullable: false), + studio_id = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_shows", x => x.id); + table.ForeignKey( + name: "fk_shows_studios_studio_id", + column: x => x.studio_id, + principalTable: "studios", + principalColumn: "id", + onDelete: ReferentialAction.SetNull + ); + } + ); + + migrationBuilder.CreateTable( + name: "link_collection_movie", + columns: table => new + { + collection_id = table.Column(type: "uuid", nullable: false), + movie_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey( + "pk_link_collection_movie", + x => new { x.collection_id, x.movie_id } + ); + table.ForeignKey( + name: "fk_link_collection_movie_collections_collection_id", + column: x => x.collection_id, + principalTable: "collections", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "fk_link_collection_movie_movies_movie_id", + column: x => x.movie_id, + principalTable: "movies", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "link_collection_show", + columns: table => new + { + collection_id = table.Column(type: "uuid", nullable: false), + show_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey( + "pk_link_collection_show", + x => new { x.collection_id, x.show_id } + ); + table.ForeignKey( + name: "fk_link_collection_show_collections_collection_id", + column: x => x.collection_id, + principalTable: "collections", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "fk_link_collection_show_shows_show_id", + column: x => x.show_id, + principalTable: "shows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "seasons", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: false + ), + show_id = table.Column(type: "uuid", nullable: false), + season_number = table.Column(type: "integer", nullable: false), + name = table.Column(type: "text", nullable: true), + overview = table.Column(type: "text", nullable: true), + start_date = table.Column( + type: "timestamp with time zone", + nullable: true + ), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ), + end_date = table.Column(type: "timestamp with time zone", nullable: true), + poster_source = table.Column(type: "text", nullable: true), + poster_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + thumbnail_source = table.Column(type: "text", nullable: true), + thumbnail_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + logo_source = table.Column(type: "text", nullable: true), + logo_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + external_id = table.Column(type: "json", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_seasons", x => x.id); + table.ForeignKey( + name: "fk_seasons_shows_show_id", + column: x => x.show_id, + principalTable: "shows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "episodes", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column( + type: "character varying(256)", + maxLength: 256, + nullable: false + ), + show_id = table.Column(type: "uuid", nullable: false), + season_id = table.Column(type: "uuid", nullable: true), + season_number = table.Column(type: "integer", nullable: true), + episode_number = table.Column(type: "integer", nullable: true), + absolute_number = table.Column(type: "integer", nullable: true), + path = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: true), + overview = table.Column(type: "text", nullable: true), + runtime = table.Column(type: "integer", nullable: false), + release_date = table.Column( + type: "timestamp with time zone", + nullable: true + ), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ), + poster_source = table.Column(type: "text", nullable: true), + poster_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + thumbnail_source = table.Column(type: "text", nullable: true), + thumbnail_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + logo_source = table.Column(type: "text", nullable: true), + logo_blurhash = table.Column( + type: "character varying(32)", + maxLength: 32, + nullable: true + ), + external_id = table.Column(type: "json", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_episodes", x => x.id); + table.ForeignKey( + name: "fk_episodes_seasons_season_id", + column: x => x.season_id, + principalTable: "seasons", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "fk_episodes_shows_show_id", + column: x => x.show_id, + principalTable: "shows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "ix_collections_slug", + table: "collections", + column: "slug", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_episodes_season_id", + table: "episodes", + column: "season_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_episodes_show_id_season_number_episode_number_absolute_numb", + table: "episodes", + columns: new[] { "show_id", "season_number", "episode_number", "absolute_number" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_episodes_slug", + table: "episodes", + column: "slug", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_link_collection_movie_movie_id", + table: "link_collection_movie", + column: "movie_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_link_collection_show_show_id", + table: "link_collection_show", + column: "show_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_movies_slug", + table: "movies", + column: "slug", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_movies_studio_id", + table: "movies", + column: "studio_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_seasons_show_id_season_number", + table: "seasons", + columns: new[] { "show_id", "season_number" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_seasons_slug", + table: "seasons", + column: "slug", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_shows_slug", + table: "shows", + column: "slug", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_shows_studio_id", + table: "shows", + column: "studio_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_studios_slug", + table: "studios", + column: "slug", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_users_slug", + table: "users", + column: "slug", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "episodes"); + + migrationBuilder.DropTable(name: "link_collection_movie"); + + migrationBuilder.DropTable(name: "link_collection_show"); + + migrationBuilder.DropTable(name: "users"); + + migrationBuilder.DropTable(name: "seasons"); + + migrationBuilder.DropTable(name: "movies"); + + migrationBuilder.DropTable(name: "collections"); + + migrationBuilder.DropTable(name: "shows"); + + migrationBuilder.DropTable(name: "studios"); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.Designer.cs b/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.Designer.cs new file mode 100644 index 0000000..2d6f552 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.Designer.cs @@ -0,0 +1,1299 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20231204000849_Watchlist")] + partial class Watchlist + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("UserId"); + + b1.ToTable("users"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_id"); + }); + + b.Navigation("Logo"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.cs b/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.cs new file mode 100644 index 0000000..6b2ca66 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20231204000849_Watchlist.cs @@ -0,0 +1,220 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using Kyoo.Abstractions.Models; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class Watchlist : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder + .AlterDatabase() + .Annotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .Annotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned") + .OldAnnotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned"); + + migrationBuilder.CreateTable( + name: "episode_watch_status", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + episode_id = table.Column(type: "uuid", nullable: false), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ), + played_date = table.Column( + type: "timestamp with time zone", + nullable: true + ), + status = table.Column(type: "watch_status", nullable: false), + watched_time = table.Column(type: "integer", nullable: true), + watched_percent = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_episode_watch_status", x => new { x.user_id, x.episode_id }); + table.ForeignKey( + name: "fk_episode_watch_status_episodes_episode_id", + column: x => x.episode_id, + principalTable: "episodes", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "fk_episode_watch_status_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "movie_watch_status", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + movie_id = table.Column(type: "uuid", nullable: false), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ), + played_date = table.Column( + type: "timestamp with time zone", + nullable: true + ), + status = table.Column(type: "watch_status", nullable: false), + watched_time = table.Column(type: "integer", nullable: true), + watched_percent = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_movie_watch_status", x => new { x.user_id, x.movie_id }); + table.ForeignKey( + name: "fk_movie_watch_status_movies_movie_id", + column: x => x.movie_id, + principalTable: "movies", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "fk_movie_watch_status_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "show_watch_status", + columns: table => new + { + user_id = table.Column(type: "uuid", nullable: false), + show_id = table.Column(type: "uuid", nullable: false), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ), + played_date = table.Column( + type: "timestamp with time zone", + nullable: true + ), + status = table.Column(type: "watch_status", nullable: false), + unseen_episodes_count = table.Column(type: "integer", nullable: false), + next_episode_id = table.Column(type: "uuid", nullable: true), + watched_time = table.Column(type: "integer", nullable: true), + watched_percent = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_show_watch_status", x => new { x.user_id, x.show_id }); + table.ForeignKey( + name: "fk_show_watch_status_episodes_next_episode_id", + column: x => x.next_episode_id, + principalTable: "episodes", + principalColumn: "id" + ); + table.ForeignKey( + name: "fk_show_watch_status_shows_show_id", + column: x => x.show_id, + principalTable: "shows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "fk_show_watch_status_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "ix_episode_watch_status_episode_id", + table: "episode_watch_status", + column: "episode_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_movie_watch_status_movie_id", + table: "movie_watch_status", + column: "movie_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_show_watch_status_next_episode_id", + table: "show_watch_status", + column: "next_episode_id" + ); + + migrationBuilder.CreateIndex( + name: "ix_show_watch_status_show_id", + table: "show_watch_status", + column: "show_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "episode_watch_status"); + + migrationBuilder.DropTable(name: "movie_watch_status"); + + migrationBuilder.DropTable(name: "show_watch_status"); + + migrationBuilder + .AlterDatabase() + .Annotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .OldAnnotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .OldAnnotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned"); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20231220093441_Settings.Designer.cs b/src/Kyoo.Postgresql/Migrations/20231220093441_Settings.Designer.cs new file mode 100644 index 0000000..3b23bdc --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20231220093441_Settings.Designer.cs @@ -0,0 +1,1304 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20231220093441_Settings")] + partial class Settings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("UserId"); + + b1.ToTable("users"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_id"); + }); + + b.Navigation("Logo"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20231220093441_Settings.cs b/src/Kyoo.Postgresql/Migrations/20231220093441_Settings.cs new file mode 100644 index 0000000..8eb837c --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20231220093441_Settings.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class Settings : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "settings", + table: "users", + type: "json", + nullable: false, + defaultValue: "{}" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "settings", table: "users"); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240120154137_RuntimeNullable.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240120154137_RuntimeNullable.Designer.cs new file mode 100644 index 0000000..174321c --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240120154137_RuntimeNullable.Designer.cs @@ -0,0 +1,1307 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240120154137_RuntimeNullable")] + partial class RuntimeNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("UserId"); + + b1.ToTable("users"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_id"); + }); + + b.Navigation("Logo"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240120154137_RuntimeNullable.cs b/src/Kyoo.Postgresql/Migrations/20240120154137_RuntimeNullable.cs new file mode 100644 index 0000000..63ec85c --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240120154137_RuntimeNullable.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class RuntimeNullable : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "runtime", + table: "movies", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer" + ); + + migrationBuilder.AlterColumn( + name: "runtime", + table: "episodes", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "runtime", + table: "movies", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "runtime", + table: "episodes", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true + ); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240204193443_RemoveUserLogo.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240204193443_RemoveUserLogo.Designer.cs new file mode 100644 index 0000000..62149ac --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240204193443_RemoveUserLogo.Designer.cs @@ -0,0 +1,1276 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240204193443_RemoveUserLogo")] + partial class RemoveUserLogo + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240204193443_RemoveUserLogo.cs b/src/Kyoo.Postgresql/Migrations/20240204193443_RemoveUserLogo.cs new file mode 100644 index 0000000..6907cb1 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240204193443_RemoveUserLogo.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class RemoveUserLogo : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "logo_blurhash", table: "users"); + + migrationBuilder.DropColumn(name: "logo_source", table: "users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "logo_blurhash", + table: "users", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_source", + table: "users", + type: "text", + nullable: true + ); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.Designer.cs new file mode 100644 index 0000000..19d8b20 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.Designer.cs @@ -0,0 +1,1309 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240217143306_AddIssues")] + partial class AddIssues + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.cs b/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.cs new file mode 100644 index 0000000..8aec020 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class AddIssues : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_show_watch_status_episodes_next_episode_id", + table: "show_watch_status" + ); + + migrationBuilder.CreateTable( + name: "issues", + columns: table => new + { + domain = table.Column(type: "text", nullable: false), + cause = table.Column(type: "text", nullable: false), + reason = table.Column(type: "text", nullable: false), + extra = table.Column(type: "json", nullable: false), + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ) + }, + constraints: table => + { + table.PrimaryKey("pk_issues", x => new { x.domain, x.cause }); + } + ); + + migrationBuilder.AddForeignKey( + name: "fk_show_watch_status_episodes_next_episode_id", + table: "show_watch_status", + column: "next_episode_id", + principalTable: "episodes", + principalColumn: "id", + onDelete: ReferentialAction.SetNull + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_show_watch_status_episodes_next_episode_id", + table: "show_watch_status" + ); + + migrationBuilder.DropTable(name: "issues"); + + migrationBuilder.AddForeignKey( + name: "fk_show_watch_status_episodes_next_episode_id", + table: "show_watch_status", + column: "next_episode_id", + principalTable: "episodes", + principalColumn: "id" + ); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240219170615_AddPlayPermission.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240219170615_AddPlayPermission.Designer.cs new file mode 100644 index 0000000..03822c1 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240219170615_AddPlayPermission.Designer.cs @@ -0,0 +1,1309 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240219170615_AddPlayPermission")] + partial class AddPlayPermission + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240219170615_AddPlayPermission.cs b/src/Kyoo.Postgresql/Migrations/20240219170615_AddPlayPermission.cs new file mode 100644 index 0000000..0144dbd --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240219170615_AddPlayPermission.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class AddPlayPermission : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // language=PostgreSQL + migrationBuilder.Sql( + "update users set permissions = ARRAY_APPEND(permissions, 'overall.play');" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) { } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240229202049_AddUserExternalId.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240229202049_AddUserExternalId.Designer.cs new file mode 100644 index 0000000..e4a49ee --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240229202049_AddUserExternalId.Designer.cs @@ -0,0 +1,1314 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240229202049_AddUserExternalId")] + partial class AddUserExternalId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240229202049_AddUserExternalId.cs b/src/Kyoo.Postgresql/Migrations/20240229202049_AddUserExternalId.cs new file mode 100644 index 0000000..4eda4df --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240229202049_AddUserExternalId.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class AddUserExternalId : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "external_id", + table: "users", + type: "json", + nullable: false, + defaultValue: "{}" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "external_id", table: "users"); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240302151906_MakePasswordOptional.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240302151906_MakePasswordOptional.Designer.cs new file mode 100644 index 0000000..387ae7c --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240302151906_MakePasswordOptional.Designer.cs @@ -0,0 +1,1313 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240302151906_MakePasswordOptional")] + partial class MakePasswordOptional + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240302151906_MakePasswordOptional.cs b/src/Kyoo.Postgresql/Migrations/20240302151906_MakePasswordOptional.cs new file mode 100644 index 0000000..c6ff637 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240302151906_MakePasswordOptional.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class MakePasswordOptional : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "password", + table: "users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "password", + table: "users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true + ); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240324174638_UseDateOnly.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240324174638_UseDateOnly.Designer.cs new file mode 100644 index 0000000..b8e4163 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240324174638_UseDateOnly.Designer.cs @@ -0,0 +1,1317 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240324174638_UseDateOnly")] + partial class UseDateOnly + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned", "deleted" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("date") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("date") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("date") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240324174638_UseDateOnly.cs b/src/Kyoo.Postgresql/Migrations/20240324174638_UseDateOnly.cs new file mode 100644 index 0000000..eb82ddd --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240324174638_UseDateOnly.cs @@ -0,0 +1,177 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class UseDateOnly : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder + .AlterDatabase() + .Annotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .Annotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned,deleted") + .OldAnnotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .OldAnnotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned"); + + migrationBuilder.AlterColumn( + name: "start_air", + table: "shows", + type: "date", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "end_air", + table: "shows", + type: "date", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "start_date", + table: "seasons", + type: "date", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "end_date", + table: "seasons", + type: "date", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "air_date", + table: "movies", + type: "date", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "release_date", + table: "episodes", + type: "date", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true + ); + + migrationBuilder.CreateIndex( + name: "ix_users_username", + table: "users", + column: "username", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex(name: "ix_users_username", table: "users"); + + migrationBuilder + .AlterDatabase() + .Annotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .Annotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned") + .OldAnnotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .OldAnnotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned,deleted"); + + migrationBuilder.AlterColumn( + name: "start_air", + table: "shows", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateOnly), + oldType: "date", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "end_air", + table: "shows", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateOnly), + oldType: "date", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "start_date", + table: "seasons", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateOnly), + oldType: "date", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "end_date", + table: "seasons", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateOnly), + oldType: "date", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "air_date", + table: "movies", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateOnly), + oldType: "date", + oldNullable: true + ); + + migrationBuilder.AlterColumn( + name: "release_date", + table: "episodes", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateOnly), + oldType: "date", + oldNullable: true + ); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240401213942_AddGenres.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240401213942_AddGenres.Designer.cs new file mode 100644 index 0000000..758e37d --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240401213942_AddGenres.Designer.cs @@ -0,0 +1,1380 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240401213942_AddGenres")] + partial class AddGenres + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum( + modelBuilder, + "genre", + new[] + { + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science_fiction", + "thriller", + "war", + "western", + "kids", + "news", + "reality", + "soap", + "talk", + "politics" + } + ); + NpgsqlModelBuilderExtensions.HasPostgresEnum( + modelBuilder, + "status", + new[] { "unknown", "finished", "airing", "planned" } + ); + NpgsqlModelBuilderExtensions.HasPostgresEnum( + modelBuilder, + "watch_status", + new[] { "completed", "watching", "droped", "planned", "deleted" } + ); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Collection", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview").HasColumnType("text").HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id").HasName("pk_collections"); + + b.HasIndex("Slug").IsUnique().HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Episode", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name").HasColumnType("text").HasColumnName("name"); + + b.Property("Overview").HasColumnType("text").HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("Runtime").HasColumnType("integer").HasColumnName("runtime"); + + b.Property("SeasonId").HasColumnType("uuid").HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId").HasColumnType("uuid").HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id").HasName("pk_episodes"); + + b.HasIndex("SeasonId").HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug").IsUnique().HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName( + "ix_episodes_show_id_season_number_episode_number_absolute_numb" + ); + + b.ToTable("episodes", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.EpisodeWatchStatus", + b => + { + b.Property("UserId").HasColumnType("uuid").HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId").HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId").HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Issue", + b => + { + b.Property("Domain").HasColumnType("text").HasColumnName("domain"); + + b.Property("Cause").HasColumnType("text").HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause").HasName("pk_issues"); + + b.ToTable("issues", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Movie", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("date") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview").HasColumnType("text").HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating").HasColumnType("integer").HasColumnName("rating"); + + b.Property("Runtime").HasColumnType("integer").HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status").HasColumnType("status").HasColumnName("status"); + + b.Property("StudioId").HasColumnType("uuid").HasColumnName("studio_id"); + + b.Property("Tagline").HasColumnType("text").HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer").HasColumnType("text").HasColumnName("trailer"); + + b.HasKey("Id").HasName("pk_movies"); + + b.HasIndex("Slug").IsUnique().HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId").HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.MovieWatchStatus", + b => + { + b.Property("UserId").HasColumnType("uuid").HasColumnName("user_id"); + + b.Property("MovieId").HasColumnType("uuid").HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId").HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId").HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Season", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name").HasColumnType("text").HasColumnName("name"); + + b.Property("Overview").HasColumnType("text").HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId").HasColumnType("uuid").HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.HasKey("Id").HasName("pk_seasons"); + + b.HasIndex("Slug").IsUnique().HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Show", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir").HasColumnType("date").HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Overview").HasColumnType("text").HasColumnName("overview"); + + b.Property("Rating").HasColumnType("integer").HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("date") + .HasColumnName("start_air"); + + b.Property("Status").HasColumnType("status").HasColumnName("status"); + + b.Property("StudioId").HasColumnType("uuid").HasColumnName("studio_id"); + + b.Property("Tagline").HasColumnType("text").HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer").HasColumnType("text").HasColumnName("trailer"); + + b.HasKey("Id").HasName("pk_shows"); + + b.HasIndex("Slug").IsUnique().HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId").HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.ShowWatchStatus", + b => + { + b.Property("UserId").HasColumnType("uuid").HasColumnName("user_id"); + + b.Property("ShowId").HasColumnType("uuid").HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId").HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId").HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Studio", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id").HasName("pk_studios"); + + b.HasIndex("Slug").IsUnique().HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.User", + b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password").HasColumnType("text").HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id").HasName("pk_users"); + + b.HasIndex("Slug").IsUnique().HasDatabaseName("ix_users_slug"); + + b.HasIndex("Username").IsUnique().HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + } + ); + + modelBuilder.Entity( + "link_collection_movie", + b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id").HasColumnType("uuid").HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id").HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id").HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + } + ); + + modelBuilder.Entity( + "link_collection_show", + b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id").HasColumnType("uuid").HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id").HasName("pk_link_collection_show"); + + b.HasIndex("show_id").HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Collection", + b => + { + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Logo", + b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Poster", + b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Thumbnail", + b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + } + ); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Episode", + b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Logo", + b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Poster", + b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Thumbnail", + b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + } + ); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.EpisodeWatchStatus", + b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Movie", + b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Logo", + b1 => + { + b1.Property("MovieId").HasColumnType("uuid").HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Poster", + b1 => + { + b1.Property("MovieId").HasColumnType("uuid").HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Thumbnail", + b1 => + { + b1.Property("MovieId").HasColumnType("uuid").HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + } + ); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.MovieWatchStatus", + b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Season", + b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Logo", + b1 => + { + b1.Property("SeasonId").HasColumnType("uuid").HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Poster", + b1 => + { + b1.Property("SeasonId").HasColumnType("uuid").HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Thumbnail", + b1 => + { + b1.Property("SeasonId").HasColumnType("uuid").HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + } + ); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Show", + b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Logo", + b1 => + { + b1.Property("ShowId").HasColumnType("uuid").HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Poster", + b1 => + { + b1.Property("ShowId").HasColumnType("uuid").HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + } + ); + + b.OwnsOne( + "Kyoo.Abstractions.Models.Image", + "Thumbnail", + b1 => + { + b1.Property("ShowId").HasColumnType("uuid").HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + } + ); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.ShowWatchStatus", + b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + } + ); + + modelBuilder.Entity( + "link_collection_movie", + b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + } + ); + + modelBuilder.Entity( + "link_collection_show", + b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Episode", + b => + { + b.Navigation("Watched"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Movie", + b => + { + b.Navigation("Watched"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Season", + b => + { + b.Navigation("Episodes"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Show", + b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + } + ); + + modelBuilder.Entity( + "Kyoo.Abstractions.Models.Studio", + b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + } + ); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240401213942_AddGenres.cs b/src/Kyoo.Postgresql/Migrations/20240401213942_AddGenres.cs new file mode 100644 index 0000000..6cb5aa6 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240401213942_AddGenres.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class AddGenres : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder + .AlterDatabase() + .Annotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western,kids,news,reality,soap,talk,politics" + ) + .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .Annotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned,deleted") + .OldAnnotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .OldAnnotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned,deleted"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder + .AlterDatabase() + .Annotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western" + ) + .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .Annotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned,deleted") + .OldAnnotation( + "Npgsql:Enum:genre", + "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western,kids,news,reality,soap,talk,politics" + ) + .OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned") + .OldAnnotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned,deleted"); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.Designer.cs new file mode 100644 index 0000000..b02a576 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.Designer.cs @@ -0,0 +1,1347 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240414212454_AddNextRefresh")] + partial class AddNextRefresh + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western", "kids", "news", "reality", "soap", "talk", "politics" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned", "deleted" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("date") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("date") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("date") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("logo_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("logo_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("poster_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("poster_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("thumbnail_blurhash"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbnail_source"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.cs b/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.cs new file mode 100644 index 0000000..c1875fd --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240414212454_AddNextRefresh.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations; + +/// +public partial class AddNextRefresh : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "shows", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "seasons", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "movies", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "episodes", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + migrationBuilder.AddColumn( + name: "next_metadata_refresh", + table: "collections", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "now() at time zone 'utc' + interval '2 hours'" + ); + + // language=PostgreSQL + migrationBuilder.Sql( + """ + update episodes as e set external_id = ( + SELECT jsonb_build_object( + 'themoviedatabase', jsonb_build_object( + 'ShowId', s.external_id->'themoviedatabase'->'DataId', + 'Season', e.season_number, + 'Episode', e.episode_number, + 'Link', null + ) + ) + FROM shows AS s + WHERE s.id = e.show_id + ); + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "shows"); + + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "seasons"); + + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "movies"); + + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "episodes"); + + migrationBuilder.DropColumn(name: "next_metadata_refresh", table: "collections"); + + // language=PostgreSQL + migrationBuilder.Sql("update episodes as e set external_id = '{}';"); + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240420124608_ReworkImages.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240420124608_ReworkImages.Designer.cs new file mode 100644 index 0000000..0157c3c --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240420124608_ReworkImages.Designer.cs @@ -0,0 +1,1375 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240420124608_ReworkImages")] + partial class ReworkImages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western", "kids", "news", "reality", "soap", "talk", "politics" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned", "deleted" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("date") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("date") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("date") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId") + .HasName("pk_collections"); + + b1.ToTable("collections"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_collection_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240420124608_ReworkImages.cs b/src/Kyoo.Postgresql/Migrations/20240420124608_ReworkImages.cs new file mode 100644 index 0000000..699c74a --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240420124608_ReworkImages.cs @@ -0,0 +1,464 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + /// + public partial class ReworkImages : Migration + { + private void MigrateImage(MigrationBuilder migrationBuilder, string table, string type) + { + migrationBuilder.Sql( + $""" + update {table} as r set {type} = json_build_object( + 'Id', gen_random_uuid(), + 'Source', r.{type}_source, + 'Blurhash', r.{type}_blurhash + ) + where r.{type}_source is not null + """ + ); + } + + private void UnMigrateImage(MigrationBuilder migrationBuilder, string table, string type) + { + migrationBuilder.Sql( + $""" + update {table} as r + set {type}_source = r.{type}->>'Source', + {type}_blurhash = r.{type}->>'Blurhash' + """ + ); + } + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "logo", + table: "shows", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster", + table: "shows", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail", + table: "shows", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo", + table: "seasons", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster", + table: "seasons", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail", + table: "seasons", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo", + table: "movies", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster", + table: "movies", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail", + table: "movies", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo", + table: "episodes", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster", + table: "episodes", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail", + table: "episodes", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo", + table: "collections", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster", + table: "collections", + type: "jsonb", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail", + table: "collections", + type: "jsonb", + nullable: true + ); + + MigrateImage(migrationBuilder, "shows", "logo"); + MigrateImage(migrationBuilder, "shows", "poster"); + MigrateImage(migrationBuilder, "shows", "thumbnail"); + + MigrateImage(migrationBuilder, "seasons", "logo"); + MigrateImage(migrationBuilder, "seasons", "poster"); + MigrateImage(migrationBuilder, "seasons", "thumbnail"); + + MigrateImage(migrationBuilder, "movies", "logo"); + MigrateImage(migrationBuilder, "movies", "poster"); + MigrateImage(migrationBuilder, "movies", "thumbnail"); + + MigrateImage(migrationBuilder, "episodes", "logo"); + MigrateImage(migrationBuilder, "episodes", "poster"); + MigrateImage(migrationBuilder, "episodes", "thumbnail"); + + MigrateImage(migrationBuilder, "collections", "logo"); + MigrateImage(migrationBuilder, "collections", "poster"); + MigrateImage(migrationBuilder, "collections", "thumbnail"); + + migrationBuilder.DropColumn(name: "logo_blurhash", table: "shows"); + migrationBuilder.DropColumn(name: "logo_source", table: "shows"); + migrationBuilder.DropColumn(name: "poster_blurhash", table: "shows"); + migrationBuilder.DropColumn(name: "poster_source", table: "shows"); + migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "shows"); + migrationBuilder.DropColumn(name: "thumbnail_source", table: "shows"); + + migrationBuilder.DropColumn(name: "logo_blurhash", table: "seasons"); + migrationBuilder.DropColumn(name: "logo_source", table: "seasons"); + migrationBuilder.DropColumn(name: "poster_blurhash", table: "seasons"); + migrationBuilder.DropColumn(name: "poster_source", table: "seasons"); + migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "seasons"); + migrationBuilder.DropColumn(name: "thumbnail_source", table: "seasons"); + + migrationBuilder.DropColumn(name: "logo_blurhash", table: "movies"); + migrationBuilder.DropColumn(name: "logo_source", table: "movies"); + migrationBuilder.DropColumn(name: "poster_blurhash", table: "movies"); + migrationBuilder.DropColumn(name: "poster_source", table: "movies"); + migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "movies"); + migrationBuilder.DropColumn(name: "thumbnail_source", table: "movies"); + + migrationBuilder.DropColumn(name: "logo_blurhash", table: "episodes"); + migrationBuilder.DropColumn(name: "logo_source", table: "episodes"); + migrationBuilder.DropColumn(name: "poster_blurhash", table: "episodes"); + migrationBuilder.DropColumn(name: "poster_source", table: "episodes"); + migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "episodes"); + migrationBuilder.DropColumn(name: "thumbnail_source", table: "episodes"); + + migrationBuilder.DropColumn(name: "logo_blurhash", table: "collections"); + migrationBuilder.DropColumn(name: "logo_source", table: "collections"); + migrationBuilder.DropColumn(name: "poster_blurhash", table: "collections"); + migrationBuilder.DropColumn(name: "poster_source", table: "collections"); + migrationBuilder.DropColumn(name: "thumbnail_blurhash", table: "collections"); + migrationBuilder.DropColumn(name: "thumbnail_source", table: "collections"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "logo_blurhash", + table: "shows", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_source", + table: "shows", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_blurhash", + table: "shows", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_source", + table: "shows", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_blurhash", + table: "shows", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_source", + table: "shows", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_blurhash", + table: "seasons", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_source", + table: "seasons", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_blurhash", + table: "seasons", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_source", + table: "seasons", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_blurhash", + table: "seasons", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_source", + table: "seasons", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_blurhash", + table: "movies", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_source", + table: "movies", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_blurhash", + table: "movies", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_source", + table: "movies", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_blurhash", + table: "movies", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_source", + table: "movies", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_blurhash", + table: "episodes", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_source", + table: "episodes", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_blurhash", + table: "episodes", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_source", + table: "episodes", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_blurhash", + table: "episodes", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_source", + table: "episodes", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_blurhash", + table: "collections", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "logo_source", + table: "collections", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_blurhash", + table: "collections", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "poster_source", + table: "collections", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_blurhash", + table: "collections", + type: "character varying(32)", + maxLength: 32, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "thumbnail_source", + table: "collections", + type: "text", + nullable: true + ); + + UnMigrateImage(migrationBuilder, "shows", "logo"); + UnMigrateImage(migrationBuilder, "shows", "poster"); + UnMigrateImage(migrationBuilder, "shows", "thumbnail"); + + UnMigrateImage(migrationBuilder, "seasons", "logo"); + UnMigrateImage(migrationBuilder, "seasons", "poster"); + UnMigrateImage(migrationBuilder, "seasons", "thumbnail"); + + UnMigrateImage(migrationBuilder, "movies", "logo"); + UnMigrateImage(migrationBuilder, "movies", "poster"); + UnMigrateImage(migrationBuilder, "movies", "thumbnail"); + + UnMigrateImage(migrationBuilder, "episodes", "logo"); + UnMigrateImage(migrationBuilder, "episodes", "poster"); + UnMigrateImage(migrationBuilder, "episodes", "thumbnail"); + + UnMigrateImage(migrationBuilder, "collections", "logo"); + UnMigrateImage(migrationBuilder, "collections", "poster"); + UnMigrateImage(migrationBuilder, "collections", "thumbnail"); + + migrationBuilder.DropColumn(name: "logo", table: "shows"); + migrationBuilder.DropColumn(name: "poster", table: "shows"); + migrationBuilder.DropColumn(name: "thumbnail", table: "shows"); + migrationBuilder.DropColumn(name: "logo", table: "seasons"); + migrationBuilder.DropColumn(name: "poster", table: "seasons"); + migrationBuilder.DropColumn(name: "thumbnail", table: "seasons"); + migrationBuilder.DropColumn(name: "logo", table: "movies"); + migrationBuilder.DropColumn(name: "poster", table: "movies"); + migrationBuilder.DropColumn(name: "thumbnail", table: "movies"); + migrationBuilder.DropColumn(name: "logo", table: "episodes"); + migrationBuilder.DropColumn(name: "poster", table: "episodes"); + migrationBuilder.DropColumn(name: "thumbnail", table: "episodes"); + migrationBuilder.DropColumn(name: "logo", table: "collections"); + migrationBuilder.DropColumn(name: "poster", table: "collections"); + migrationBuilder.DropColumn(name: "thumbnail", table: "collections"); + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240423151632_AddServerOptions.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240423151632_AddServerOptions.Designer.cs new file mode 100644 index 0000000..eedca22 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240423151632_AddServerOptions.Designer.cs @@ -0,0 +1,1395 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240423151632_AddServerOptions")] + partial class AddServerOptions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western", "kids", "news", "reality", "soap", "talk", "politics" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned", "deleted" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("date") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("date") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("date") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ServerOption", b => + { + b.Property("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_options"); + + b.ToTable("options", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId") + .HasName("pk_collections"); + + b1.ToTable("collections"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_collection_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240423151632_AddServerOptions.cs b/src/Kyoo.Postgresql/Migrations/20240423151632_AddServerOptions.cs new file mode 100644 index 0000000..03429d6 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240423151632_AddServerOptions.cs @@ -0,0 +1,43 @@ +using System; +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + /// + public partial class AddServerOptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "options", + columns: table => new + { + key = table.Column(type: "text", nullable: false), + value = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_options", x => x.key); + } + ); + byte[] secret = new byte[128]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(secret); + migrationBuilder.InsertData( + "options", + new[] { "key", "value" }, + new[] { "AUTHENTICATION_SECRET", Convert.ToBase64String(secret) } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "options"); + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240506175054_FixSeasonMetadataId.Designer.cs b/src/Kyoo.Postgresql/Migrations/20240506175054_FixSeasonMetadataId.Designer.cs new file mode 100644 index 0000000..37506ee --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240506175054_FixSeasonMetadataId.Designer.cs @@ -0,0 +1,1395 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + [Migration("20240506175054_FixSeasonMetadataId")] + partial class FixSeasonMetadataId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western", "kids", "news", "reality", "soap", "talk", "politics" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned", "deleted" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("date") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("date") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("date") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ServerOption", b => + { + b.Property("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_options"); + + b.ToTable("options", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId") + .HasName("pk_collections"); + + b1.ToTable("collections"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_collection_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/20240506175054_FixSeasonMetadataId.cs b/src/Kyoo.Postgresql/Migrations/20240506175054_FixSeasonMetadataId.cs new file mode 100644 index 0000000..c4bb048 --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/20240506175054_FixSeasonMetadataId.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + /// + public partial class FixSeasonMetadataId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // language=PostgreSQL + migrationBuilder.Sql( + """ + update seasons as s set external_id = ( + SELECT jsonb_build_object( + 'themoviedatabase', jsonb_build_object( + 'DataId', sh.external_id->'themoviedatabase'->'DataId', + 'Link', s.external_id->'themoviedatabase'->'Link' + ) + ) + FROM shows AS sh + WHERE sh.id = s.show_id + ); + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) { } + } +} diff --git a/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs b/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs new file mode 100644 index 0000000..7a5a8fb --- /dev/null +++ b/src/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs @@ -0,0 +1,1392 @@ +// +using System; +using System.Collections.Generic; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Kyoo.Postgresql.Migrations +{ + [DbContext(typeof(PostgresContext))] + partial class PostgresContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western", "kids", "news", "reality", "soap", "talk", "politics" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned", "deleted" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_collections_slug"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AbsoluteNumber") + .HasColumnType("integer") + .HasColumnName("absolute_number"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EpisodeNumber") + .HasColumnType("integer") + .HasColumnName("episode_number"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("SeasonId") + .HasColumnType("uuid") + .HasColumnName("season_id"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_episodes"); + + b.HasIndex("SeasonId") + .HasDatabaseName("ix_episodes_season_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_episodes_slug"); + + b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber", "AbsoluteNumber") + .IsUnique() + .HasDatabaseName("ix_episodes_show_id_season_number_episode_number_absolute_numb"); + + b.ToTable("episodes", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("EpisodeId") + .HasColumnType("uuid") + .HasColumnName("episode_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "EpisodeId") + .HasName("pk_episode_watch_status"); + + b.HasIndex("EpisodeId") + .HasDatabaseName("ix_episode_watch_status_episode_id"); + + b.ToTable("episode_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Issue", b => + { + b.Property("Domain") + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("Cause") + .HasColumnType("text") + .HasColumnName("cause"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Extra") + .IsRequired() + .HasColumnType("json") + .HasColumnName("extra"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.HasKey("Domain", "Cause") + .HasName("pk_issues"); + + b.ToTable("issues", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("AirDate") + .HasColumnType("date") + .HasColumnName("air_date"); + + b.Property("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Runtime") + .HasColumnType("integer") + .HasColumnName("runtime"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_movies"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_movies_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_movies_studio_id"); + + b.ToTable("movies", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("MovieId") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "MovieId") + .HasName("pk_movie_watch_status"); + + b.HasIndex("MovieId") + .HasDatabaseName("ix_movie_watch_status_movie_id"); + + b.ToTable("movie_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("pk_seasons"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_seasons_slug"); + + b.HasIndex("ShowId", "SeasonNumber") + .IsUnique() + .HasDatabaseName("ix_seasons_show_id_season_number"); + + b.ToTable("seasons", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property>("Aliases") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("aliases"); + + b.Property("EndAir") + .HasColumnType("date") + .HasColumnName("end_air"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property>("Genres") + .IsRequired() + .HasColumnType("genre[]") + .HasColumnName("genres"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NextMetadataRefresh") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_metadata_refresh") + .HasDefaultValueSql("now() at time zone 'utc' + interval '2 hours'"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("date") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("StudioId") + .HasColumnType("uuid") + .HasColumnName("studio_id"); + + b.Property("Tagline") + .HasColumnType("text") + .HasColumnName("tagline"); + + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Trailer") + .HasColumnType("text") + .HasColumnName("trailer"); + + b.HasKey("Id") + .HasName("pk_shows"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_shows_slug"); + + b.HasIndex("StudioId") + .HasDatabaseName("ix_shows_studio_id"); + + b.ToTable("shows", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ShowId") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("NextEpisodeId") + .HasColumnType("uuid") + .HasColumnName("next_episode_id"); + + b.Property("PlayedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("played_date"); + + b.Property("Status") + .HasColumnType("watch_status") + .HasColumnName("status"); + + b.Property("UnseenEpisodesCount") + .HasColumnType("integer") + .HasColumnName("unseen_episodes_count"); + + b.Property("WatchedPercent") + .HasColumnType("integer") + .HasColumnName("watched_percent"); + + b.Property("WatchedTime") + .HasColumnType("integer") + .HasColumnName("watched_time"); + + b.HasKey("UserId", "ShowId") + .HasName("pk_show_watch_status"); + + b.HasIndex("NextEpisodeId") + .HasDatabaseName("ix_show_watch_status_next_episode_id"); + + b.HasIndex("ShowId") + .HasDatabaseName("ix_show_watch_status_show_id"); + + b.ToTable("show_watch_status", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_studios"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_studios_slug"); + + b.ToTable("studios", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AddedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("added_date") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("json") + .HasColumnName("external_id"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("permissions"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("json") + .HasColumnName("settings"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("slug"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_users_slug"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("ServerOption", b => + { + b.Property("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_options"); + + b.ToTable("options", (string)null); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("movie_id") + .HasColumnType("uuid") + .HasColumnName("movie_id"); + + b.HasKey("collection_id", "movie_id") + .HasName("pk_link_collection_movie"); + + b.HasIndex("movie_id") + .HasDatabaseName("ix_link_collection_movie_movie_id"); + + b.ToTable("link_collection_movie", (string)null); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.Property("collection_id") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("show_id") + .HasColumnType("uuid") + .HasColumnName("show_id"); + + b.HasKey("collection_id", "show_id") + .HasName("pk_link_collection_show"); + + b.HasIndex("show_id") + .HasDatabaseName("ix_link_collection_show_show_id"); + + b.ToTable("link_collection_show", (string)null); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => + { + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId") + .HasName("pk_collections"); + + b1.ToTable("collections"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_collection_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("CollectionId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("CollectionId"); + + b1.ToTable("collections"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("CollectionId") + .HasConstraintName("fk_collections_collections_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.HasOne("Kyoo.Abstractions.Models.Season", "Season") + .WithMany("Episodes") + .HasForeignKey("SeasonId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_episodes_seasons_season_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episodes_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("EpisodeId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("EpisodeId"); + + b1.ToTable("episodes"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("EpisodeId") + .HasConstraintName("fk_episodes_episodes_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Season"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.EpisodeWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "Episode") + .WithMany("Watched") + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_episodes_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_episode_watch_status_users_user_id"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Movies") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_movies_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("MovieId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MovieId"); + + b1.ToTable("movies"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("MovieId") + .HasConstraintName("fk_movies_movies_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.MovieWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Movie", "Movie") + .WithMany("Watched") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_movies_movie_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_movie_watch_status_users_user_id"); + + b.Navigation("Movie"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Seasons") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seasons_shows_show_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("SeasonId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("SeasonId"); + + b1.ToTable("seasons"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("SeasonId") + .HasConstraintName("fk_seasons_seasons_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Show"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Studio", "Studio") + .WithMany("Shows") + .HasForeignKey("StudioId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shows_studios_studio_id"); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Logo", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("logo"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Poster", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("poster"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.OwnsOne("Kyoo.Abstractions.Models.Image", "Thumbnail", b1 => + { + b1.Property("ShowId") + .HasColumnType("uuid"); + + b1.Property("Blurhash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b1.Property("Id") + .HasColumnType("uuid"); + + b1.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ShowId"); + + b1.ToTable("shows"); + + b1.ToJson("thumbnail"); + + b1.WithOwner() + .HasForeignKey("ShowId") + .HasConstraintName("fk_shows_shows_id"); + }); + + b.Navigation("Logo"); + + b.Navigation("Poster"); + + b.Navigation("Studio"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.ShowWatchStatus", b => + { + b.HasOne("Kyoo.Abstractions.Models.Episode", "NextEpisode") + .WithMany() + .HasForeignKey("NextEpisodeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_show_watch_status_episodes_next_episode_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", "Show") + .WithMany("Watched") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_shows_show_id"); + + b.HasOne("Kyoo.Abstractions.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_show_watch_status_users_user_id"); + + b.Navigation("NextEpisode"); + + b.Navigation("Show"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("link_collection_movie", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Movie", null) + .WithMany() + .HasForeignKey("movie_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_movie_movies_movie_id"); + }); + + modelBuilder.Entity("link_collection_show", b => + { + b.HasOne("Kyoo.Abstractions.Models.Collection", null) + .WithMany() + .HasForeignKey("collection_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_collections_collection_id"); + + b.HasOne("Kyoo.Abstractions.Models.Show", null) + .WithMany() + .HasForeignKey("show_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_link_collection_show_shows_show_id"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Episode", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Movie", b => + { + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Season", b => + { + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Seasons"); + + b.Navigation("Watched"); + }); + + modelBuilder.Entity("Kyoo.Abstractions.Models.Studio", b => + { + b.Navigation("Movies"); + + b.Navigation("Shows"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Kyoo.Postgresql/PostgresContext.cs b/src/Kyoo.Postgresql/PostgresContext.cs new file mode 100644 index 0000000..8052fdc --- /dev/null +++ b/src/Kyoo.Postgresql/PostgresContext.cs @@ -0,0 +1,147 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; +using Dapper; +using EFCore.NamingConventions.Internal; +using InterpolatedSql.SqlBuilders; +using Kyoo.Abstractions.Models; +using Kyoo.Postgresql.Utils; +using Kyoo.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.Extensions.Configuration; +using Npgsql; + +namespace Kyoo.Postgresql; + +public class PostgresContext(DbContextOptions options, IHttpContextAccessor accessor) + : DatabaseContext(options, accessor) +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseProjectables(); + optionsBuilder.UseSnakeCaseNamingConvention(); + base.OnConfiguring(optionsBuilder); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasPostgresEnum(); + modelBuilder.HasPostgresEnum(); + modelBuilder.HasPostgresEnum(); + + modelBuilder + .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!) + .HasTranslation(args => new SqlFunctionExpression( + "md5", + args, + nullable: true, + argumentsPropagateNullability: [false], + type: args[0].Type, + typeMapping: args[0].TypeMapping + )); + + SqlMapper.TypeMapProvider = (type) => + { + return new CustomPropertyTypeMap( + type, + (type, name) => + { + string newName = Regex.Replace( + name, + "(^|_)([a-z])", + (match) => match.Groups[2].Value.ToUpperInvariant() + ); + // TODO: Add images handling here (name: poster_source, newName: PosterSource) should set Poster.Source + return type.GetProperty(newName)!; + } + ); + }; + SqlMapper.AddTypeHandler( + typeof(Dictionary), + new JsonTypeHandler>() + ); + SqlMapper.AddTypeHandler( + typeof(Dictionary), + new JsonTypeHandler>() + ); + SqlMapper.AddTypeHandler( + typeof(Dictionary), + new JsonTypeHandler>() + ); + SqlMapper.AddTypeHandler( + typeof(Dictionary), + new JsonTypeHandler>() + ); + SqlMapper.AddTypeHandler(typeof(Image), new JsonTypeHandler()); + SqlMapper.AddTypeHandler(typeof(List), new ListTypeHandler()); + SqlMapper.AddTypeHandler(typeof(List), new ListTypeHandler()); + SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler()); + InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true; + InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false; + + base.OnModelCreating(modelBuilder); + } + + /// + protected override string LinkName() + { + SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); + return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name); + } + + /// + protected override string LinkNameFk() + { + SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); + return rewriter.RewriteName(typeof(T).Name + "ID"); + } + + /// + protected override bool IsDuplicateException(Exception ex) + { + return ex.InnerException + is PostgresException + { + SqlState: PostgresErrorCodes.UniqueViolation + or PostgresErrorCodes.ForeignKeyViolation + }; + } +} + +public class PostgresContextBuilder : IDesignTimeDbContextFactory +{ + public PostgresContext CreateDbContext(string[] args) + { + IConfigurationRoot config = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build(); + NpgsqlDataSource dataSource = PostgresModule.CreateDataSource(config); + DbContextOptionsBuilder builder = new(); + builder.UseNpgsql(dataSource); + + return new PostgresContext(builder.Options, null!); + } +} diff --git a/src/Kyoo.Postgresql/PostgresModule.cs b/src/Kyoo.Postgresql/PostgresModule.cs new file mode 100644 index 0000000..f7ad8ff --- /dev/null +++ b/src/Kyoo.Postgresql/PostgresModule.cs @@ -0,0 +1,114 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Data.Common; +using Kyoo.Abstractions.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; + +namespace Kyoo.Postgresql; + +public static class PostgresModule +{ + public static NpgsqlDataSource CreateDataSource(IConfiguration configuration) + { + var connectionString = configuration.GetValue("POSTGRES_URL"); + + // Load the connection string from the environment variable, as well as standard libpq environment variables + // (PGUSER, PGPASSWORD, PGHOST, PGPORT, PGDATABASE, etc.) + NpgsqlConnectionStringBuilder conBuilder = new(connectionString ?? ""); + // Set defaults when no explicit connection string is provided. This cannot be set if the connection string + // is provided, or it will override connection string values. + if (string.IsNullOrEmpty(connectionString)) + { + conBuilder.Pooling = true; + conBuilder.MaxPoolSize = 95; + conBuilder.Timeout = 30; + } + + string? oldVarUsername = configuration.GetValue("POSTGRES_USER"); + if (!string.IsNullOrEmpty(oldVarUsername)) + conBuilder.Username = oldVarUsername; + if (string.IsNullOrEmpty(conBuilder.Username)) + conBuilder.Username = "KyooUser"; + + string? oldVarPassword = configuration.GetValue("POSTGRES_PASSWORD"); + if (!string.IsNullOrEmpty(oldVarPassword)) + conBuilder.Password = oldVarPassword; + if (string.IsNullOrEmpty(conBuilder.Password)) + conBuilder.Password = "KyooPassword"; + + string? oldVarHost = configuration.GetValue("POSTGRES_SERVER"); + if (!string.IsNullOrEmpty(oldVarHost)) + conBuilder.Host = oldVarHost; + if (string.IsNullOrEmpty(conBuilder.Host)) + conBuilder.Host = "postgres"; + + int? oldVarPort = configuration.GetValue("POSTGRES_PORT"); + if (oldVarPort != null && oldVarPort != 0) + conBuilder.Port = oldVarPort.Value; + if (conBuilder.Port == 0) + conBuilder.Port = 5432; + + string? oldVarDatabase = configuration.GetValue("POSTGRES_DB"); + if (!string.IsNullOrEmpty(oldVarDatabase)) + conBuilder.Database = oldVarDatabase; + if (string.IsNullOrEmpty(conBuilder.Database)) + conBuilder.Database = "kyooDB"; + + NpgsqlDataSourceBuilder dsBuilder = new(conBuilder.ConnectionString); + dsBuilder.MapEnum(); + dsBuilder.MapEnum(); + dsBuilder.MapEnum(); + return dsBuilder.Build(); + } + + public static void ConfigurePostgres(this WebApplicationBuilder builder) + { + NpgsqlDataSource dataSource = CreateDataSource(builder.Configuration); + builder.Services.AddDbContext( + x => + { + x.UseNpgsql(dataSource); + if (builder.Environment.IsDevelopment()) + x.EnableDetailedErrors().EnableSensitiveDataLogging(); + }, + ServiceLifetime.Transient + ); + builder.Services.AddTransient( + (services) => services.GetRequiredService().Database.GetDbConnection() + ); + + builder.Services.AddHealthChecks().AddDbContextCheck(); + builder.Configuration.AddDbConfigurationProvider(x => x.UseNpgsql(dataSource)); + } + + private static void AddDbConfigurationProvider( + this IConfigurationBuilder builder, + Action action + ) + { + builder.Add(new DbConfigurationSource(action)); + } +} diff --git a/src/Kyoo.Postgresql/Utils/JsonTypeHandler.cs b/src/Kyoo.Postgresql/Utils/JsonTypeHandler.cs new file mode 100644 index 0000000..e965538 --- /dev/null +++ b/src/Kyoo.Postgresql/Utils/JsonTypeHandler.cs @@ -0,0 +1,42 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Data; +using System.Text.Json; +using Npgsql; +using NpgsqlTypes; +using static Dapper.SqlMapper; + +namespace Kyoo.Postgresql.Utils; + +public class JsonTypeHandler : TypeHandler + where T : class +{ + public override T? Parse(object value) + { + if (value is string str) + return JsonSerializer.Deserialize(str); + return default; + } + + public override void SetValue(IDbDataParameter parameter, T? value) + { + parameter.Value = JsonSerializer.Serialize(value); + ((NpgsqlParameter)parameter).NpgsqlDbType = NpgsqlDbType.Jsonb; + } +} diff --git a/src/Kyoo.Postgresql/Utils/ListTypeHandler.cs b/src/Kyoo.Postgresql/Utils/ListTypeHandler.cs new file mode 100644 index 0000000..837528b --- /dev/null +++ b/src/Kyoo.Postgresql/Utils/ListTypeHandler.cs @@ -0,0 +1,39 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Data; +using System.Linq; +using Dapper; + +namespace Kyoo.Postgresql.Utils; + +// See https://github.com/DapperLib/Dapper/issues/1424 +public class ListTypeHandler : SqlMapper.TypeHandler> +{ + public override List Parse(object value) + { + T[] typedValue = (T[])value; // looks like Dapper did not indicate the property type to Npgsql, so it defaults to string[] (default CLR type for text[] PostgreSQL type) + return typedValue?.ToList() ?? []; + } + + public override void SetValue(IDbDataParameter parameter, List? value) + { + parameter.Value = value; // no need to convert to string[] in this direction + } +} diff --git a/src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj b/src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj new file mode 100644 index 0000000..b159b96 --- /dev/null +++ b/src/Kyoo.RabbitMq/Kyoo.RabbitMq.csproj @@ -0,0 +1,15 @@ + + + enable + Kyoo.RabbitMq + + + + + + + + + + + diff --git a/src/Kyoo.RabbitMq/Message.cs b/src/Kyoo.RabbitMq/Message.cs new file mode 100644 index 0000000..72fa2f3 --- /dev/null +++ b/src/Kyoo.RabbitMq/Message.cs @@ -0,0 +1,40 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Text; +using System.Text.Json; +using Kyoo.Utils; + +namespace Kyoo.RabbitMq; + +public class Message +{ + public string Action { get; set; } + public string Type { get; set; } + public T Value { get; set; } + + public string AsRoutingKey() + { + return $"{Type}.{Action}"; + } + + public byte[] AsBytes() + { + return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(this, Utility.JsonOptions)); + } +} diff --git a/src/Kyoo.RabbitMq/RabbitMqModule.cs b/src/Kyoo.RabbitMq/RabbitMqModule.cs new file mode 100644 index 0000000..9b2da76 --- /dev/null +++ b/src/Kyoo.RabbitMq/RabbitMqModule.cs @@ -0,0 +1,223 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Kyoo.Abstractions.Controllers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; +using RabbitMQ.Client; + +namespace Kyoo.RabbitMq; + +public static class RabbitMqModule +{ + public static void ConfigureRabbitMq(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(_ => + { + ConnectionFactory factory = new(); + + // See https://www.rabbitmq.com/docs/uri-spec + string? connectionString = builder.Configuration.GetValue("RABBITMQ_URL"); + if (!string.IsNullOrEmpty(connectionString)) + factory._ConfigureFactoryWithConnectionString(connectionString); + else + factory._ConfigureFactoryWithEnvironmentVars(builder.Configuration); + + return factory.CreateConnection(); + }); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + + private static void _ConfigureFactoryWithConnectionString( + this ConnectionFactory factory, + string? connectionString + ) + { + if (string.IsNullOrEmpty(connectionString)) + return; + + // Important: setting this property will not use any query parameters, so they must be parsed here instead.. + factory.Uri = new Uri(connectionString); + + // Support query parameters defined here: + // https://www.rabbitmq.com/docs/uri-query-parameters + Dictionary queryParameters = QueryHelpers.ParseQuery( + factory.Uri.Query + ); + + queryParameters.TryGetValue("heartbeat", out StringValues heartbeats); + if (int.TryParse(heartbeats.LastOrDefault(), out int heartbeatValue)) + factory.RequestedHeartbeat = TimeSpan.FromSeconds(heartbeatValue); + + queryParameters.TryGetValue("connection_timeout", out StringValues connectionTimeouts); + if (int.TryParse(connectionTimeouts.LastOrDefault(), out int connectionTimeoutValue)) + factory.RequestedConnectionTimeout = TimeSpan.FromSeconds(connectionTimeoutValue); + + queryParameters.TryGetValue("channel_max", out StringValues channelMaxValues); + if (ushort.TryParse(channelMaxValues.LastOrDefault(), out ushort channelMaxValue)) + factory.RequestedChannelMax = channelMaxValue; + + if (!factory.Ssl.Enabled) + return; + + queryParameters.TryGetValue("cacertfile", out StringValues caCertFiles); + var caCertFile = caCertFiles.LastOrDefault(); + if (!string.IsNullOrEmpty(caCertFile)) + { + // Load the cert once at startup instead of on every connection. + X509Certificate2Collection rootCACollection = []; + rootCACollection.ImportFromPemFile(caCertFile); + + // This is a custom validator that obeys the set SslPolicyErrors, while also using the CA cert specified in the query string. + factory.Ssl.CertificateValidationCallback = ( + sender, + certificate, + chain, + sslPolicyErrors + ) => + { + // If no cert was provided + if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable)) + { + // Accept the cert anyway if the client was explicitly configured to ignore this. + if ( + factory.Ssl.AcceptablePolicyErrors.HasFlag( + SslPolicyErrors.RemoteCertificateNotAvailable + ) + ) + return true; + // Otherwise, reject it. + return false; + } + + // If the cert hostname does not match + if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch)) + { + // Accept the cert anyway if the client was explicitly configured to ignore this. + if ( + factory.Ssl.AcceptablePolicyErrors.HasFlag( + SslPolicyErrors.RemoteCertificateNameMismatch + ) + ) + return true; + // Otherwise, reject it. + return false; + } + + // This shouldn't ever happen, and is mostly just here to satisfy the linter + if (chain == null || certificate == null) + return false; + + // Verify that the certificate came from the specified CA. + chain.ChainPolicy.ExtraStore.AddRange( + chain.ChainElements.Select(x => x.Certificate).ToArray() + ); + chain.ChainPolicy.CustomTrustStore.Clear(); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.AddRange(rootCACollection); + + return chain.Build(new X509Certificate2(certificate)); + }; + } + + queryParameters.TryGetValue("certfile", out var certfiles); + var certfile = certfiles.LastOrDefault(); + queryParameters.TryGetValue("keyfile", out var keyfiles); + var keyfile = keyfiles.LastOrDefault(); + if (!string.IsNullOrEmpty(certfile) && !string.IsNullOrEmpty(keyfile)) + factory.Ssl.Certs = [X509Certificate2.CreateFromPemFile(certfile, keyfile)]; + + queryParameters.TryGetValue("verify", out var verifyValues); + switch (verifyValues.LastOrDefault()) + { + case "verify_none": + factory.Ssl.AcceptablePolicyErrors = ~SslPolicyErrors.None; + break; + case "verify_peer": + factory.Ssl.AcceptablePolicyErrors = SslPolicyErrors.None; + break; + } + + queryParameters.TryGetValue("server_name_indication", out StringValues sniValues); + var sni = sniValues.LastOrDefault(); + if (!string.IsNullOrEmpty(sni)) + { + if (sni == "disabled") // Special value, see https://www.rabbitmq.com/docs/ssl#erlang-ssl + { + factory.Ssl.ServerName = null; + factory.Ssl.AcceptablePolicyErrors |= SslPolicyErrors.RemoteCertificateNameMismatch; + } + else + factory.Ssl.ServerName = sni; + } + + queryParameters.TryGetValue("auth_mechanism", out StringValues authMechanisms); + if (authMechanisms.Count > 0) + { + factory.AuthMechanisms.Clear(); + foreach (var authMechanism in authMechanisms) + { + switch (authMechanism) + { + case "external": + factory.AuthMechanisms.Add(new ExternalMechanismFactory()); + break; + case "plain": + factory.AuthMechanisms.Add(new PlainMechanismFactory()); + break; + default: + throw new NotSupportedException( + $"Unsupported authentication mechanism: {authMechanism}" + ); + } + } + } + } + + private static void _ConfigureFactoryWithEnvironmentVars( + this ConnectionFactory factory, + IConfigurationManager configuration + ) + { + factory.UserName = _GetNonEmptyString( + configuration.GetValue("RABBITMQ_DEFAULT_USER"), + factory.UserName, + "guest" + ); + factory.Password = _GetNonEmptyString( + configuration.GetValue("RABBITMQ_DEFAULT_PASS"), + factory.Password, + "guest" + ); + factory.HostName = _GetNonEmptyString( + configuration.GetValue("RABBITMQ_HOST"), + factory.HostName, + "rabbitmq" + ); + factory.Port = configuration.GetValue("RABBITMQ_PORT", 5672); + } + + private static string _GetNonEmptyString(params string?[] values) => + values.FirstOrDefault(string.IsNullOrEmpty) ?? string.Empty; +} diff --git a/src/Kyoo.RabbitMq/RabbitProducer.cs b/src/Kyoo.RabbitMq/RabbitProducer.cs new file mode 100644 index 0000000..f7433dd --- /dev/null +++ b/src/Kyoo.RabbitMq/RabbitProducer.cs @@ -0,0 +1,132 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using RabbitMQ.Client; + +namespace Kyoo.RabbitMq; + +public class RabbitProducer +{ + private readonly IModel _channel; + + public RabbitProducer(IConnection rabbitConnection) + { + _channel = rabbitConnection.CreateModel(); + + if (!doesExchangeExist(rabbitConnection, "events.resource")) + _channel.ExchangeDeclare("events.resource", ExchangeType.Topic); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + _ListenResourceEvents("events.resource"); + + if (!doesExchangeExist(rabbitConnection, "events.watched")) + _channel.ExchangeDeclare("events.watched", ExchangeType.Topic); + IWatchStatusRepository.OnMovieStatusChangedHandler += _PublishWatchStatus("movie"); + IWatchStatusRepository.OnShowStatusChangedHandler += _PublishWatchStatus("show"); + IWatchStatusRepository.OnEpisodeStatusChangedHandler += _PublishWatchStatus( + "episode" + ); + } + + /// + /// Checks if the exchange exists. Needed to avoid crashing when re-declaring an existing + /// queue with different parameters. + /// + /// The RabbitMQ connection. + /// The name of the channel. + /// True if the queue exists, false otherwise. + private bool doesExchangeExist(IConnection rabbitConnection, string exchangeName) + { + // If the queue does not exist when QueueDeclarePassive is called, + // an exception will be thrown. According to the docs, when this + // happens, the entire channel should be thrown away. + using var channel = rabbitConnection.CreateModel(); + try + { + channel.ExchangeDeclarePassive(exchangeName); + return true; + } + catch (Exception) + { + return false; + } + } + + private void _ListenResourceEvents(string exchange) + where T : IResource, IQuery + { + string type = typeof(T).Name.ToLowerInvariant(); + + IRepository.OnCreated += _Publish(exchange, type, "created"); + IRepository.OnEdited += _Publish(exchange, type, "edited"); + IRepository.OnDeleted += _Publish(exchange, type, "deleted"); + } + + private IRepository.ResourceEventHandler _Publish( + string exchange, + string type, + string action + ) + where T : IResource, IQuery + { + return (T resource) => + { + Message message = + new() + { + Action = action, + Type = type, + Value = resource, + }; + _channel.BasicPublish( + exchange, + routingKey: message.AsRoutingKey(), + body: message.AsBytes() + ); + return Task.CompletedTask; + }; + } + + private IWatchStatusRepository.ResourceEventHandler> _PublishWatchStatus( + string resource + ) + { + return (status) => + { + Message> message = + new() + { + Type = resource, + Action = status.Status.ToString().ToLowerInvariant(), + Value = status, + }; + _channel.BasicPublish( + exchange: "events.watched", + routingKey: message.AsRoutingKey(), + body: message.AsBytes() + ); + return Task.CompletedTask; + }; + } +} diff --git a/src/Kyoo.RabbitMq/ScannerProducer.cs b/src/Kyoo.RabbitMq/ScannerProducer.cs new file mode 100644 index 0000000..0411e25 --- /dev/null +++ b/src/Kyoo.RabbitMq/ScannerProducer.cs @@ -0,0 +1,87 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Text; +using System.Text.Json; +using Kyoo.Abstractions.Controllers; +using Kyoo.Utils; +using RabbitMQ.Client; + +namespace Kyoo.RabbitMq; + +public class ScannerProducer : IScanner +{ + private readonly IModel _channel; + + public ScannerProducer(IConnection rabbitConnection) + { + _channel = rabbitConnection.CreateModel(); + if (!doesQueueExist(rabbitConnection, "scanner")) + _channel.QueueDeclare("scanner", exclusive: false, autoDelete: false); + if (!doesQueueExist(rabbitConnection, "scanner.rescan")) + _channel.QueueDeclare("scanner.rescan", exclusive: false, autoDelete: false); + } + + /// + /// Checks if the queue exists. Needed to avoid crashing when re-declaring an existing + /// queue with different parameters. + /// + /// The RabbitMQ connection. + /// The name of the channel. + /// True if the queue exists, false otherwise. + private bool doesQueueExist(IConnection rabbitConnection, string queueName) + { + // If the queue does not exist when QueueDeclarePassive is called, + // an exception will be thrown. According to the docs, when this + // happens, the entire channel should be thrown away. + using var channel = rabbitConnection.CreateModel(); + try + { + channel.QueueDeclarePassive(queueName); + return true; + } + catch (Exception) + { + return false; + } + } + + private Task _Publish(T message, string queue = "scanner") + { + var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, Utility.JsonOptions)); + _channel.BasicPublish("", routingKey: queue, body: body); + return Task.CompletedTask; + } + + public Task SendRescanRequest() + { + var message = new { Action = "rescan", }; + return _Publish(message, queue: "scanner.rescan"); + } + + public Task SendRefreshRequest(string kind, Guid id) + { + var message = new + { + Action = "refresh", + Kind = kind.ToLowerInvariant(), + Id = id + }; + return _Publish(message); + } +} diff --git a/src/Kyoo.Swagger/ApiSorter.cs b/src/Kyoo.Swagger/ApiSorter.cs new file mode 100644 index 0000000..0334805 --- /dev/null +++ b/src/Kyoo.Swagger/ApiSorter.cs @@ -0,0 +1,65 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Linq; +using Kyoo.Swagger.Models; +using NSwag; +using NSwag.Generation.AspNetCore; + +namespace Kyoo.Swagger; + +/// +/// A class to sort apis. +/// +public static class ApiSorter +{ + /// + /// Sort apis by alphabetical orders. + /// + /// The swagger settings to update. + public static void SortApis(this AspNetCoreOpenApiDocumentGeneratorSettings options) + { + options.PostProcess += postProcess => + { + // We can't reorder items by assigning the sorted value to the Paths variable since it has no setter. + List> sorted = postProcess + .Paths.OrderBy(x => x.Key) + .ToList(); + postProcess.Paths.Clear(); + foreach ((string key, OpenApiPathItem value) in sorted) + postProcess.Paths.Add(key, value); + }; + + options.PostProcess += postProcess => + { + if (!postProcess.ExtensionData.TryGetValue("x-tagGroups", out object list)) + return; + List tagGroups = (List)list; + postProcess.ExtensionData["x-tagGroups"] = tagGroups + .OrderBy(x => x.Name) + .Select(x => + { + x.Name = x.Name[(x.Name.IndexOf(':') + 1)..]; + x.Tags = x.Tags.OrderBy(y => y).ToList(); + return x; + }) + .ToList(); + }; + } +} diff --git a/src/Kyoo.Swagger/ApiTagsFilter.cs b/src/Kyoo.Swagger/ApiTagsFilter.cs new file mode 100644 index 0000000..7c001c1 --- /dev/null +++ b/src/Kyoo.Swagger/ApiTagsFilter.cs @@ -0,0 +1,123 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Swagger.Models; +using Namotion.Reflection; +using NSwag; +using NSwag.Generation.AspNetCore; +using NSwag.Generation.Processors.Contexts; + +namespace Kyoo.Swagger; + +/// +/// A class to handle Api Groups (OpenApi tags and x-tagGroups). +/// Tags should be specified via and this filter will map this to the +/// . +/// +public static class ApiTagsFilter +{ + /// + /// The main operation filter that will map every . + /// + /// The processor context, this is given by the AddOperationFilter method. + /// This always return true since it should not remove operations. + public static bool OperationFilter(OperationProcessorContext context) + { + ApiDefinitionAttribute def = + context.ControllerType.GetCustomAttribute(); + string name = def?.Name ?? context.ControllerType.Name; + + ApiDefinitionAttribute methodOverride = + context.MethodInfo.GetCustomAttribute(); + if (methodOverride != null) + name = methodOverride.Name; + + context.OperationDescription.Operation.Tags.Add(name); + if (context.Document.Tags.All(x => x.Name != name)) + { + context.Document.Tags.Add( + new OpenApiTag + { + Name = name, + Description = context.ControllerType.GetXmlDocsSummary() + } + ); + } + + if (def?.Group == null) + return true; + + context.Document.ExtensionData ??= new Dictionary(); + context.Document.ExtensionData.TryAdd("x-tagGroups", new List()); + List obj = (List)context.Document.ExtensionData["x-tagGroups"]; + TagGroups existing = obj.FirstOrDefault(x => x.Name == def.Group); + if (existing != null) + { + if (!existing.Tags.Contains(def.Name)) + existing.Tags.Add(def.Name); + } + else + { + obj.Add( + new TagGroups + { + Name = def.Group, + Tags = new List { def.Name } + } + ); + } + + return true; + } + + /// + /// This add every tags that are not in a x-tagGroups to a new tagGroups named "Other". + /// Since tags that are not in a tagGroups are not shown, this is necessary if you want them displayed. + /// + /// + /// The document to do this for. This should be done in the PostProcess part of the document or after + /// the main operation filter (see ) has finished. + /// + public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess) + { + List tagGroups = (List)postProcess.ExtensionData["x-tagGroups"]; + List tagsWithoutGroup = postProcess + .Tags.Select(x => x.Name) + .Where(x => tagGroups.SelectMany(y => y.Tags).All(y => y != x)) + .ToList(); + if (tagsWithoutGroup.Any()) + { + tagGroups.Add(new TagGroups { Name = "Others", Tags = tagsWithoutGroup }); + } + } + + /// + /// Use to create tags and groups of tags on the resulting swagger + /// document. + /// + /// The settings of the swagger document. + public static void UseApiTags(this AspNetCoreOpenApiDocumentGeneratorSettings options) + { + options.AddOperationFilter(OperationFilter); + options.PostProcess += x => x.AddLeftoversToOthersGroup(); + } +} diff --git a/src/Kyoo.Swagger/GenericResponseProvider.cs b/src/Kyoo.Swagger/GenericResponseProvider.cs new file mode 100644 index 0000000..a3aeaf7 --- /dev/null +++ b/src/Kyoo.Swagger/GenericResponseProvider.cs @@ -0,0 +1,68 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Utils; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace Kyoo.Swagger; + +/// +/// A filter that change 's +/// that where set to to the +/// return type of the method. +/// +/// +/// This is only useful when the return type of the method is a generics type and that can't be specified in the +/// attribute directly (since attributes don't support generics). This should not be used otherwise. +/// +public class GenericResponseProvider : IApplicationModelProvider +{ + /// + public int Order => -1; + + /// + public void OnProvidersExecuted(ApplicationModelProviderContext context) { } + + /// + public void OnProvidersExecuting(ApplicationModelProviderContext context) + { + foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions)) + { + IEnumerable responses = action + .Filters.OfType() + .Where(x => x.Type == typeof(ActionResult<>)); + foreach (ProducesResponseTypeAttribute response in responses) + { + Type type = action.ActionMethod.ReturnType; + type = + Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0] + ?? type; + type = + Utility + .GetGenericDefinition(type, typeof(ActionResult<>)) + ?.GetGenericArguments()[0] ?? type; + response.Type = type; + } + } + } +} diff --git a/src/Kyoo.Swagger/Kyoo.Swagger.csproj b/src/Kyoo.Swagger/Kyoo.Swagger.csproj new file mode 100644 index 0000000..c1d504f --- /dev/null +++ b/src/Kyoo.Swagger/Kyoo.Swagger.csproj @@ -0,0 +1,12 @@ + + + Kyoo.Swagger + Kyoo.Swagger + disable + + + + + + + diff --git a/src/Kyoo.Swagger/Models/TagGroups.cs b/src/Kyoo.Swagger/Models/TagGroups.cs new file mode 100644 index 0000000..e16d458 --- /dev/null +++ b/src/Kyoo.Swagger/Models/TagGroups.cs @@ -0,0 +1,41 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using Newtonsoft.Json; +using NSwag; + +namespace Kyoo.Swagger.Models; + +/// +/// A class representing a group of tags in the +/// +public class TagGroups +{ + /// + /// The name of the tag group. + /// + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } + + /// + /// The list of tags in this group. + /// + [JsonProperty(PropertyName = "tags")] + public List Tags { get; set; } +} diff --git a/src/Kyoo.Swagger/OperationPermissionProcessor.cs b/src/Kyoo.Swagger/OperationPermissionProcessor.cs new file mode 100644 index 0000000..d56727a --- /dev/null +++ b/src/Kyoo.Swagger/OperationPermissionProcessor.cs @@ -0,0 +1,102 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Kyoo.Abstractions.Models.Permissions; +using NSwag; +using NSwag.Generation.Processors; +using NSwag.Generation.Processors.Contexts; + +namespace Kyoo.Swagger; + +/// +/// An operation processor that adds permissions information from the and the +/// . +/// +public class OperationPermissionProcessor : IOperationProcessor +{ + /// + public bool Process(OperationProcessorContext context) + { + context.OperationDescription.Operation.Security ??= new List(); + OpenApiSecurityRequirement perms = context + .MethodInfo.GetCustomAttributes() + .Aggregate( + new OpenApiSecurityRequirement(), + (agg, _) => + { + agg[nameof(Kyoo)] = Array.Empty(); + return agg; + } + ); + + perms = context + .MethodInfo.GetCustomAttributes() + .Aggregate( + perms, + (agg, cur) => + { + ICollection permissions = _GetPermissionsList(agg, cur.Group); + permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}"); + agg[nameof(Kyoo)] = permissions; + return agg; + } + ); + + PartialPermissionAttribute controller = + context.ControllerType.GetCustomAttribute(); + if (controller != null) + { + perms = context + .MethodInfo.GetCustomAttributes() + .Aggregate( + perms, + (agg, cur) => + { + Group? group = + controller.Group != Group.Overall ? controller.Group : cur.Group; + string type = controller.Type ?? cur.Type; + Kind? kind = controller.Type == null ? controller.Kind : cur.Kind; + ICollection permissions = _GetPermissionsList( + agg, + group ?? Group.Overall + ); + permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}"); + agg[nameof(Kyoo)] = permissions; + return agg; + } + ); + } + + context.OperationDescription.Operation.Security.Add(perms); + return true; + } + + private static ICollection _GetPermissionsList( + OpenApiSecurityRequirement security, + Group group + ) + { + return security.TryGetValue(group.ToString(), out IEnumerable perms) + ? perms.ToList() + : new List(); + } +} diff --git a/src/Kyoo.Swagger/SwaggerModule.cs b/src/Kyoo.Swagger/SwaggerModule.cs new file mode 100644 index 0000000..f1e4077 --- /dev/null +++ b/src/Kyoo.Swagger/SwaggerModule.cs @@ -0,0 +1,114 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Reflection; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.Extensions.DependencyInjection; +using NJsonSchema; +using NJsonSchema.Generation.TypeMappers; +using NSwag; +using NSwag.Generation.AspNetCore; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Swagger; + +public static class SwaggerModule +{ + public static void ConfigureOpenApi(this IServiceCollection services) + { + services.AddTransient(); + services.AddOpenApiDocument(document => + { + document.Title = "Kyoo API"; + // TODO use a real multi-line description in markdown. + document.Description = "The Kyoo's public API"; + document.Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString(3); + document.DocumentName = "v1"; + document.UseControllerSummaryAsTagDescription = true; + document.PostProcess = options => + { + options.Info.Contact = new OpenApiContact + { + Name = "Kyoo's github", + Url = "https://github.com/zoriya/Kyoo" + }; + options.Info.License = new OpenApiLicense + { + Name = "GPL-3.0-or-later", + Url = "https://github.com/zoriya/Kyoo/blob/master/LICENSE" + }; + + options.Info.ExtensionData ??= new Dictionary(); + options.Info.ExtensionData["x-logo"] = new + { + url = "/banner.png", + backgroundColor = "#FFFFFF", + altText = "Kyoo's logo" + }; + }; + document.UseApiTags(); + document.SortApis(); + document.AddOperationFilter(x => + { + if (x is AspNetCoreOperationProcessorContext ctx) + return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order + != AlternativeRoute; + return true; + }); + document.SchemaSettings.TypeMappers.Add( + new PrimitiveTypeMapper( + typeof(Identifier), + x => + { + x.IsNullableRaw = false; + x.Type = JsonObjectType.String | JsonObjectType.Integer; + } + ) + ); + + document.AddSecurity( + nameof(Kyoo), + new OpenApiSecurityScheme + { + Type = OpenApiSecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + Description = "The user's bearer" + } + ); + document.OperationProcessors.Add(new OperationPermissionProcessor()); + }); + } + + public static void UseKyooOpenApi(this IApplicationBuilder app) + { + app.UseOpenApi(); + app.UseReDoc(x => + { + x.Path = "/doc"; + x.TransformToExternalPath = (internalUiRoute, _) => "/api" + internalUiRoute; + x.AdditionalSettings["theme"] = new + { + colors = new { primary = new { main = "#e13e13" } } + }; + }); + } +} diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 0000000..4a27621 --- /dev/null +++ b/stylecop.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "copyrightText": "Kyoo - A portable and vast media library solution.\nCopyright (c) Kyoo.\n\nSee AUTHORS.md and LICENSE file in the project root for full license information.\n\nKyoo is free software: you can redistribute it and/or modify\nit under the terms of the GNU General Public License as published by\nthe Free Software Foundation, either version 3 of the License, or\nany later version.\n\nKyoo is distributed in the hope that it will be useful,\nbut WITHOUT ANY WARRANTY; without even the implied warranty of\nMERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\nGNU General Public License for more details.\n\nYou should have received a copy of the GNU General Public License\nalong with Kyoo. If not, see .", + "xmlHeader": false + } + } +} diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..f7275bb --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +venv/ diff --git a/tests/auth/auth.robot b/tests/auth/auth.robot new file mode 100644 index 0000000..02b7617 --- /dev/null +++ b/tests/auth/auth.robot @@ -0,0 +1,82 @@ +*** Settings *** +Documentation Tests of the /auth route. +... Ensures that the user can authenticate on kyoo. + +Resource ../rest.resource + + +*** Keywords *** +Login + [Documentation] Shortcut to login with the given username for future requests + [Arguments] ${username} + &{res}= POST /auth/login {"username": "${username}", "password": "password-${username}"} + Output + Integer response status 200 + String response body access_token + Set Headers {"Authorization": "Bearer ${res.body.access_token}"} + +Register + [Documentation] Shortcut to register with the given username for future requests + [Arguments] ${username} + &{res}= POST + ... /auth/register + ... {"username": "${username}", "password": "password-${username}", "email": "${username}@kyoo.moe"} + Output + Integer response status 200 + String response body access_token + Set Headers {"Authorization": "Bearer ${res.body.access_token}"} + +Logout + [Documentation] Logout the current user, only the local client is affected. + Set Headers {"Authorization": ""} + + +*** Test Cases *** +Me cant be accessed without an account + Get /auth/me + Output + Integer response status 401 + +Bad Account + [Documentation] Login fails if user does not exist + POST /auth/login {"username": "i-don-t-exist", "password": "pass"} + Output + Integer response status 403 + +Register + [Documentation] Create a new user and login in it + Register user-1 + [Teardown] DELETE /auth/me + +Register Duplicates + [Documentation] If two users tries to register with the same username, it fails + Register user-duplicate + # We can't use the `Register` keyword because it assert for success + POST /auth/register {"username": "user-duplicate", "password": "pass", "email": "mail@kyoo.moe"} + Output + Integer response status 409 + [Teardown] DELETE /auth/me + +Delete Account + [Documentation] Check if a user can delete it's account + Register I-should-be-deleted + DELETE /auth/me + Output + Integer response status 204 + +Login + [Documentation] Create a new user and login in it + Register login-user + ${res}= GET /auth/me + Output + Integer response status 200 + String response body username login-user + + Logout + Login login-user + ${me}= Get /auth/me + Output + Output ${me} + Should Be Equal As Strings ${res["body"]} ${me["body"]} + + [Teardown] DELETE /auth/me diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..e5e1011 --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,4 @@ +[tool.robotidy] +configure = [ + "MergeAndOrderSections:order=comments,settings,keywords,variables,testcases" +] diff --git a/tests/rest.resource b/tests/rest.resource new file mode 100644 index 0000000..348bbc0 --- /dev/null +++ b/tests/rest.resource @@ -0,0 +1,4 @@ +*** Settings *** +Documentation Common things to handle rest requests + +Library REST http://localhost:8901/api