commit 75595d1136e32a17faf156d0eb9b577ccf2bdf9d Author: Zoe Roux Date: Sun Oct 26 10:50:05 2025 +0100 Initial copy 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 0000000..8a6ef23 Binary files /dev/null and b/icon.ico differ 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